319 lines
8.6 KiB
TypeScript
319 lines
8.6 KiB
TypeScript
/**
|
||
* 文档评审结果统计显示组件
|
||
* 显示通过、警告、错误、人工四个维度的统计数据及版本差异
|
||
*/
|
||
|
||
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>
|
||
);
|
||
}
|