feat: 生成一个结果统计的组件。

This commit is contained in:
2025-11-20 16:19:48 +08:00
parent 6dc9b4e468
commit 2e604e8ede
2 changed files with 555 additions and 0 deletions
+318
View File
@@ -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>
);
}
+237
View File
@@ -0,0 +1,237 @@
/**
* 结果统计组件样式
* 用于显示文档评审结果的多维度统计信息
*/
.result-stats-wrapper {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.result-stat-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
background-color: #f3f4f6;
border: 1px solid #e5e7eb;
}
.result-stat-item i {
font-size: 13px;
flex-shrink: 0;
}
.result-stat-item .stat-label {
font-weight: 500;
flex-shrink: 0;
color: #6b7280;
}
.result-stat-item .stat-content {
display: inline-flex;
align-items: center;
gap: 3px;
}
.result-stat-item .stat-value {
font-size: 13px;
font-weight: 600;
min-width: 16px;
text-align: center;
color: #374151;
}
.result-stat-item .stat-diff {
display: inline-flex;
align-items: center;
gap: 1px;
font-size: 11px;
padding: 1px 3px;
border-radius: 3px;
font-weight: 500;
}
.result-stat-item .stat-diff i {
font-size: 11px;
}
/* 可点击状态 */
.result-stat-item.clickable {
cursor: pointer;
transition: all 0.2s ease;
}
.result-stat-item.clickable:hover {
background-color: #e5e7eb;
border-color: #d1d5db;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 气泡框样式 */
.stat-popover {
min-width: 280px;
max-width: 400px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
animation: popoverFadeIn 0.2s ease;
pointer-events: auto;
}
@keyframes popoverFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* 箭头指示 */
.stat-popover::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
width: 12px;
height: 12px;
background: white;
border-left: 1px solid #e5e7eb;
border-top: 1px solid #e5e7eb;
transform: translateX(-50%) rotate(45deg);
z-index: 1;
}
.stat-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 16px;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
font-size: 14px;
color: #374151;
position: relative;
z-index: 2;
background: white;
border-radius: 6px 6px 0 0;
}
.stat-popover-close {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
transition: color 0.2s;
}
.stat-popover-close:hover {
color: #374151;
}
.stat-popover-close i {
font-size: 16px;
}
.stat-popover-content {
padding: 8px;
max-height: 300px;
overflow-y: auto;
position: relative;
z-index: 2;
background: white;
border-radius: 0 0 6px 6px;
}
.stat-popover-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px;
font-size: 13px;
color: #4b5563;
line-height: 1.5;
border-radius: 4px;
transition: background-color 0.2s;
}
.stat-popover-item:hover {
background-color: #f9fafb;
}
.stat-popover-item i {
color: #9ca3af;
flex-shrink: 0;
margin-top: 2px;
font-size: 8px;
}
.stat-popover-item span {
flex: 1;
word-break: break-word;
}
.stat-popover-item .message-count {
color: #9ca3af;
font-size: 11px;
font-weight: 500;
margin-left: 4px;
}
/* 气泡框滚动条样式 */
.stat-popover-content::-webkit-scrollbar {
width: 6px;
}
.stat-popover-content::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 3px;
}
.stat-popover-content::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.stat-popover-content::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 响应式布局 */
@media (max-width: 768px) {
.result-stats-wrapper {
gap: 4px;
}
.result-stat-item {
padding: 2px 6px;
font-size: 11px;
}
.result-stat-item i {
font-size: 12px;
}
.result-stat-item .stat-value {
font-size: 12px;
}
.result-stat-item .stat-diff {
font-size: 10px;
}
}