From be99fdec790d5dddaa6429bd148a43d43c6b1a36 Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Wed, 23 Apr 2025 20:48:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AF=84=E6=9F=A5=E8=AF=A6?= =?UTF-8?q?=E6=83=85=EF=BC=8C=E6=96=B0=E5=A2=9E=E4=BF=A1=E6=81=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=A1=86=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/evaluation_points/reviews.ts | 92 +-- app/components/reviews/ReviewPointsList.tsx | 69 +-- app/components/rules/new/ActionButtons.tsx | 14 +- app/components/ui/Modal.tsx | 137 ++++- app/components/ui/Toast.tsx | 23 +- app/routes/config-lists._index.tsx | 241 ++++---- app/routes/config-lists.new.tsx | 621 ++++++++++++++------ app/routes/documents._index.tsx | 239 ++++---- app/routes/documents.edit.tsx | 235 +++++--- app/routes/rule-groups.new.tsx | 3 +- app/routes/rules-files.tsx | 78 +-- app/routes/rules._index.tsx | 235 ++++---- app/styles/components/modal.css | 164 ++++++ app/styles/main.css | 1 + app/styles/pages/documents_edit.css | 4 + 15 files changed, 1399 insertions(+), 757 deletions(-) create mode 100644 app/styles/components/modal.css diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index a8b7257..84bfed3 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -251,61 +251,61 @@ export async function getReviewPoints(fileId: string) { } // 提取页码数组 - let contentPage: Record = {}; + let contentPage: Record = {}; // console.log('result-------', result.evaluated_results?.result); // console.log('datacontent-------', data); if (data && typeof data === 'object') { - try { - const dataObj = data as Record; - // 检查是否是预期的格式 {'立案报告表-完整性检查':'缺失部分内容'} - 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; + // // 检查是否是预期的格式 {'立案报告表-完整性检查':'缺失部分内容'} + // 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; - // 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; + 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 = {}; diff --git a/app/components/reviews/ReviewPointsList.tsx b/app/components/reviews/ReviewPointsList.tsx index 0478cba..2a5a785 100644 --- a/app/components/reviews/ReviewPointsList.tsx +++ b/app/components/reviews/ReviewPointsList.tsx @@ -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; + content: Record; suggestion: string; needsHumanReview?: boolean; humanReviewNote?: string; humanReviewBy?: string; humanReviewTime?: string; - contentPage?: Record; + contentPage?: Record; 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}内容详情`} >
- {key} - - {value ? '' : '缺失'} + {key} + + {value.value?.toString().trim() ? '' : '缺失'}
+

+ {(value.value?.toString().trim() === '') + ? 占位符 + : value.value?.toString() || ''} +

))} @@ -551,7 +560,7 @@ export function ReviewPointsList({ {reviewPoint.content && Object.entries(reviewPoint.content).length > 0 && (
{/* 修改评查结果的结构之前,先显示旧的结构 */} - {Object.entries(reviewPoint.content).map(([key, value], index) => ( + {/* {Object.entries(reviewPoint.content).map(([key, value], index) => (
- {/* 使用flex布局使key和状态标签左右对齐 */}
{key} @@ -597,9 +605,9 @@ export function ReviewPointsList({ : (value || (value === '' ? 占位符 : ''))}

- ))} + ))} */} {/* 修改评查结果的结构之后,显示新的结构 */} - {/* {renderContent(reviewPoint)} */} + {renderContent(reviewPoint)}
)} @@ -655,7 +663,7 @@ export function ReviewPointsList({
{/* 修改评查结果的结构之前,先显示旧的结构 */} - {Object.entries(reviewPoint.content).map(([key, value], index) => ( + {/* {Object.entries(reviewPoint.content).map(([key, value], index) => (
- {/* 使用flex布局使key和状态标签左右对齐 */} +
{key} @@ -701,19 +709,15 @@ export function ReviewPointsList({ : (value || (value === '' ? 占位符 : ''))}

- ))} + ))} */} {/* 修改评查结果的结构之后,显示新的结构 */} - {/* {renderContent(reviewPoint)} */} + {renderContent(reviewPoint)}
); } - // 非通过状态,显示内容和修改建议 - const isErrorStatus = reviewPoint.result === false && reviewPoint.status === 'error'; - - return (
@@ -774,7 +778,7 @@ export function ReviewPointsList({
{/* 修改评查结果的结构之前,先显示旧的结构 */} - {Object.entries(reviewPoint.content).map(([key, value], index) => ( + {/* {Object.entries(reviewPoint.content).map(([key, value], index) => (
- {/* 使用flex布局使key和状态标签左右对齐 */}
{key} @@ -823,9 +826,9 @@ export function ReviewPointsList({ : (value || (value === '' ? 占位符 : ''))}

- ))} + ))} */} {/* 修改评查结果的结构之后,显示新的结构 */} - {/* {renderContent(reviewPoint)} */} + {renderContent(reviewPoint)}
@@ -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 }; diff --git a/app/components/rules/new/ActionButtons.tsx b/app/components/rules/new/ActionButtons.tsx index 0de9c4f..df3ad25 100644 --- a/app/components/rules/new/ActionButtons.tsx +++ b/app/components/rules/new/ActionButtons.tsx @@ -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 > {isEditMode ? '另存为草稿' : '保存草稿'} - + 返回 +
); } \ No newline at end of file diff --git a/app/components/ui/Modal.tsx b/app/components/ui/Modal.tsx index 5372d12..7734ab0 100644 --- a/app/components/ui/Modal.tsx +++ b/app/components/ui/Modal.tsx @@ -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(null); + // 保存之前的活动元素,以便关闭时恢复焦点 + const previousActiveElement = useRef(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 ( -
{ - if (e.target === e.currentTarget) onClose(); - }} + aria-hidden="true" >
e.stopPropagation()} + ref={contentRef} + className={`modal-content ${sizeClass} ${className}`} + style={widthStyle} + tabIndex={-1} + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" >
-

{title}

+
-
+
{children}
{footer && ( -
+
{footer}
)}
+ {/* 背景点击处理 */} +
); } \ No newline at end of file diff --git a/app/components/ui/Toast.tsx b/app/components/ui/Toast.tsx index e547d08..ab33d4e 100644 --- a/app/components/ui/Toast.tsx +++ b/app/components/ui/Toast.tsx @@ -54,6 +54,7 @@ export function Toast({ const [isClosing, setIsClosing] = useState(false); const [portalElement, setPortalElement] = useState(null); const [messageLines, setMessageLines] = useState(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(
3 ? 'toast-multiline' : ''}`} - role="alert" - aria-live="assertive" - onKeyDown={handleKeyDown} - tabIndex={0} + role="status" + aria-live="polite" + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} >
@@ -155,6 +165,7 @@ export function Toast({ className="toast-close" onClick={handleClose} aria-label="关闭" + onKeyDown={handleKeyDown} > diff --git a/app/routes/config-lists._index.tsx b/app/routes/config-lists._index.tsx index 764abc5..0c56c4e 100644 --- a/app/routes/config-lists._index.tsx +++ b/app/routes/config-lists._index.tsx @@ -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.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({ + 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 ( -
-

出错了

-

加载配置列表时发生错误。请稍后再试,或联系管理员。

- -
- ); -} + export default function ConfigListsIndex() { - const { configs, totalCount, currentPage, pageSize, types, environments } = useLoaderData(); + const { configs, totalCount, currentPage, pageSize, types, environments, error } = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); - const submit = useSubmit(); + const fetcher = useFetcher(); const [showDetailModal, setShowDetailModal] = useState(false); const [selectedConfig, setSelectedConfig] = useState(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) => { 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 && ( -
-
-
-

查看配置详情

- + 关闭 + } + > +
+
+
配置名称
+
{selectedConfig.name}
-
-
-
配置名称
-
{selectedConfig.name}
-
- -
-
所属模块
-
{selectedConfig.type}
-
- -
-
环境
-
- - {selectedConfig.environment} - -
-
- -
-
状态
-
- - {selectedConfig.is_active ? '已启用' : '已禁用'} - -
-
- -
-
配置数据
-
-                  {JSON.stringify(selectedConfig.config, null, 2)}
-                
-
- -
-
-
创建时间
-
{selectedConfig.created_at}
-
- -
-
更新时间
-
{selectedConfig.updated_at}
-
+
+
所属模块
+
{selectedConfig.type}
+
+ +
+
环境
+
+ + {selectedConfig.environment} +
-
- +
+
状态
+
+ + {selectedConfig.is_active ? '已启用' : '已禁用'} + +
+
+ +
+
配置数据
+
+                {JSON.stringify(selectedConfig.config, null, 2)}
+              
+
+ +
+
+
创建时间
+
{selectedConfig.created_at}
+
+ +
+
更新时间
+
{selectedConfig.updated_at}
+
-
+ )}
); } + +// 错误边界 +export function ErrorBoundary() { + return ( +
+

出错了

+

加载配置列表时发生错误。请稍后再试,或联系管理员。

+ +
+ ); +} \ No newline at end of file diff --git a/app/routes/config-lists.new.tsx b/app/routes/config-lists.new.tsx index 7689981..81a47dd 100644 --- a/app/routes/config-lists.new.tsx +++ b/app/routes/config-lists.new.tsx @@ -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 = { [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; } // 配置模板常量 @@ -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 + }); + } + + 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 + }); + } +} + + export default function ConfigNew() { - const { config, isEdit, types, environments } = useLoaderData(); + const data = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; + const formRef = useRef(null); - const [jsonError, setJsonError] = useState(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(""); - const [selectedEnvironment, setSelectedEnvironment] = useState(""); - - // 在 ConfigNew 组件中添加状态来跟踪当前选中的模板 const [selectedTemplate, setSelectedTemplate] = useState(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) => { + 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) => { - 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) => { + 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 ( +
+

加载出错

+

{error}

+ +
+ ); + } + return (
@@ -302,14 +555,20 @@ export default function ConfigNew() {
- {actionData?.errors?.general && ( + {formErrors.general && (
-
{actionData.errors.general}
+
{formErrors.general}
)} -
+ {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 && ( -
{actionData.errors.name}
+ {touchedFields.name && formErrors.name && ( +
{formErrors.name}
)}
唯一标识符,配置名称应使用英文,推荐使用下划线命名方式 @@ -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} />
{/* 预览窗口 */} - {loadError ?(
- {loadError} -
):( + {loadError ? ( +
+ {loadError} +
+ ) : ( renderDocumentContent() )}
diff --git a/app/routes/rule-groups.new.tsx b/app/routes/rule-groups.new.tsx index f2df006..1206074 100644 --- a/app/routes/rule-groups.new.tsx +++ b/app/routes/rule-groups.new.tsx @@ -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() === "") { diff --git a/app/routes/rules-files.tsx b/app/routes/rules-files.tsx index 14a10bc..25a7bbe 100644 --- a/app/routes/rules-files.tsx +++ b/app/routes/rules-files.tsx @@ -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 = { '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 ( -
-

出错了

-

加载评查文件列表时发生错误。请稍后再试,或联系管理员。

- -
- ); -} - -// 在文件中定义一个与路由文件名匹配的命名函数组件 export default function RulesFiles() { const navigate = useNavigate(); - const { files, documentTypes, totalCount, currentPage, pageSize } = useLoaderData(); + const { files, documentTypes, totalCount, currentPage, pageSize, result, message } = useLoaderData(); 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) => { 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 (
所有评查点均通过 @@ -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() {
); +} + +// 错误边界 +export function ErrorBoundary() { + return ( +
+

出错了

+

加载评查文件列表时发生错误。请稍后再试,或联系管理员。

+ +
+ ); } \ No newline at end of file diff --git a/app/routes/rules._index.tsx b/app/routes/rules._index.tsx index 01579e6..6fcf4ff 100644 --- a/app/routes/rules._index.tsx +++ b/app/routes/rules._index.tsx @@ -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 ( -
-

出错了

-

加载评查点列表时发生错误。请稍后再试,或联系管理员。

- -
- ); -} // 规则优先级的描述标签映射 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(); + // 状态管理 - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [ruleToDelete, setRuleToDelete] = useState(null); const [ruleGroups, setRuleGroups] = useState([]); 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) => { 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() {
{/* 筛选区域 */} - + + + + } + > @@ -552,19 +533,17 @@ export default function RulesIndex() { )} - {/* 删除确认对话框 */} - {showDeleteConfirm && ruleToDelete && ( -
-
-

确认删除

-

确定要删除评查点“{ruleToDelete.name}”吗?

-
- - -
-
-
- )}
); -} \ No newline at end of file +} + +// 错误边界 +export function ErrorBoundary() { + return ( +
+

出错了

+

加载评查点列表时发生错误。请稍后再试,或联系管理员。

+ +
+ ); +} \ No newline at end of file diff --git a/app/styles/components/modal.css b/app/styles/components/modal.css new file mode 100644 index 0000000..46c317c --- /dev/null +++ b/app/styles/components/modal.css @@ -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; + } +} \ No newline at end of file diff --git a/app/styles/main.css b/app/styles/main.css index 440801c..a5d7f86 100644 --- a/app/styles/main.css +++ b/app/styles/main.css @@ -5,6 +5,7 @@ /* 导入组件样式 */ @import './components/badge.css'; +@import './components/modal.css'; @import './components/button.css'; @import './components/card.css'; @import './components/form.css'; diff --git a/app/styles/pages/documents_edit.css b/app/styles/pages/documents_edit.css index 6bee404..99c6755 100644 --- a/app/styles/pages/documents_edit.css +++ b/app/styles/pages/documents_edit.css @@ -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; +}