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:
2026-02-18 18:26:16 +08:00
parent 9f256c4a02
commit f9ed0c2528
36 changed files with 2790 additions and 501 deletions

View File

@@ -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>

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}