diff --git a/app/components/ui/ResultStats.tsx b/app/components/ui/ResultStats.tsx new file mode 100644 index 0000000..063730f --- /dev/null +++ b/app/components/ui/ResultStats.tsx @@ -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(null); + const popoverRef = useRef(null); + + // 定义图标颜色映射 + const iconColorMap: Record = { + 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 ( +
+ + {label} + - +
+ ); + } + + // 去重并计算重复次数 + const deduplicateMessages = (msgs: string[]): Array<{ text: string; count: number }> => { + const messageMap = new Map(); + + 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 && ( +
e.stopPropagation()} + > +
+ {label}详情 + +
+
+ {deduplicateMessages(messages).map((item, index) => ( +
+ + + {item.text} + {item.count > 1 && (×{item.count})} + +
+ ))} +
+
+ ); + + return ( + <> +
0 ? 'clickable' : ''}`} + ref={itemRef} + onClick={handleClick} + > + + {label} +
+ {current} + {diffResult && ( + + {diffResult.type === 'increase' && ( + <> + + +{diffResult.diff} + + )} + {diffResult.type === 'decrease' && ( + <> + + -{diffResult.diff} + + )} + {diffResult.type === 'same' && ( + <> + + 0 + + )} + + )} +
+
+ + {/* 使用 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 ( +
+ {stats.map((stat, index) => ( + + ))} +
+ ); +} diff --git a/app/styles/components/result-stats.css b/app/styles/components/result-stats.css new file mode 100644 index 0000000..4dbdb36 --- /dev/null +++ b/app/styles/components/result-stats.css @@ -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; + } +}