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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user