优化评查详情,新增信息提示框组件
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user