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