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:
2026-01-07 22:39:08 +08:00
parent 06028c6952
commit 179afa2c6b
226 changed files with 5860 additions and 21 deletions

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

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

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

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

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

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

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

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

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

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

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