feat: 生成一个结果统计的组件。
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 文档评审结果统计显示组件
|
||||
* 显示通过、警告、错误、人工四个维度的统计数据及版本差异
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user