Files

319 lines
8.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 文档评审结果统计显示组件
* 显示通过、警告、错误、人工四个维度的统计数据及版本差异
*/
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface StatItem {
label: string;
current: number | null;
previous?: number | null;
color: string;
icon: string;
reverseColors?: boolean;
messages?: string[];
onClick?: () => void;
isClickable?: boolean;
}
interface ResultStatsProps {
passCount: number | null;
warningCount: number | null;
errorCount: number | null;
manualCount: number | null;
previousPassCount?: number | null;
previousWarningCount?: number | null;
previousErrorCount?: number | null;
previousManualCount?: number | null;
warningMessages?: string[];
errorMessages?: string[];
manualMessages?: string[];
className?: string;
}
/**
* 计算差异类型和差异值
*/
function calculateDiff(current: number | null, previous: number | null | undefined): {
diff: number;
type: 'increase' | 'decrease' | 'same';
} | null {
if (current === null || previous === null || previous === undefined) {
return null;
}
const diff = current - previous;
if (diff > 0) {
return { diff, type: 'increase' };
} else if (diff < 0) {
return { diff: Math.abs(diff), type: 'decrease' };
} else {
return { diff: 0, type: 'same' };
}
}
/**
* 单个统计项组件
*/
function StatItemComponent({
label,
current,
previous,
color,
icon,
reverseColors = false,
messages = [],
onClick,
isClickable = false
}: StatItem) {
const diffResult = calculateDiff(current, previous);
const [showPopover, setShowPopover] = useState(false);
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
const itemRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// 定义图标颜色映射
const iconColorMap: Record<string, string> = {
green: '#16a34a',
yellow: '#f59e0b',
red: '#dc2626',
blue: '#3b82f6'
};
const iconColor = iconColorMap[color] || '#6b7280';
// 根据是否反转颜色来确定差异显示的颜色
const getDiffColor = (type: 'increase' | 'decrease' | 'same'): string => {
if (type === 'same') return '#9ca3af';
if (reverseColors) {
// 对于通过数量:增加是好的(绿色),减少是坏的(红色)
return type === 'increase' ? '#059669' : '#dc2626';
} else {
// 对于错误/警告:增加是坏的(红色),减少是好的(绿色)
return type === 'increase' ? '#dc2626' : '#059669';
}
};
// 计算气泡框位置
const updatePopoverPosition = () => {
if (itemRef.current) {
const rect = itemRef.current.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
setPopoverPosition({
top: rect.bottom + scrollTop + 8,
left: rect.left + scrollLeft + rect.width / 2
});
}
};
// 点击外部关闭气泡框
useEffect(() => {
if (!showPopover) return;
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
itemRef.current &&
!itemRef.current.contains(event.target as Node)
) {
setShowPopover(false);
}
};
const handleScroll = () => {
updatePopoverPosition();
};
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleScroll);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleScroll);
};
}, [showPopover]);
const handleClick = () => {
if (isClickable && messages.length > 0) {
updatePopoverPosition();
setShowPopover(!showPopover);
onClick?.();
}
};
// 如果当前值为 null,显示 "-"
if (current === null) {
return (
<div className="result-stat-item" ref={itemRef}>
<i className={icon} style={{ color: iconColor }}></i>
<span className="stat-label">{label}</span>
<span className="stat-value" style={{ color: '#9ca3af' }}>-</span>
</div>
);
}
// 去重并计算重复次数
const deduplicateMessages = (msgs: string[]): Array<{ text: string; count: number }> => {
const messageMap = new Map<string, number>();
msgs.forEach(msg => {
const count = messageMap.get(msg) || 0;
messageMap.set(msg, count + 1);
});
return Array.from(messageMap.entries()).map(([text, count]) => ({ text, count }));
};
const popoverContent = showPopover && messages.length > 0 && (
<div
ref={popoverRef}
className="stat-popover"
style={{
position: 'absolute',
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`,
transform: 'translateX(-50%)',
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
<div className="stat-popover-header">
<span>{label}</span>
<button
className="stat-popover-close"
onClick={() => setShowPopover(false)}
>
<i className="ri-close-line"></i>
</button>
</div>
<div className="stat-popover-content">
{deduplicateMessages(messages).map((item, index) => (
<div key={index} className="stat-popover-item">
<i className="ri-checkbox-blank-circle-fill"></i>
<span>
{item.text}
{item.count > 1 && <span className="message-count"> (×{item.count})</span>}
</span>
</div>
))}
</div>
</div>
);
return (
<>
<div
className={`result-stat-item ${isClickable && messages.length > 0 ? 'clickable' : ''}`}
ref={itemRef}
onClick={handleClick}
>
<i className={icon} style={{ color: iconColor }}></i>
<span className="stat-label">{label}</span>
<div className="stat-content">
<span className="stat-value">{current}</span>
{diffResult && (
<span className="stat-diff" style={{ color: getDiffColor(diffResult.type) }}>
{diffResult.type === 'increase' && (
<>
<i className="ri-arrow-up-line"></i>
<span>+{diffResult.diff}</span>
</>
)}
{diffResult.type === 'decrease' && (
<>
<i className="ri-arrow-down-line"></i>
<span>-{diffResult.diff}</span>
</>
)}
{diffResult.type === 'same' && (
<>
<i className="ri-subtract-line"></i>
<span>0</span>
</>
)}
</span>
)}
</div>
</div>
{/* 使用 Portal 将气泡框渲染到 body */}
{typeof document !== 'undefined' && popoverContent && createPortal(popoverContent, document.body)}
</>
);
}
/**
* 结果统计组件
*/
export function ResultStats({
passCount,
warningCount,
errorCount,
manualCount,
previousPassCount,
previousWarningCount,
previousErrorCount,
previousManualCount,
warningMessages = [],
errorMessages = [],
manualMessages = [],
className = ''
}: ResultStatsProps) {
const stats: StatItem[] = [
{
label: '通过',
current: passCount,
previous: previousPassCount,
color: 'green',
icon: 'ri-check-line',
reverseColors: true, // 通过数量增加是好的,减少是坏的
messages: [],
isClickable: false
},
{
label: '警告',
current: warningCount,
previous: previousWarningCount,
color: 'yellow',
icon: 'ri-alert-line',
messages: warningMessages,
isClickable: warningMessages.length > 0
},
{
label: '错误',
current: errorCount,
previous: previousErrorCount,
color: 'red',
icon: 'ri-close-circle-line',
messages: errorMessages,
isClickable: errorMessages.length > 0
},
{
label: '人工',
current: manualCount,
previous: previousManualCount,
color: 'blue',
icon: 'ri-user-line',
messages: manualMessages,
isClickable: manualMessages.length > 0
}
];
return (
<div className={`result-stats-wrapper ${className}`}>
{stats.map((stat, index) => (
<StatItemComponent key={index} {...stat} />
))}
</div>
);
}