feat(rvw): Complete RVW module development Phase 1-3
Summary: - Migrate backend to modules/rvw with v2 API routes (/api/v2/rvw) - Add new database fields: selectedAgents, editorialScore, methodologyStatus, picoExtract, isArchived - Create frontend module in frontend-v2/src/modules/rvw - Implement Dashboard with task list, filtering, batch operations - Implement ReportDetail with dual tabs (editorial/methodology) - Implement AgentModal for intelligent agent selection - Register RVW module in moduleRegistry.ts - Add navigation entry in TopNavigation - Update documentation for RVW module status (v3.0) - Update system status document (v2.9) Features: - User can select agents: editorial, methodology, or both - Support batch task execution - Task status filtering - Replace console.log with logger service - Maintain v1 API backward compatibility Tested: Frontend and backend verified locally Status: 85% complete (Phase 1-3 done)
This commit is contained in:
123
frontend/src/pages/rvw/components/AgentModal.tsx
Normal file
123
frontend/src/pages/rvw/components/AgentModal.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 智能体选择弹窗
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { PlayCircle, X } from 'lucide-react';
|
||||
import type { AgentType } from '../types';
|
||||
|
||||
interface AgentModalProps {
|
||||
visible: boolean;
|
||||
taskCount: number;
|
||||
onClose: () => void;
|
||||
onConfirm: (agents: AgentType[]) => void;
|
||||
}
|
||||
|
||||
export default function AgentModal({ visible, taskCount, onClose, onConfirm }: AgentModalProps) {
|
||||
const [selectedAgents, setSelectedAgents] = useState<AgentType[]>(['editorial']);
|
||||
|
||||
const toggleAgent = (agent: AgentType) => {
|
||||
if (selectedAgents.includes(agent)) {
|
||||
// 至少保留一个
|
||||
if (selectedAgents.length > 1) {
|
||||
setSelectedAgents(selectedAgents.filter(a => a !== agent));
|
||||
}
|
||||
} else {
|
||||
setSelectedAgents([...selectedAgents, agent]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedAgents);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/50 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-[400px] overflow-hidden transform transition-all scale-100 fade-in">
|
||||
{/* 头部 */}
|
||||
<div className="bg-slate-900 p-5 text-white flex items-center justify-between">
|
||||
<h3 className="font-bold text-lg flex items-center gap-2">
|
||||
<PlayCircle className="w-5 h-5 text-indigo-400" />
|
||||
发起智能审稿
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
{taskCount > 1 ? `已选择 ${taskCount} 个稿件,请选择审稿维度:` : '请选择审稿维度:'}
|
||||
</p>
|
||||
|
||||
{/* 规范性智能体 */}
|
||||
<label
|
||||
className={`flex items-start gap-3 p-4 border rounded-xl cursor-pointer transition-all ${
|
||||
selectedAgents.includes('editorial')
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes('editorial')}
|
||||
onChange={() => toggleAgent('editorial')}
|
||||
className="mt-1 w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="block font-bold text-slate-800 text-sm">稿约规范性智能体</span>
|
||||
<span className="block text-xs text-slate-500 mt-0.5">
|
||||
检查格式、参考文献、图片、伦理声明等11项标准
|
||||
</span>
|
||||
</div>
|
||||
<span className="tag tag-blue">快速</span>
|
||||
</label>
|
||||
|
||||
{/* 方法学智能体 */}
|
||||
<label
|
||||
className={`flex items-start gap-3 p-4 border rounded-xl cursor-pointer transition-all ${
|
||||
selectedAgents.includes('methodology')
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes('methodology')}
|
||||
onChange={() => toggleAgent('methodology')}
|
||||
className="mt-1 w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="block font-bold text-slate-800 text-sm">方法学统计智能体</span>
|
||||
<span className="block text-xs text-slate-500 mt-0.5">
|
||||
深度推理检验方法、统计逻辑、研究设计等20项评估
|
||||
</span>
|
||||
</div>
|
||||
<span className="tag tag-purple">深度</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="p-4 bg-slate-50 flex justify-end gap-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedAgents.length === 0}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-bold rounded-lg shadow-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
立即运行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
43
frontend/src/pages/rvw/components/BatchToolbar.tsx
Normal file
43
frontend/src/pages/rvw/components/BatchToolbar.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 批量操作浮动工具栏
|
||||
*/
|
||||
import { Play, X } from 'lucide-react';
|
||||
|
||||
interface BatchToolbarProps {
|
||||
selectedCount: number;
|
||||
onRunBatch: () => void;
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelection }: BatchToolbarProps) {
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 left-1/2 transform -translate-x-1/2 bg-slate-800 text-white px-5 py-3 rounded-full shadow-2xl flex items-center gap-6 z-30 fade-in border border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full bg-indigo-500 flex items-center justify-center text-[10px] font-bold">
|
||||
{selectedCount}
|
||||
</div>
|
||||
<span className="text-sm font-medium">个文件已选中</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-slate-600" />
|
||||
|
||||
<button
|
||||
onClick={onRunBatch}
|
||||
className="text-sm font-bold text-white hover:text-indigo-300 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Play className="w-4 h-4 text-green-400" />
|
||||
运行智能审稿
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
className="text-slate-400 hover:text-white ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
108
frontend/src/pages/rvw/components/EditorialReport.tsx
Normal file
108
frontend/src/pages/rvw/components/EditorialReport.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 规范性评估报告组件
|
||||
*/
|
||||
import { AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import type { EditorialReviewResult } from '../types';
|
||||
import ScoreRing from './ScoreRing';
|
||||
|
||||
interface EditorialReportProps {
|
||||
data: EditorialReviewResult;
|
||||
}
|
||||
|
||||
export default function EditorialReport({ data }: EditorialReportProps) {
|
||||
const getStatusIcon = (status: 'pass' | 'warning' | 'fail') => {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-5 h-5 text-amber-500" />;
|
||||
case 'fail':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: 'pass' | 'warning' | 'fail') => {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return <span className="tag tag-green">通过</span>;
|
||||
case 'warning':
|
||||
return <span className="tag tag-amber">警告</span>;
|
||||
case 'fail':
|
||||
return <span className="tag tag-red">不通过</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getItemBgClass = (status: 'pass' | 'warning' | 'fail') => {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return '';
|
||||
case 'warning':
|
||||
return 'bg-amber-50/50';
|
||||
case 'fail':
|
||||
return 'bg-red-50/50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* 总分卡片 */}
|
||||
<div className={`bg-white p-6 rounded-2xl shadow-sm border flex items-center gap-8 ${
|
||||
data.overall_score >= 80 ? 'border-green-200' :
|
||||
data.overall_score >= 60 ? 'border-amber-200' : 'border-red-200'
|
||||
}`}>
|
||||
<ScoreRing score={data.overall_score} size="medium" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg text-slate-800">
|
||||
{data.overall_score >= 80 ? '基本符合稿约规范' :
|
||||
data.overall_score >= 60 ? '部分符合稿约规范' : '不符合稿约规范'}
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm mt-1">{data.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检测详情 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 bg-slate-50 border-b border-gray-200 font-bold text-sm text-slate-700">
|
||||
检测详情({data.items.length}项)
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100">
|
||||
{data.items.map((item, index) => (
|
||||
<div key={index} className={`p-5 ${getItemBgClass(item.status)}`}>
|
||||
<div className="flex gap-4">
|
||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<h4 className="font-bold text-sm text-slate-800">{item.criterion}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{item.score}分</span>
|
||||
{getStatusTag(item.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.issues && item.issues.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{item.issues.map((issue, i) => (
|
||||
<p key={i} className="text-sm text-slate-600">• {issue}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.suggestions && item.suggestions.length > 0 && (
|
||||
<div className="mt-3 bg-white border border-gray-200 p-3 rounded-lg">
|
||||
<p className="text-xs font-bold text-slate-500 mb-1">建议:</p>
|
||||
{item.suggestions.map((suggestion, i) => (
|
||||
<p key={i} className="text-xs text-slate-600">• {suggestion}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
66
frontend/src/pages/rvw/components/FilterChips.tsx
Normal file
66
frontend/src/pages/rvw/components/FilterChips.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 筛选Chips组件
|
||||
*/
|
||||
import type { TaskFilters } from '../types';
|
||||
|
||||
interface FilterChipsProps {
|
||||
filters: TaskFilters;
|
||||
counts: { all: number; pending: number; completed: number };
|
||||
onFilterChange: (filters: TaskFilters) => void;
|
||||
}
|
||||
|
||||
export default function FilterChips({ filters, counts, onFilterChange }: FilterChipsProps) {
|
||||
const statusOptions = [
|
||||
{ value: 'all' as const, label: '全部', count: counts.all },
|
||||
{ value: 'pending' as const, label: '待处理', count: counts.pending },
|
||||
{ value: 'completed' as const, label: '已完成', count: counts.completed },
|
||||
];
|
||||
|
||||
const timeOptions = [
|
||||
{ value: 'all' as const, label: '不限' },
|
||||
{ value: 'today' as const, label: '今天' },
|
||||
{ value: 'week' as const, label: '近7天' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider mr-2">状态:</span>
|
||||
{statusOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onFilterChange({ ...filters, status: option.value })}
|
||||
className={`filter-chip ${filters.status === option.value ? 'active' : ''}`}
|
||||
>
|
||||
{option.label}
|
||||
{option.count !== undefined && (
|
||||
<span className={`ml-1 text-xs px-1.5 rounded-full ${
|
||||
filters.status === option.value ? 'bg-black/10' : 'bg-slate-200'
|
||||
}`}>
|
||||
{option.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-gray-200 mx-2" />
|
||||
|
||||
{/* 时间筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider mr-2">时间:</span>
|
||||
{timeOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onFilterChange({ ...filters, timeRange: option.value })}
|
||||
className={`filter-chip ${filters.timeRange === option.value ? 'active' : ''}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
56
frontend/src/pages/rvw/components/Header.tsx
Normal file
56
frontend/src/pages/rvw/components/Header.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Dashboard头部组件
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { BrainCircuit, UploadCloud } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
onUpload: (files: FileList) => void;
|
||||
}
|
||||
|
||||
export default function Header({ onUpload }: HeaderProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onUpload(e.target.files);
|
||||
// 重置input以允许选择相同文件
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">智能审稿系统</h1>
|
||||
<p className="text-xs text-slate-500">当前工作区:编辑部初审组</p>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
113
frontend/src/pages/rvw/components/MethodologyReport.tsx
Normal file
113
frontend/src/pages/rvw/components/MethodologyReport.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 方法学评估报告组件
|
||||
*/
|
||||
import { XCircle, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
import type { MethodologyReviewResult } from '../types';
|
||||
import ScoreRing from './ScoreRing';
|
||||
|
||||
interface MethodologyReportProps {
|
||||
data: MethodologyReviewResult;
|
||||
}
|
||||
|
||||
export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
const getSeverityStyle = (severity: 'major' | 'minor') => {
|
||||
return severity === 'major'
|
||||
? { border: 'border-red-200', bg: 'bg-red-50', icon: <XCircle className="w-4 h-4 text-red-500" />, label: '严重' }
|
||||
: { border: 'border-amber-200', bg: 'bg-amber-50', icon: <AlertTriangle className="w-4 h-4 text-amber-500" />, label: '轻微' };
|
||||
};
|
||||
|
||||
const getOverallStatus = () => {
|
||||
if (data.overall_score >= 80) return { label: '通过', color: 'text-green-700', bg: 'bg-green-50' };
|
||||
if (data.overall_score >= 60) return { label: '存疑', color: 'text-amber-700', bg: 'bg-amber-50' };
|
||||
return { label: '不通过', color: 'text-red-700', bg: 'bg-red-50' };
|
||||
};
|
||||
|
||||
const status = getOverallStatus();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* 总分卡片 */}
|
||||
<div className={`bg-white p-6 rounded-2xl shadow-sm border flex items-center gap-8 ${
|
||||
data.overall_score >= 80 ? 'border-green-200' :
|
||||
data.overall_score >= 60 ? 'border-amber-200' : 'border-red-200'
|
||||
}`}>
|
||||
<ScoreRing score={data.overall_score} size="medium" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg text-slate-800 flex items-center gap-2">
|
||||
方法学评估
|
||||
<span className={`text-sm px-2 py-0.5 rounded ${status.bg} ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm mt-1">{data.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分项详情 */}
|
||||
{data.parts.map((part, partIndex) => (
|
||||
<div key={partIndex} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 bg-slate-50 border-b border-gray-200 flex justify-between items-center">
|
||||
<span className="font-bold text-sm text-slate-700">{part.part}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{part.score}分</span>
|
||||
{part.issues.length === 0 ? (
|
||||
<span className="tag tag-green flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
无问题
|
||||
</span>
|
||||
) : (
|
||||
<span className="tag tag-amber">
|
||||
{part.issues.length}个问题
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{part.issues.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{part.issues.map((issue, issueIndex) => {
|
||||
const severity = getSeverityStyle(issue.severity);
|
||||
return (
|
||||
<div key={issueIndex} className={`p-5 ${severity.bg}`}>
|
||||
<div className="flex gap-3">
|
||||
<div className="mt-0.5">{severity.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-bold text-sm text-slate-800">{issue.type}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
issue.severity === 'major' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{severity.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{issue.description}</p>
|
||||
{issue.location && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
位置:{issue.location}
|
||||
</p>
|
||||
)}
|
||||
{issue.suggestion && (
|
||||
<div className="mt-3 bg-white border border-gray-200 p-3 rounded-lg">
|
||||
<p className="text-xs text-slate-600">
|
||||
<strong>建议:</strong>{issue.suggestion}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center text-slate-500 text-sm">
|
||||
<CheckCircle className="w-8 h-8 text-green-400 mx-auto mb-2" />
|
||||
该部分未发现问题
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
110
frontend/src/pages/rvw/components/ReportDetail.tsx
Normal file
110
frontend/src/pages/rvw/components/ReportDetail.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 报告详情页组件
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft, FileCheck, Tag } from 'lucide-react';
|
||||
import type { ReviewReport } from '../types';
|
||||
import EditorialReport from './EditorialReport';
|
||||
import MethodologyReport from './MethodologyReport';
|
||||
|
||||
interface ReportDetailProps {
|
||||
report: ReviewReport;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
const [activeTab, setActiveTab] = useState<'editorial' | 'methodology'>('editorial');
|
||||
|
||||
const hasEditorial = !!report.editorialReview;
|
||||
const hasMethodology = !!report.methodologyReview;
|
||||
|
||||
// 如果只有方法学,默认显示方法学
|
||||
const effectiveTab = activeTab === 'editorial' && !hasEditorial && hasMethodology ? 'methodology' : activeTab;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50 relative fade-in">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="h-16 bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-20 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-slate-500 hover:text-slate-800 transition-colors px-2 py-1 rounded hover:bg-slate-100"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">返回列表</span>
|
||||
</button>
|
||||
<div className="h-6 w-px bg-slate-200" />
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-slate-800 flex items-center gap-2">
|
||||
{report.fileName}
|
||||
{report.overallScore && (
|
||||
<span className={`tag ${
|
||||
report.overallScore >= 80 ? 'tag-green' :
|
||||
report.overallScore >= 60 ? 'tag-amber' : 'tag-red'
|
||||
}`}>
|
||||
{report.overallScore}分
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm font-medium hover:bg-indigo-700 transition shadow-sm flex items-center gap-2">
|
||||
<FileCheck className="w-4 h-4" />
|
||||
导出报告
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-auto p-8 max-w-5xl mx-auto w-full">
|
||||
{/* Tab切换 */}
|
||||
{(hasEditorial || hasMethodology) && (
|
||||
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-8 w-fit mx-auto">
|
||||
{hasEditorial && (
|
||||
<button
|
||||
onClick={() => setActiveTab('editorial')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
effectiveTab === 'editorial'
|
||||
? 'font-bold bg-white text-indigo-600 shadow-sm'
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
稿约规范性 ({report.editorialReview?.overall_score}分)
|
||||
</button>
|
||||
)}
|
||||
{hasMethodology && (
|
||||
<button
|
||||
onClick={() => setActiveTab('methodology')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
effectiveTab === 'methodology'
|
||||
? 'font-bold bg-white text-indigo-600 shadow-sm'
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
方法学评估 ({report.methodologyReview?.overall_score}分)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 报告内容 */}
|
||||
{effectiveTab === 'editorial' && report.editorialReview && (
|
||||
<EditorialReport data={report.editorialReview} />
|
||||
)}
|
||||
{effectiveTab === 'methodology' && report.methodologyReview && (
|
||||
<MethodologyReport data={report.methodologyReview} />
|
||||
)}
|
||||
|
||||
{/* 无数据状态 */}
|
||||
{!hasEditorial && !hasMethodology && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Tag className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
||||
<p>暂无评估报告</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
frontend/src/pages/rvw/components/ScoreRing.tsx
Normal file
38
frontend/src/pages/rvw/components/ScoreRing.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 评分环组件
|
||||
*/
|
||||
|
||||
interface ScoreRingProps {
|
||||
score: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export default function ScoreRing({ score, size = 'medium', showLabel = true }: ScoreRingProps) {
|
||||
const sizeStyles = {
|
||||
small: 'w-12 h-12 text-lg border-4',
|
||||
medium: 'w-20 h-20 text-2xl border-6',
|
||||
large: 'w-24 h-24 text-3xl border-8',
|
||||
};
|
||||
|
||||
const getScoreStatus = (score: number) => {
|
||||
if (score >= 80) return { class: 'pass', label: 'Pass', bgColor: 'bg-green-50', borderColor: 'border-green-500', textColor: 'text-green-700' };
|
||||
if (score >= 60) return { class: 'warn', label: 'Warning', bgColor: 'bg-amber-50', borderColor: 'border-amber-500', textColor: 'text-amber-700' };
|
||||
return { class: 'fail', label: 'Fail', bgColor: 'bg-red-50', borderColor: 'border-red-500', textColor: 'text-red-700' };
|
||||
};
|
||||
|
||||
const status = getScoreStatus(score);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full flex flex-col items-center justify-center ${sizeStyles[size]} ${status.bgColor} ${status.borderColor} ${status.textColor}`}
|
||||
style={{ borderWidth: size === 'small' ? 4 : size === 'medium' ? 6 : 8 }}
|
||||
>
|
||||
<span className="font-bold">{score}</span>
|
||||
{showLabel && size !== 'small' && (
|
||||
<span className="text-[10px] font-bold uppercase">{status.label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
73
frontend/src/pages/rvw/components/Sidebar.tsx
Normal file
73
frontend/src/pages/rvw/components/Sidebar.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* RVW侧边栏组件
|
||||
*/
|
||||
import { LayoutGrid, Archive, Settings, BrainCircuit } from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
currentView: 'dashboard' | 'archive';
|
||||
onViewChange: (view: 'dashboard' | 'archive') => void;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ currentView, onViewChange, onSettingsClick }: SidebarProps) {
|
||||
return (
|
||||
<aside className="w-[72px] bg-slate-900 flex flex-col items-center py-6 gap-4 z-20 shadow-xl flex-shrink-0 relative">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="w-10 h-10 bg-indigo-500 rounded-xl flex items-center justify-center text-white shadow-lg mb-4"
|
||||
title="智能审稿系统"
|
||||
>
|
||||
<BrainCircuit className="w-6 h-6" />
|
||||
</div>
|
||||
|
||||
{/* 审稿工作台 */}
|
||||
<button
|
||||
onClick={() => onViewChange('dashboard')}
|
||||
className={`sidebar-btn w-10 h-10 rounded-lg flex items-center justify-center transition-colors relative group
|
||||
${currentView === 'dashboard'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-slate-400 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
title="审稿工作台"
|
||||
>
|
||||
<LayoutGrid className="w-5 h-5" />
|
||||
<span className="sidebar-tooltip">审稿工作台</span>
|
||||
</button>
|
||||
|
||||
{/* 历史归档 */}
|
||||
<button
|
||||
onClick={() => onViewChange('archive')}
|
||||
className={`sidebar-btn w-10 h-10 rounded-lg flex items-center justify-center transition-colors relative group
|
||||
${currentView === 'archive'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-slate-400 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
title="历史归档"
|
||||
>
|
||||
<Archive className="w-5 h-5" />
|
||||
<span className="sidebar-tooltip">历史归档</span>
|
||||
</button>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<div className="mt-auto flex flex-col gap-4 relative">
|
||||
{/* 系统设置 */}
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="sidebar-btn w-10 h-10 rounded-lg text-slate-400 flex items-center justify-center hover:bg-white/10 hover:text-white transition-colors relative group"
|
||||
title="系统设置"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="sidebar-tooltip">系统设置</span>
|
||||
</button>
|
||||
|
||||
{/* 用户头像 */}
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-tr from-indigo-500 to-purple-500 flex items-center justify-center text-xs font-bold text-white border-2 border-slate-700 cursor-pointer hover:border-white transition-all shadow-md">
|
||||
编辑
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
252
frontend/src/pages/rvw/components/TaskTable.tsx
Normal file
252
frontend/src/pages/rvw/components/TaskTable.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 任务表格组件
|
||||
*/
|
||||
import { FileText, FileType2, Loader2, Play, Eye } from 'lucide-react';
|
||||
import type { ReviewTask } from '../types';
|
||||
import { formatFileSize, formatTime } from '../api';
|
||||
|
||||
interface TaskTableProps {
|
||||
tasks: ReviewTask[];
|
||||
selectedIds: string[];
|
||||
onSelectChange: (ids: string[]) => void;
|
||||
onViewReport: (task: ReviewTask) => void;
|
||||
onRunTask: (task: ReviewTask) => void;
|
||||
}
|
||||
|
||||
export default function TaskTable({
|
||||
tasks,
|
||||
selectedIds,
|
||||
onSelectChange,
|
||||
onViewReport,
|
||||
onRunTask
|
||||
}: TaskTableProps) {
|
||||
const allSelected = tasks.length > 0 && selectedIds.length === tasks.length;
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
onSelectChange([]);
|
||||
} else {
|
||||
onSelectChange(tasks.map(t => t.id));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectChange(selectedIds.filter(i => i !== id));
|
||||
} else {
|
||||
onSelectChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件图标
|
||||
const getFileIcon = (fileName: string) => {
|
||||
if (fileName.endsWith('.pdf')) {
|
||||
return <FileText className="w-5 h-5" />;
|
||||
}
|
||||
return <FileType2 className="w-5 h-5" />;
|
||||
};
|
||||
|
||||
// 获取文件图标容器样式
|
||||
const getFileIconStyle = (fileName: string) => {
|
||||
if (fileName.endsWith('.pdf')) {
|
||||
return 'bg-red-50 text-red-600 border-red-100';
|
||||
}
|
||||
return 'bg-blue-50 text-blue-600 border-blue-100';
|
||||
};
|
||||
|
||||
// 渲染智能体标签
|
||||
const renderAgentTags = (task: ReviewTask) => {
|
||||
if (!task.selectedAgents || task.selectedAgents.length === 0) {
|
||||
return <span className="tag tag-gray">未运行</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{task.selectedAgents.includes('editorial') && (
|
||||
<span className="tag tag-blue">规范性</span>
|
||||
)}
|
||||
{task.selectedAgents.includes('methodology') && (
|
||||
<span className="tag tag-purple">方法学</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染结果摘要
|
||||
const renderResultSummary = (task: ReviewTask) => {
|
||||
if (task.status === 'pending') {
|
||||
return <span className="text-xs text-slate-400 italic">等待发起...</span>;
|
||||
}
|
||||
|
||||
if (task.status === 'extracting' || task.status === 'reviewing') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-indigo-600">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>{task.status === 'extracting' ? '提取文本中...' : '审查中...'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'failed') {
|
||||
return <span className="text-xs text-red-500">失败</span>;
|
||||
}
|
||||
|
||||
if (task.status === 'completed') {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{task.editorialScore !== undefined && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-2 h-2 rounded-full ${task.editorialScore >= 80 ? 'bg-green-500' : task.editorialScore >= 60 ? 'bg-amber-500' : 'bg-red-500'}`} />
|
||||
<span className="text-slate-600">规范性:</span>
|
||||
<span className={`font-bold ${task.editorialScore >= 80 ? 'text-green-700' : task.editorialScore >= 60 ? 'text-amber-700' : 'text-red-700'}`}>
|
||||
{task.editorialScore}分
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.methodologyStatus && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
task.methodologyStatus === '通过' ? 'bg-green-500' :
|
||||
task.methodologyStatus === '存疑' ? 'bg-amber-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className="text-slate-600">方法学:</span>
|
||||
<span className={`font-bold ${
|
||||
task.methodologyStatus === '通过' ? 'text-green-700' :
|
||||
task.methodologyStatus === '存疑' ? 'text-amber-700' : 'text-red-700'
|
||||
}`}>
|
||||
{task.methodologyStatus}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 渲染操作按钮
|
||||
const renderActions = (task: ReviewTask) => {
|
||||
if (task.status === 'completed') {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onViewReport(task)}
|
||||
className="text-indigo-600 font-bold hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
查看
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'pending') {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onRunTask(task)}
|
||||
className="border border-indigo-200 text-indigo-600 hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs font-medium flex items-center gap-1"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
开始
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'extracting' || task.status === 'reviewing') {
|
||||
return (
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
处理中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-12 text-center">
|
||||
<FileText className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500">暂无稿件,请上传新稿件开始审查</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-500 font-semibold uppercase tracking-wider text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4 w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-4 w-1/3">文件名称 / 信息</th>
|
||||
<th className="px-6 py-4">上传时间</th>
|
||||
<th className="px-6 py-4">审稿维度</th>
|
||||
<th className="px-6 py-4">结果摘要</th>
|
||||
<th className="px-6 py-4 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{tasks.map(task => (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`hover:bg-slate-50 group transition-colors ${selectedIds.includes(task.id) ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(task.id)}
|
||||
onChange={() => toggleSelect(task.id)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2.5 rounded-lg border ${getFileIconStyle(task.fileName)}`}>
|
||||
{getFileIcon(task.fileName)}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`font-bold text-slate-800 text-base mb-0.5 ${task.status === 'completed' ? 'cursor-pointer hover:text-indigo-600' : ''}`}
|
||||
onClick={() => task.status === 'completed' && onViewReport(task)}
|
||||
>
|
||||
{task.fileName}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 flex items-center gap-2">
|
||||
<span className="bg-slate-100 px-1.5 rounded">{formatFileSize(task.fileSize)}</span>
|
||||
{task.wordCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{task.wordCount.toLocaleString()} 字</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-500 font-mono text-xs">
|
||||
{formatTime(task.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{renderAgentTags(task)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{renderResultSummary(task)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{renderActions(task)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
14
frontend/src/pages/rvw/components/index.ts
Normal file
14
frontend/src/pages/rvw/components/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* RVW组件导出
|
||||
*/
|
||||
export { default as Sidebar } from './Sidebar';
|
||||
export { default as Header } from './Header';
|
||||
export { default as FilterChips } from './FilterChips';
|
||||
export { default as TaskTable } from './TaskTable';
|
||||
export { default as BatchToolbar } from './BatchToolbar';
|
||||
export { default as AgentModal } from './AgentModal';
|
||||
export { default as ScoreRing } from './ScoreRing';
|
||||
export { default as EditorialReport } from './EditorialReport';
|
||||
export { default as MethodologyReport } from './MethodologyReport';
|
||||
export { default as ReportDetail } from './ReportDetail';
|
||||
|
||||
Reference in New Issue
Block a user