feat(rvw): Complete V2.0 Week 3 - Statistical validation extension and UX improvements
Week 3 Development Summary: - Implement negative sign normalization (6 Unicode variants) - Enhance T-test validation with smart sample size extraction - Enhance SE triangle and CI-P consistency validation with subrow support - Add precise sub-cell highlighting for P-values in multi-line cells - Add frontend issue type Chinese translations (6 new types) - Add file format tips for PDF/DOC uploads Technical improvements: - Add _clean_statistical_text() in extractor.py - Add _safe_float() wrapper in validator.py - Add ForensicsReport.tsx component - Update ISSUE_TYPE_LABELS translations Documentation: - Add 2026-02-18 development record - Update RVW module status (v5.1) - Update system status (v5.2) Status: Week 3 complete, ready for Week 4 testing Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -66,7 +66,7 @@ export default function EditorialReport({ data }: EditorialReportProps) {
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-24 h-24 rounded-full border-4 ${grade.bg.replace('bg-', 'border-')} flex items-center justify-center bg-white shadow-lg`}>
|
||||
<div className="text-center">
|
||||
<span className={`text-3xl font-bold ${grade.color}`}>{data.overall_score}</span>
|
||||
<span className={`text-3xl font-bold ${grade.color}`}>{Number(data.overall_score).toFixed(1)}</span>
|
||||
<span className="text-xs text-slate-400 block">分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
487
frontend-v2/src/modules/rvw/components/ForensicsReport.tsx
Normal file
487
frontend-v2/src/modules/rvw/components/ForensicsReport.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* 数据验证报告组件
|
||||
* 展示 DataForensicsSkill 的表格验证结果
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info,
|
||||
Table2,
|
||||
FlaskConical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MousePointerClick
|
||||
} from 'lucide-react';
|
||||
import type { ForensicsResult, ForensicsIssue, ForensicsTable } from '../types';
|
||||
|
||||
interface ForensicsReportProps {
|
||||
data: ForensicsResult;
|
||||
}
|
||||
|
||||
// 统计方法英文 -> 中文映射
|
||||
const METHOD_NAMES: Record<string, string> = {
|
||||
'chi-square': '卡方检验',
|
||||
'mann-whitney': 'Mann-Whitney U 检验',
|
||||
't-test': 'T 检验',
|
||||
'anova': '方差分析',
|
||||
'fisher': 'Fisher 精确检验',
|
||||
'wilcoxon': 'Wilcoxon 检验',
|
||||
'kruskal-wallis': 'Kruskal-Wallis 检验',
|
||||
'mcnemar': 'McNemar 检验',
|
||||
'correlation': '相关性分析',
|
||||
'regression': '回归分析',
|
||||
'logistic': 'Logistic 回归',
|
||||
'cox': 'Cox 回归',
|
||||
'kaplan-meier': 'Kaplan-Meier 生存分析',
|
||||
};
|
||||
|
||||
// 问题类型代码 -> 中文描述映射
|
||||
const ISSUE_TYPE_LABELS: Record<string, string> = {
|
||||
// L1 算术验证
|
||||
'ARITHMETIC_PERCENT': '百分比计算错误',
|
||||
'ARITHMETIC_SUM': '合计计算错误',
|
||||
'ARITHMETIC_TOTAL': '总计行错误',
|
||||
'ARITHMETIC_MEAN': '均值计算错误',
|
||||
|
||||
// L2 统计验证
|
||||
'STAT_CHI2_PVALUE': '卡方检验 P 值',
|
||||
'STAT_TTEST_PVALUE': 'T 检验 P 值',
|
||||
'STAT_CI_PVALUE_CONFLICT': 'CI 与 P 值矛盾',
|
||||
|
||||
// L2.5 一致性取证
|
||||
'STAT_SE_TRIANGLE': 'SE 三角验证',
|
||||
'STAT_SD_GREATER_MEAN': 'SD 大于均值',
|
||||
'STAT_REGRESSION_CI_P': '回归 CI-P 不一致',
|
||||
|
||||
// 一致性检查
|
||||
'CONSISTENCY_DUPLICATE': '数据重复',
|
||||
'CONSISTENCY_MISMATCH': '数据不一致',
|
||||
|
||||
// 提取问题
|
||||
'EXTRACTION_WARNING': '提取警告',
|
||||
'TABLE_SKIPPED': '表格跳过',
|
||||
};
|
||||
|
||||
export default function ForensicsReport({ data }: ForensicsReportProps) {
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||
const [highlightedCell, setHighlightedCell] = useState<string | null>(null);
|
||||
|
||||
// 防御性检查:确保所有数组和对象存在
|
||||
const tables = data?.tables || [];
|
||||
const issues = data?.issues || [];
|
||||
const methods = data?.methods || [];
|
||||
const summary = data?.summary || { totalTables: 0, totalIssues: 0, errorCount: 0, warningCount: 0 };
|
||||
|
||||
// 创建 tableId -> caption 映射,用于显示友好的表格名称
|
||||
const tableIdToCaption: Record<string, string> = {};
|
||||
tables.forEach((t, idx) => {
|
||||
tableIdToCaption[t.id] = t.caption || `表格 ${idx + 1}`;
|
||||
});
|
||||
|
||||
// 获取表格的友好名称
|
||||
const getTableName = (tableId: string | undefined): string => {
|
||||
if (!tableId) return '';
|
||||
return tableIdToCaption[tableId] || tableId;
|
||||
};
|
||||
|
||||
// 翻译统计方法名称为中文
|
||||
const translateMethod = (method: string): string => {
|
||||
return METHOD_NAMES[method.toLowerCase()] || method;
|
||||
};
|
||||
|
||||
// 翻译问题类型代码为中文
|
||||
const translateIssueType = (type: string): string => {
|
||||
return ISSUE_TYPE_LABELS[type] || type;
|
||||
};
|
||||
|
||||
const toggleTable = (tableId: string) => {
|
||||
const newExpanded = new Set(expandedTables);
|
||||
if (newExpanded.has(tableId)) {
|
||||
newExpanded.delete(tableId);
|
||||
} else {
|
||||
newExpanded.add(tableId);
|
||||
}
|
||||
setExpandedTables(newExpanded);
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity: ForensicsIssue['severity']) => {
|
||||
switch (severity) {
|
||||
case 'ERROR':
|
||||
return <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />;
|
||||
case 'WARNING':
|
||||
return <AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />;
|
||||
case 'INFO':
|
||||
return <Info className="w-4 h-4 text-blue-500 flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColors = (severity: ForensicsIssue['severity']) => {
|
||||
switch (severity) {
|
||||
case 'ERROR':
|
||||
return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700' };
|
||||
case 'WARNING':
|
||||
return { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700' };
|
||||
case 'INFO':
|
||||
return { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700' };
|
||||
}
|
||||
};
|
||||
|
||||
const getOverallStatus = () => {
|
||||
if (summary.errorCount > 0) {
|
||||
return { label: '发现问题', color: 'text-red-600', bg: 'bg-red-500', icon: XCircle };
|
||||
}
|
||||
if (summary.warningCount > 0) {
|
||||
return { label: '需关注', color: 'text-amber-600', bg: 'bg-amber-500', icon: AlertTriangle };
|
||||
}
|
||||
return { label: '数据正常', color: 'text-green-600', bg: 'bg-green-500', icon: CheckCircle };
|
||||
};
|
||||
|
||||
const status = getOverallStatus();
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
const handleCellClick = (cellRef: string | undefined) => {
|
||||
if (cellRef) {
|
||||
setHighlightedCell(highlightedCell === cellRef ? null : cellRef);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* 总览卡片 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div className="flex items-start gap-8">
|
||||
{/* 状态图标 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-24 h-24 rounded-full border-4 ${status.bg.replace('bg-', 'border-')} flex items-center justify-center bg-white shadow-lg`}>
|
||||
<StatusIcon className={`w-12 h-12 ${status.color}`} />
|
||||
</div>
|
||||
<span className={`mt-2 px-3 py-1 rounded-full text-xs font-bold ${status.bg} text-white`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FlaskConical className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-bold text-lg text-slate-800">数据验证报告</h3>
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-4">
|
||||
已检测 {summary.totalTables} 张表格,发现 {summary.totalIssues} 个问题
|
||||
{methods.length > 0 && `,识别到统计方法:${methods.map(translateMethod).join('、')}`}
|
||||
</p>
|
||||
|
||||
{/* 统计指标 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-100 rounded-lg border border-slate-200">
|
||||
<Table2 className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">{summary.totalTables} 张表格</span>
|
||||
</div>
|
||||
{summary.errorCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 rounded-lg border border-red-100">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">{summary.errorCount} 个错误</span>
|
||||
</div>
|
||||
)}
|
||||
{summary.warningCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-700">{summary.warningCount} 个警告</span>
|
||||
</div>
|
||||
)}
|
||||
{summary.errorCount === 0 && summary.warningCount === 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">未发现问题</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 问题列表(按严重程度排序) */}
|
||||
{issues.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 bg-slate-50">
|
||||
<h3 className="font-bold text-base text-slate-800 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
发现的问题
|
||||
<span className="text-xs text-slate-400 bg-slate-200 px-2 py-0.5 rounded">
|
||||
共 {issues.length} 项
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{[...issues]
|
||||
.sort((a, b) => {
|
||||
const order = { ERROR: 0, WARNING: 1, INFO: 2 };
|
||||
return order[a.severity] - order[b.severity];
|
||||
})
|
||||
.map((issue, index) => {
|
||||
const colors = getSeverityColors(issue.severity);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-5 py-4 ${colors.bg} hover:brightness-95 transition-all cursor-pointer`}
|
||||
onClick={() => handleCellClick(issue.location?.cellRef)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon(issue.severity)}
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${colors.text}`}>{issue.message}</p>
|
||||
{issue.location && (
|
||||
<p className="text-xs text-slate-500 mt-1 flex items-center gap-1">
|
||||
<MousePointerClick className="w-3 h-3" />
|
||||
{issue.location.tableId && getTableName(issue.location.tableId)}
|
||||
{issue.location.cellRef && ` · 单元格 ${issue.location.cellRef}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${colors.bg} ${colors.text} border ${colors.border}`}>
|
||||
{translateIssueType(issue.type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格详情 */}
|
||||
{tables.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Table2 className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-bold text-base text-slate-800">表格详情</h3>
|
||||
<span className="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded">
|
||||
共 {tables.length} 张
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tables.map((table) => (
|
||||
<TableCard
|
||||
key={table.id}
|
||||
table={table}
|
||||
expanded={expandedTables.has(table.id)}
|
||||
onToggle={() => toggleTable(table.id)}
|
||||
highlightedCell={highlightedCell}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无表格提示 */}
|
||||
{tables.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500 bg-white rounded-xl border border-gray-100">
|
||||
<Table2 className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
||||
<p>未检测到表格数据</p>
|
||||
<p className="text-xs text-slate-400 mt-1">该文档可能不包含数据表格</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格卡片组件
|
||||
*/
|
||||
interface TableCardProps {
|
||||
table: ForensicsTable;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
highlightedCell: string | null;
|
||||
}
|
||||
|
||||
function TableCard({ table, expanded, onToggle, highlightedCell }: TableCardProps) {
|
||||
// 防御性检查:确保 issues 数组存在
|
||||
const issues = table.issues || [];
|
||||
const hasIssues = issues.length > 0;
|
||||
const errorCount = issues.filter(i => i.severity === 'ERROR').length;
|
||||
const warningCount = issues.filter(i => i.severity === 'WARNING').length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm">
|
||||
{/* 表格头部 */}
|
||||
<div
|
||||
className={`px-5 py-4 flex items-center justify-between cursor-pointer hover:bg-slate-50 transition-colors ${
|
||||
hasIssues ? 'bg-amber-50/50' : 'bg-green-50/30'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasIssues ? (
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-800">{table.caption || `表格 ${table.id}`}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{table.rowCount} 行 × {table.colCount} 列
|
||||
{table.skipped && ` · ⚠️ ${table.skipReason}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{errorCount > 0 && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-md font-medium">
|
||||
{errorCount} 错误
|
||||
</span>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<span className="px-2 py-1 bg-amber-100 text-amber-700 text-xs rounded-md font-medium">
|
||||
{warningCount} 警告
|
||||
</span>
|
||||
)}
|
||||
{!hasIssues && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-md font-medium">
|
||||
通过
|
||||
</span>
|
||||
)}
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-200">
|
||||
{/* 表格渲染 */}
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<style>{`
|
||||
.forensics-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.forensics-table th,
|
||||
.forensics-table td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.forensics-table th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
.forensics-table tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.forensics-table td.has-issue,
|
||||
.forensics-table span.has-issue {
|
||||
color: #dc2626 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.forensics-table td.highlighted,
|
||||
.forensics-table span.highlighted {
|
||||
color: #dc2626 !important;
|
||||
font-weight: 700;
|
||||
background: #fef2f2 !important;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className="forensics-table-wrapper"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: addHighlightToHtml(table.html || '', highlightedCell, issues)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表格问题 */}
|
||||
{issues.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
该表格发现的问题
|
||||
</p>
|
||||
{issues.map((issue, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
{issue.severity === 'ERROR' ? (
|
||||
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<span className="text-slate-700">{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 HTML 表格添加高亮样式
|
||||
* 支持两种坐标:
|
||||
* - data-coord="R5C4" - 单元格级别
|
||||
* - data-subcoord="R5C4S2" - 子行级别(用于多行单元格)
|
||||
*/
|
||||
function addHighlightToHtml(
|
||||
html: string,
|
||||
highlightedCell: string | null,
|
||||
issues: ForensicsIssue[]
|
||||
): string {
|
||||
let result = html;
|
||||
|
||||
// 给有问题的元素添加 has-issue 类
|
||||
for (const issue of issues) {
|
||||
if (issue.location?.cellRef) {
|
||||
const cellRef = issue.location.cellRef;
|
||||
|
||||
// 检查是否包含子行坐标 (如 R5C4S2)
|
||||
if (cellRef.includes('S')) {
|
||||
// 子行级别高亮:匹配 data-subcoord
|
||||
result = result.replace(
|
||||
new RegExp(`data-subcoord="${cellRef}"`, 'g'),
|
||||
`data-subcoord="${cellRef}" class="has-issue"`
|
||||
);
|
||||
} else {
|
||||
// 单元格级别高亮:匹配 data-coord(向后兼容)
|
||||
result = result.replace(
|
||||
new RegExp(`data-coord="${cellRef}"(?![S\\d])`, 'g'),
|
||||
`data-coord="${cellRef}" class="has-issue"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 给用户点击高亮的元素添加 highlighted 类
|
||||
if (highlightedCell) {
|
||||
if (highlightedCell.includes('S')) {
|
||||
result = result.replace(
|
||||
new RegExp(`data-subcoord="${highlightedCell}"(\\s+class="[^"]*")?`, 'g'),
|
||||
(match, existingClass) => {
|
||||
if (existingClass) {
|
||||
return match.replace('class="', 'class="highlighted ');
|
||||
}
|
||||
return `data-subcoord="${highlightedCell}" class="highlighted"`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
result = result.replace(
|
||||
new RegExp(`data-coord="${highlightedCell}"(\\s+class="[^"]*")?`, 'g'),
|
||||
(match, existingClass) => {
|
||||
if (existingClass) {
|
||||
return match.replace('class="', 'class="highlighted ');
|
||||
}
|
||||
return `data-coord="${highlightedCell}" class="highlighted"`;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Dashboard头部组件
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { BrainCircuit, UploadCloud } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { BrainCircuit, UploadCloud, Info, X } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
onUpload: (files: FileList) => void;
|
||||
@@ -10,6 +10,7 @@ interface HeaderProps {
|
||||
|
||||
export default function Header({ onUpload }: HeaderProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showTip, setShowTip] = useState(true);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
@@ -20,36 +21,56 @@ export default function Header({ onUpload }: HeaderProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
{/* Logo区域 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-indigo-50 p-2 rounded-lg text-indigo-700">
|
||||
<BrainCircuit className="w-6 h-6" />
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Logo区域 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-indigo-50 p-2 rounded-lg text-indigo-700">
|
||||
<BrainCircuit className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">智能审稿系统</h1>
|
||||
<p className="text-xs text-slate-500">当前工作区:编辑部初审组</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">智能审稿系统</h1>
|
||||
<p className="text-xs text-slate-500">当前工作区:编辑部初审组</p>
|
||||
|
||||
{/* 上传按钮 */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-bold flex items-center gap-2 shadow-sm transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
<UploadCloud className="w-4 h-4" />
|
||||
上传新稿件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传按钮 */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-bold flex items-center gap-2 shadow-sm transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
<UploadCloud className="w-4 h-4" />
|
||||
上传新稿件
|
||||
</button>
|
||||
</div>
|
||||
{/* 文件格式提示 */}
|
||||
{showTip && (
|
||||
<div className="mt-3 flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm">
|
||||
<Info className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 text-blue-700">
|
||||
<span className="font-medium">推荐上传 .docx 格式文件</span>
|
||||
<span className="text-blue-600">,可获得完整的数据验证功能(表格算术校验、P值验证等)。</span>
|
||||
<span className="text-blue-500">PDF 和 .doc 格式仅支持稿约和方法学评审。</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTip(false)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-24 h-24 rounded-full border-4 ${grade.bg.replace('bg-', 'border-')} flex items-center justify-center bg-white shadow-lg`}>
|
||||
<div className="text-center">
|
||||
<span className={`text-3xl font-bold ${grade.color}`}>{data.overall_score}</span>
|
||||
<span className={`text-3xl font-bold ${grade.color}`}>{Number(data.overall_score).toFixed(1)}</span>
|
||||
<span className="text-xs text-slate-400 block">分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,24 +2,45 @@
|
||||
* 报告详情页组件
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft, FileCheck, Tag } from 'lucide-react';
|
||||
import { ArrowLeft, FileCheck, Tag, Info } from 'lucide-react';
|
||||
import type { ReviewReport } from '../types';
|
||||
import EditorialReport from './EditorialReport';
|
||||
import MethodologyReport from './MethodologyReport';
|
||||
import ForensicsReport from './ForensicsReport';
|
||||
|
||||
interface ReportDetailProps {
|
||||
report: ReviewReport;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'editorial' | 'methodology' | 'forensics';
|
||||
|
||||
export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
const [activeTab, setActiveTab] = useState<'editorial' | 'methodology'>('editorial');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('editorial');
|
||||
|
||||
const hasEditorial = !!report.editorialReview;
|
||||
const hasMethodology = !!report.methodologyReview;
|
||||
const hasForensics = !!report.forensicsResult;
|
||||
|
||||
// 检查文件格式:非 .docx 文件无法进行数据验证
|
||||
const fileName = report.fileName || '';
|
||||
const isDocx = fileName.toLowerCase().endsWith('.docx');
|
||||
const isPdf = fileName.toLowerCase().endsWith('.pdf');
|
||||
const isDoc = fileName.toLowerCase().endsWith('.doc');
|
||||
const showNoForensicsTip = !hasForensics && (hasEditorial || hasMethodology) && (isPdf || isDoc);
|
||||
|
||||
// 如果只有方法学,默认显示方法学
|
||||
const effectiveTab = activeTab === 'editorial' && !hasEditorial && hasMethodology ? 'methodology' : activeTab;
|
||||
// 智能默认 Tab 选择
|
||||
const getEffectiveTab = (): TabType => {
|
||||
if (activeTab === 'editorial' && hasEditorial) return 'editorial';
|
||||
if (activeTab === 'methodology' && hasMethodology) return 'methodology';
|
||||
if (activeTab === 'forensics' && hasForensics) return 'forensics';
|
||||
// 默认优先级:editorial > methodology > forensics
|
||||
if (hasEditorial) return 'editorial';
|
||||
if (hasMethodology) return 'methodology';
|
||||
if (hasForensics) return 'forensics';
|
||||
return 'editorial';
|
||||
};
|
||||
const effectiveTab = getEffectiveTab();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50 relative fade-in">
|
||||
@@ -37,12 +58,12 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-slate-800 flex items-center gap-2">
|
||||
{report.fileName}
|
||||
{report.overallScore && (
|
||||
{report.overallScore != null && (
|
||||
<span className={`tag ${
|
||||
report.overallScore >= 80 ? 'tag-green' :
|
||||
report.overallScore >= 60 ? 'tag-amber' : 'tag-red'
|
||||
}`}>
|
||||
{report.overallScore}分
|
||||
{Number(report.overallScore).toFixed(1)}分
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
@@ -59,7 +80,7 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-auto p-8 max-w-5xl mx-auto w-full">
|
||||
{/* Tab切换 */}
|
||||
{(hasEditorial || hasMethodology) && (
|
||||
{(hasEditorial || hasMethodology || hasForensics) && (
|
||||
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-8 w-fit mx-auto">
|
||||
{hasEditorial && (
|
||||
<button
|
||||
@@ -85,6 +106,30 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
方法学评估 ({report.methodologyReview?.overall_score}分)
|
||||
</button>
|
||||
)}
|
||||
{hasForensics && (
|
||||
<button
|
||||
onClick={() => setActiveTab('forensics')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
effectiveTab === 'forensics'
|
||||
? 'font-bold bg-white text-indigo-600 shadow-sm'
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
数据验证 ({report.forensicsResult?.summary.totalIssues || 0}个问题)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 非 docx 文件无数据验证提示 */}
|
||||
{showNoForensicsTip && (
|
||||
<div className="mb-4 flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm">
|
||||
<Info className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-amber-700">
|
||||
<span className="font-medium">当前文件为 {isPdf ? 'PDF' : '.doc'} 格式,</span>
|
||||
<span>无法进行数据验证(表格算术校验、P值验证等)。</span>
|
||||
<span className="text-amber-600">如需数据验证功能,请上传 .docx 格式文件。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,9 +140,12 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
{effectiveTab === 'methodology' && report.methodologyReview && (
|
||||
<MethodologyReport data={report.methodologyReview} />
|
||||
)}
|
||||
{effectiveTab === 'forensics' && report.forensicsResult && (
|
||||
<ForensicsReport data={report.forensicsResult} />
|
||||
)}
|
||||
|
||||
{/* 无数据状态 */}
|
||||
{!hasEditorial && !hasMethodology && (
|
||||
{!hasEditorial && !hasMethodology && !hasForensics && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Tag className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
||||
<p>暂无评估报告</p>
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
* 支持显示审稿进度和结果
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot } from 'lucide-react';
|
||||
import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot, Info } from 'lucide-react';
|
||||
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, Table, TableRow, TableCell, WidthType } from 'docx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import type { ReviewTask, ReviewReport, TaskStatus } from '../types';
|
||||
import EditorialReport from './EditorialReport';
|
||||
import MethodologyReport from './MethodologyReport';
|
||||
import ForensicsReport from './ForensicsReport';
|
||||
import * as api from '../api';
|
||||
import { message } from 'antd';
|
||||
|
||||
type TabType = 'editorial' | 'methodology' | 'forensics';
|
||||
|
||||
interface TaskDetailProps {
|
||||
task: ReviewTask;
|
||||
jobId?: string | null; // pg-boss 任务ID(可选,用于更精确的状态轮询)
|
||||
@@ -49,7 +52,7 @@ const getProgressSteps = (selectedAgents: string[]) => {
|
||||
export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDetailProps) {
|
||||
const [task, setTask] = useState<ReviewTask>(initialTask);
|
||||
const [report, setReport] = useState<ReviewReport | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'editorial' | 'methodology'>('editorial');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('editorial');
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
|
||||
// Suppress unused variable warning - jobId is reserved for future use
|
||||
@@ -110,6 +113,8 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
setActiveTab('editorial');
|
||||
} else if (report.methodologyReview) {
|
||||
setActiveTab('methodology');
|
||||
} else if (report.forensicsResult) {
|
||||
setActiveTab('forensics');
|
||||
}
|
||||
}
|
||||
}, [report]);
|
||||
@@ -196,7 +201,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
width: { size: 2000, type: WidthType.DXA },
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(`${report.overallScore || '-'} 分`)],
|
||||
children: [new Paragraph(`${report.overallScore != null ? Number(report.overallScore).toFixed(1) : '-'} 分`)],
|
||||
width: { size: 7000, type: WidthType.DXA },
|
||||
}),
|
||||
],
|
||||
@@ -532,7 +537,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-5xl font-bold">{report.overallScore || '-'}</div>
|
||||
<div className="text-5xl font-bold">{report.overallScore != null ? Number(report.overallScore).toFixed(1) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -562,7 +567,39 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
方法学评估 ({report.methodologyReview.overall_score}分)
|
||||
</button>
|
||||
)}
|
||||
{report.forensicsResult && (
|
||||
<button
|
||||
onClick={() => setActiveTab('forensics')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
activeTab === 'forensics'
|
||||
? 'font-bold bg-white text-indigo-600 shadow-sm'
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
数据验证 ({report.forensicsResult.summary.totalIssues || 0}个问题)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 非 docx 文件无数据验证提示 */}
|
||||
{!report.forensicsResult && (report.editorialReview || report.methodologyReview) && (() => {
|
||||
const fileName = task.fileName || '';
|
||||
const isPdf = fileName.toLowerCase().endsWith('.pdf');
|
||||
const isDoc = fileName.toLowerCase().endsWith('.doc');
|
||||
if (isPdf || isDoc) {
|
||||
return (
|
||||
<div className="mb-4 flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm">
|
||||
<Info className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-amber-700">
|
||||
<span className="font-medium">当前文件为 {isPdf ? 'PDF' : '.doc'} 格式,</span>
|
||||
<span>无法进行数据验证(表格算术校验、P值验证等)。</span>
|
||||
<span className="text-amber-600">如需数据验证功能,请上传 .docx 格式文件。</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* 报告内容 */}
|
||||
{activeTab === 'editorial' && report.editorialReview && (
|
||||
@@ -571,6 +608,9 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
{activeTab === 'methodology' && report.methodologyReview && (
|
||||
<MethodologyReport data={report.methodologyReview} />
|
||||
)}
|
||||
{activeTab === 'forensics' && report.forensicsResult && (
|
||||
<ForensicsReport data={report.forensicsResult} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -72,10 +72,51 @@ export interface MethodologyReviewResult {
|
||||
parts: MethodologyPart[];
|
||||
}
|
||||
|
||||
// 数据验证问题
|
||||
export interface ForensicsIssue {
|
||||
severity: 'ERROR' | 'WARNING' | 'INFO';
|
||||
type: string;
|
||||
message: string;
|
||||
location?: {
|
||||
tableId?: string;
|
||||
cellRef?: string;
|
||||
paragraph?: number;
|
||||
};
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 表格数据
|
||||
export interface ForensicsTable {
|
||||
id: string;
|
||||
caption: string;
|
||||
html: string;
|
||||
data: string[][];
|
||||
headers: string[];
|
||||
rowCount: number;
|
||||
colCount: number;
|
||||
skipped?: boolean;
|
||||
skipReason?: string;
|
||||
issues: ForensicsIssue[];
|
||||
}
|
||||
|
||||
// 数据验证结果
|
||||
export interface ForensicsResult {
|
||||
tables: ForensicsTable[];
|
||||
methods: string[];
|
||||
issues: ForensicsIssue[];
|
||||
summary: {
|
||||
totalTables: number;
|
||||
totalIssues: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 完整审查报告
|
||||
export interface ReviewReport extends ReviewTask {
|
||||
editorialReview?: EditorialReviewResult;
|
||||
methodologyReview?: MethodologyReviewResult;
|
||||
forensicsResult?: ForensicsResult;
|
||||
modelUsed?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user