feat(rvw): Complete Phase 4-5 - Bug fixes and Word export
Summary: - Fix methodology score display issue in task list (show score instead of 'warn') - Add methodology_score field to database schema - Fix report display when only methodology agent is selected - Implement Word document export using docx library - Update documentation to v3.0/v3.1 Backend changes: - Add methodologyScore to Prisma schema and TaskSummary type - Update reviewWorker to save methodologyScore - Update getTaskList to return methodologyScore Frontend changes: - Install docx and file-saver libraries - Implement handleExportReport with Word generation - Fix activeTab auto-selection based on available data - Add proper imports for docx components Documentation: - Update RVW module status to 90% (Phase 1-5 complete) - Update system status document to v3.0 Tested: All review workflows verified, Word export functional
This commit is contained in:
@@ -555,6 +555,8 @@ export default FulltextDetailDrawer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -148,6 +148,8 @@ export const useAssets = (activeTab: AssetTabType) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ export const useRecentTasks = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -337,6 +337,8 @@ export default DropnaDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -422,6 +422,8 @@ export default MetricTimePanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -308,6 +308,8 @@ export default PivotPanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ export function useSessionStatus({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface DataStats {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ export type AssetTabType = 'all' | 'processed' | 'raw';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -218,3 +218,5 @@ export const documentSelectionApi = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -286,3 +286,5 @@ export default KnowledgePage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -224,3 +224,5 @@ export const useKnowledgeBaseStore = create<KnowledgeBaseState>((set, get) => ({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,3 +41,5 @@ export interface BatchTemplate {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
130
frontend-v2/src/modules/rvw/api/index.ts
Normal file
130
frontend-v2/src/modules/rvw/api/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* RVW模块API
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import type { ReviewTask, ReviewReport, ApiResponse, AgentType } from '../types';
|
||||
|
||||
const API_BASE = '/api/v2/rvw';
|
||||
|
||||
// 获取任务列表
|
||||
export async function getTasks(status?: string): Promise<ReviewTask[]> {
|
||||
const params = status && status !== 'all' ? { status } : {};
|
||||
const response = await axios.get<ApiResponse<ReviewTask[]>>(`${API_BASE}/tasks`, { params });
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
// 上传稿件
|
||||
export async function uploadManuscript(file: File, selectedAgents?: AgentType[]): Promise<{ taskId: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (selectedAgents) {
|
||||
formData.append('selectedAgents', JSON.stringify(selectedAgents));
|
||||
}
|
||||
|
||||
const response = await axios.post<ApiResponse<{ taskId: string }>>(`${API_BASE}/tasks`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '上传失败');
|
||||
}
|
||||
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
// 获取任务详情
|
||||
export async function getTask(taskId: string): Promise<ReviewTask> {
|
||||
const response = await axios.get<ApiResponse<ReviewTask>>(`${API_BASE}/tasks/${taskId}`);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
// 获取任务报告
|
||||
export async function getTaskReport(taskId: string): Promise<ReviewReport> {
|
||||
const response = await axios.get<ApiResponse<ReviewReport>>(`${API_BASE}/tasks/${taskId}/report`);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
// 运行审查任务(返回jobId供轮询)
|
||||
export async function runTask(taskId: string, agents: AgentType[]): Promise<{ taskId: string; jobId: string }> {
|
||||
const response = await axios.post<ApiResponse<{ taskId: string; jobId: string }>>(`${API_BASE}/tasks/${taskId}/run`, { agents });
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '运行失败');
|
||||
}
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
// 批量运行审查任务
|
||||
export async function batchRunTasks(taskIds: string[], agents: AgentType[]): Promise<void> {
|
||||
const response = await axios.post<ApiResponse<void>>(`${API_BASE}/tasks/batch/run`, { taskIds, agents });
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '批量运行失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
export async function deleteTask(taskId: string): Promise<void> {
|
||||
await axios.delete(`${API_BASE}/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
// 轮询任务状态
|
||||
export async function pollTaskUntilComplete(
|
||||
taskId: string,
|
||||
onUpdate?: (task: ReviewTask) => void,
|
||||
maxAttempts = 120,
|
||||
interval = 3000
|
||||
): Promise<ReviewTask> {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const task = await getTask(taskId);
|
||||
onUpdate?.(task);
|
||||
|
||||
if (task.status === 'completed' || task.status === 'failed') {
|
||||
return task;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
throw new Error('任务超时');
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}分${secs}秒`;
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
export function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
// 小于1分钟
|
||||
if (diff < 60 * 1000) return '刚刚';
|
||||
|
||||
// 小于1小时
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
return `${Math.floor(diff / (60 * 1000))}分钟前`;
|
||||
}
|
||||
|
||||
// 今天
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// 其他
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
123
frontend-v2/src/modules/rvw/components/AgentModal.tsx
Normal file
123
frontend-v2/src/modules/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,让调用方控制关闭时机
|
||||
onConfirm(selectedAgents);
|
||||
};
|
||||
|
||||
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-v2/src/modules/rvw/components/BatchToolbar.tsx
Normal file
43
frontend-v2/src/modules/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>
|
||||
);
|
||||
}
|
||||
|
||||
195
frontend-v2/src/modules/rvw/components/EditorialReport.tsx
Normal file
195
frontend-v2/src/modules/rvw/components/EditorialReport.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 规范性评估报告组件 - 专业版
|
||||
*/
|
||||
import { AlertTriangle, CheckCircle, XCircle, TrendingUp, FileText, Lightbulb } from 'lucide-react';
|
||||
import type { EditorialReviewResult } from '../types';
|
||||
|
||||
interface EditorialReportProps {
|
||||
data: EditorialReviewResult;
|
||||
}
|
||||
|
||||
export default function EditorialReport({ data }: EditorialReportProps) {
|
||||
// 统计各状态数量
|
||||
const stats = {
|
||||
pass: data.items.filter(item => item.status === 'pass').length,
|
||||
warning: data.items.filter(item => item.status === 'warning').length,
|
||||
fail: data.items.filter(item => item.status === 'fail').length,
|
||||
};
|
||||
|
||||
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 getStatusLabel = (status: 'pass' | 'warning' | 'fail') => {
|
||||
switch (status) {
|
||||
case 'pass': return '通过';
|
||||
case 'warning': return '警告';
|
||||
case 'fail': return '不通过';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColors = (status: 'pass' | 'warning' | 'fail') => {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', badge: 'bg-green-100 text-green-700' };
|
||||
case 'warning':
|
||||
return { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', badge: 'bg-amber-100 text-amber-700' };
|
||||
case 'fail':
|
||||
return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', badge: 'bg-red-100 text-red-700' };
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreGrade = (score: number) => {
|
||||
if (score >= 90) return { label: '优秀', color: 'text-green-600', bg: 'bg-green-500' };
|
||||
if (score >= 80) return { label: '良好', color: 'text-green-600', bg: 'bg-green-500' };
|
||||
if (score >= 70) return { label: '中等', color: 'text-amber-600', bg: 'bg-amber-500' };
|
||||
if (score >= 60) return { label: '及格', color: 'text-amber-600', bg: 'bg-amber-500' };
|
||||
return { label: '不及格', color: 'text-red-600', bg: 'bg-red-500' };
|
||||
};
|
||||
|
||||
const grade = getScoreGrade(data.overall_score);
|
||||
|
||||
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 ${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-xs text-slate-400 block">分</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`mt-2 px-3 py-1 rounded-full text-xs font-bold ${grade.bg} text-white`}>
|
||||
{grade.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 评估摘要 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText 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">{data.summary}</p>
|
||||
|
||||
{/* 统计指标 */}
|
||||
<div className="flex gap-4">
|
||||
<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">{stats.pass} 项通过</span>
|
||||
</div>
|
||||
{stats.warning > 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">{stats.warning} 项警告</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.fail > 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">{stats.fail} 项不通过</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检测详情标题 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp 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">共 {data.items.length} 项</span>
|
||||
</div>
|
||||
|
||||
{/* 检测项列表 */}
|
||||
<div className="space-y-3">
|
||||
{data.items.map((item, index) => {
|
||||
const colors = getStatusColors(item.status);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-white rounded-xl border ${colors.border} overflow-hidden transition-all hover:shadow-md`}
|
||||
>
|
||||
{/* 检测项头部 */}
|
||||
<div className={`px-5 py-4 ${colors.bg} border-b ${colors.border}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(item.status)}
|
||||
<h4 className="font-semibold text-slate-800">{item.criterion}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-bold ${colors.badge}`}>
|
||||
{item.score}分
|
||||
</span>
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${colors.badge}`}>
|
||||
{getStatusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检测项内容 */}
|
||||
{(item.issues?.length || item.suggestions?.length) && (
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* 问题列表 */}
|
||||
{item.issues && item.issues.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">发现问题</p>
|
||||
<ul className="space-y-1.5">
|
||||
{item.issues.map((issue, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-slate-600">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 mt-2 flex-shrink-0" />
|
||||
<span>{issue}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 建议 */}
|
||||
{item.suggestions && item.suggestions.length > 0 && (
|
||||
<div className="bg-indigo-50/50 rounded-lg p-4 border border-indigo-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Lightbulb className="w-4 h-4 text-indigo-500" />
|
||||
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wider">改进建议</p>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{item.suggestions.map((suggestion, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-slate-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-indigo-400 mt-2 flex-shrink-0" />
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无问题时的简洁显示 */}
|
||||
{!item.issues?.length && !item.suggestions?.length && item.status === 'pass' && (
|
||||
<div className="px-5 py-3 text-sm text-green-600 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>该项检测通过,符合规范要求</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
frontend-v2/src/modules/rvw/components/FilterChips.tsx
Normal file
66
frontend-v2/src/modules/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-v2/src/modules/rvw/components/Header.tsx
Normal file
56
frontend-v2/src/modules/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>
|
||||
);
|
||||
}
|
||||
|
||||
206
frontend-v2/src/modules/rvw/components/MethodologyReport.tsx
Normal file
206
frontend-v2/src/modules/rvw/components/MethodologyReport.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 方法学评估报告组件 - 专业版
|
||||
*/
|
||||
import { XCircle, AlertTriangle, CheckCircle, Microscope, Lightbulb, MapPin, TrendingUp } from 'lucide-react';
|
||||
import type { MethodologyReviewResult } from '../types';
|
||||
|
||||
interface MethodologyReportProps {
|
||||
data: MethodologyReviewResult;
|
||||
}
|
||||
|
||||
export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
// 统计问题数量
|
||||
const totalIssues = data.parts.reduce((sum, part) => sum + part.issues.length, 0);
|
||||
const majorIssues = data.parts.reduce((sum, part) => sum + part.issues.filter(i => i.severity === 'major').length, 0);
|
||||
const minorIssues = totalIssues - majorIssues;
|
||||
|
||||
const getSeverityStyle = (severity: 'major' | 'minor') => {
|
||||
return severity === 'major'
|
||||
? { icon: <XCircle className="w-4 h-4 text-red-500" />, label: '严重', badge: 'bg-red-100 text-red-700 border-red-200' }
|
||||
: { icon: <AlertTriangle className="w-4 h-4 text-amber-500" />, label: '轻微', badge: 'bg-amber-100 text-amber-700 border-amber-200' };
|
||||
};
|
||||
|
||||
const getScoreGrade = (score: number) => {
|
||||
if (score >= 90) return { label: '优秀', color: 'text-green-600', bg: 'bg-green-500' };
|
||||
if (score >= 80) return { label: '良好', color: 'text-green-600', bg: 'bg-green-500' };
|
||||
if (score >= 70) return { label: '中等', color: 'text-amber-600', bg: 'bg-amber-500' };
|
||||
if (score >= 60) return { label: '及格', color: 'text-amber-600', bg: 'bg-amber-500' };
|
||||
return { label: '不及格', color: 'text-red-600', bg: 'bg-red-500' };
|
||||
};
|
||||
|
||||
const getOverallStatus = () => {
|
||||
if (data.overall_score >= 80) return { label: '通过', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' };
|
||||
if (data.overall_score >= 60) return { label: '存疑', color: 'text-amber-700', bg: 'bg-amber-50', border: 'border-amber-200' };
|
||||
return { label: '不通过', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' };
|
||||
};
|
||||
|
||||
const grade = getScoreGrade(data.overall_score);
|
||||
const status = getOverallStatus();
|
||||
|
||||
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 ${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-xs text-slate-400 block">分</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`mt-2 px-3 py-1 rounded-full text-xs font-bold ${grade.bg} text-white`}>
|
||||
{grade.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 评估摘要 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Microscope className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="font-bold text-lg text-slate-800">方法学评估</h3>
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-bold ${status.bg} ${status.color} ${status.border} border`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-4">{data.summary}</p>
|
||||
|
||||
{/* 统计指标 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span className="text-sm text-slate-600">共检测 <span className="font-bold text-slate-800">{data.parts.length}</span> 个方面</span>
|
||||
</div>
|
||||
{totalIssues === 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>
|
||||
) : (
|
||||
<>
|
||||
{majorIssues > 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">{majorIssues} 个严重问题</span>
|
||||
</div>
|
||||
)}
|
||||
{minorIssues > 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">{minorIssues} 个轻微问题</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分项详情标题 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-purple-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">共 {data.parts.length} 项</span>
|
||||
</div>
|
||||
|
||||
{/* 分项详情 */}
|
||||
<div className="space-y-4">
|
||||
{data.parts.map((part, partIndex) => (
|
||||
<div key={partIndex} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
{/* 分项头部 */}
|
||||
<div className={`px-5 py-4 border-b ${part.issues.length === 0 ? 'bg-green-50/50 border-green-100' : 'bg-slate-50 border-slate-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{part.issues.length === 0 ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
)}
|
||||
<h4 className="font-semibold text-slate-800">{part.part}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-bold ${
|
||||
part.score >= 80 ? 'bg-green-100 text-green-700' :
|
||||
part.score >= 60 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{part.score}分
|
||||
</span>
|
||||
{part.issues.length === 0 ? (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-green-100 text-green-700 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
无问题
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-amber-100 text-amber-700">
|
||||
{part.issues.length} 个问题
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 问题列表 */}
|
||||
{part.issues.length > 0 && (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{part.issues.map((issue, issueIndex) => {
|
||||
const severity = getSeverityStyle(issue.severity);
|
||||
return (
|
||||
<div key={issueIndex} className="px-5 py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-0.5">{severity.icon}</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* 问题标题和严重程度 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-slate-800">{issue.type}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${severity.badge}`}>
|
||||
{severity.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 问题描述 */}
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{issue.description}</p>
|
||||
|
||||
{/* 位置信息 */}
|
||||
{issue.location && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>位置:{issue.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 改进建议 */}
|
||||
{issue.suggestion && (
|
||||
<div className="bg-indigo-50/50 rounded-lg p-3 border border-indigo-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-indigo-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-indigo-600 mb-1">改进建议</p>
|
||||
<p className="text-sm text-slate-700">{issue.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无问题时的简洁显示 */}
|
||||
{part.issues.length === 0 && (
|
||||
<div className="px-5 py-4 text-sm text-green-600 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>该部分未发现方法学问题,符合规范要求</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend-v2/src/modules/rvw/components/ReportDetail.tsx
Normal file
110
frontend-v2/src/modules/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-v2/src/modules/rvw/components/ScoreRing.tsx
Normal file
38
frontend-v2/src/modules/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-v2/src/modules/rvw/components/Sidebar.tsx
Normal file
73
frontend-v2/src/modules/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>
|
||||
);
|
||||
}
|
||||
|
||||
574
frontend-v2/src/modules/rvw/components/TaskDetail.tsx
Normal file
574
frontend-v2/src/modules/rvw/components/TaskDetail.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* 任务详情页组件
|
||||
* 支持显示审稿进度和结果
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot } 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 * as api from '../api';
|
||||
import { message } from 'antd';
|
||||
|
||||
interface TaskDetailProps {
|
||||
task: ReviewTask;
|
||||
jobId?: string | null; // pg-boss 任务ID(可选,用于更精确的状态轮询)
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
// 状态信息映射
|
||||
const STATUS_INFO: Record<TaskStatus, { label: string; color: string; icon: any }> = {
|
||||
pending: { label: '等待开始', color: 'text-slate-500', icon: Clock },
|
||||
extracting: { label: '正在提取文档', color: 'text-blue-500', icon: Loader2 },
|
||||
reviewing: { label: '正在初始化审查', color: 'text-indigo-500', icon: Loader2 },
|
||||
reviewing_editorial: { label: '正在审查稿约规范性', color: 'text-indigo-500', icon: Bot },
|
||||
reviewing_methodology: { label: '正在审查方法学', color: 'text-purple-500', icon: Bot },
|
||||
completed: { label: '审查完成', color: 'text-green-600', icon: CheckCircle },
|
||||
failed: { label: '审查失败', color: 'text-red-500', icon: AlertCircle },
|
||||
};
|
||||
|
||||
// 根据选择的智能体动态生成进度步骤
|
||||
const getProgressSteps = (selectedAgents: string[]) => {
|
||||
const steps = [
|
||||
{ key: 'upload', label: '上传文档' },
|
||||
{ key: 'extract', label: '文本提取' },
|
||||
];
|
||||
|
||||
if (selectedAgents.includes('editorial')) {
|
||||
steps.push({ key: 'editorial', label: '稿约规范性' });
|
||||
}
|
||||
if (selectedAgents.includes('methodology')) {
|
||||
steps.push({ key: 'methodology', label: '方法学评估' });
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
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 [elapsedTime, setElapsedTime] = useState(0);
|
||||
|
||||
// Suppress unused variable warning - jobId is reserved for future use
|
||||
void jobId;
|
||||
|
||||
const isProcessing = ['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(task.status);
|
||||
const isCompleted = task.status === 'completed';
|
||||
const isFailed = task.status === 'failed';
|
||||
|
||||
// 轮询任务状态
|
||||
useEffect(() => {
|
||||
if (!isProcessing) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const updated = await api.getTask(task.id);
|
||||
setTask(updated);
|
||||
|
||||
// 如果完成了,加载报告
|
||||
if (updated.status === 'completed') {
|
||||
const reportData = await api.getTaskReport(task.id);
|
||||
setReport(reportData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新任务状态失败:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [task.id, isProcessing]);
|
||||
|
||||
// 计时器
|
||||
useEffect(() => {
|
||||
if (!isProcessing) return;
|
||||
|
||||
const start = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(Math.floor((Date.now() - start) / 1000));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isProcessing]);
|
||||
|
||||
// 完成时加载报告
|
||||
useEffect(() => {
|
||||
if (isCompleted && !report) {
|
||||
api.getTaskReport(task.id).then(setReport).catch(() => {
|
||||
message.error('加载报告失败');
|
||||
});
|
||||
}
|
||||
}, [isCompleted, task.id, report]);
|
||||
|
||||
// 报告加载后自动设置正确的 Tab
|
||||
useEffect(() => {
|
||||
if (report) {
|
||||
// 优先显示有数据的 Tab
|
||||
if (report.editorialReview) {
|
||||
setActiveTab('editorial');
|
||||
} else if (report.methodologyReview) {
|
||||
setActiveTab('methodology');
|
||||
}
|
||||
}
|
||||
}, [report]);
|
||||
|
||||
// 动态获取进度步骤
|
||||
const progressSteps = getProgressSteps(task.selectedAgents || ['editorial', 'methodology']);
|
||||
|
||||
// 获取进度步骤状态
|
||||
const getStepStatus = (stepKey: string): 'completed' | 'active' | 'pending' => {
|
||||
const hasEditorial = task.selectedAgents?.includes('editorial');
|
||||
const hasMethodology = task.selectedAgents?.includes('methodology');
|
||||
|
||||
if (task.status === 'pending') {
|
||||
return stepKey === 'upload' ? 'completed' : 'pending';
|
||||
}
|
||||
if (task.status === 'extracting') {
|
||||
if (stepKey === 'upload') return 'completed';
|
||||
if (stepKey === 'extract') return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (task.status === 'reviewing' || task.status === 'reviewing_editorial') {
|
||||
if (['upload', 'extract'].includes(stepKey)) return 'completed';
|
||||
if (stepKey === 'editorial' && hasEditorial) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (task.status === 'reviewing_methodology') {
|
||||
if (['upload', 'extract'].includes(stepKey)) return 'completed';
|
||||
if (stepKey === 'editorial') return 'completed';
|
||||
if (stepKey === 'methodology' && hasMethodology) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (task.status === 'completed') return 'completed';
|
||||
if (task.status === 'failed') {
|
||||
if (['upload', 'extract'].includes(stepKey)) return 'completed';
|
||||
return 'pending';
|
||||
}
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
// 导出报告为 Word 文档
|
||||
const handleExportReport = async () => {
|
||||
if (!report) {
|
||||
message.warning('报告尚未加载完成');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
message.loading({ content: '正在生成Word文档...', key: 'export' });
|
||||
|
||||
const children: (Paragraph | Table)[] = [];
|
||||
|
||||
// 标题
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: '智能审稿报告',
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 400 },
|
||||
})
|
||||
);
|
||||
|
||||
// 基本信息表格
|
||||
children.push(
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '文件名', bold: true })] })],
|
||||
width: { size: 25, type: WidthType.PERCENTAGE },
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(report.fileName)],
|
||||
width: { size: 75, type: WidthType.PERCENTAGE },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '综合评分', bold: true })] })],
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(`${report.overallScore || '-'} 分`)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '审查用时', bold: true })] })],
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(
|
||||
report.durationSeconds
|
||||
? `${Math.floor(report.durationSeconds / 60)}分${report.durationSeconds % 60}秒`
|
||||
: '-'
|
||||
)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '审查时间', bold: true })] })],
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(report.completedAt ? new Date(report.completedAt).toLocaleString('zh-CN') : '-')],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
children.push(new Paragraph({ spacing: { after: 300 } }));
|
||||
|
||||
// 稿约规范性评估
|
||||
if (report.editorialReview) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `一、稿约规范性评估(${report.editorialReview.overall_score}分)`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 400, after: 200 },
|
||||
})
|
||||
);
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: '总体评价:', bold: true }), new TextRun(report.editorialReview.summary)],
|
||||
spacing: { after: 200 },
|
||||
})
|
||||
);
|
||||
|
||||
report.editorialReview.items.forEach((item, i) => {
|
||||
const statusText = item.status === 'pass' ? '✓通过' : item.status === 'warning' ? '⚠警告' : '✗不通过';
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `${i + 1}. ${item.criterion}(${item.score}分)- ${statusText}`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 },
|
||||
})
|
||||
);
|
||||
|
||||
if (item.issues?.length) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: '存在问题:', bold: true, color: 'CC0000' })],
|
||||
})
|
||||
);
|
||||
item.issues.forEach(issue => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `• ${issue}`,
|
||||
indent: { left: 720 },
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (item.suggestions?.length) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: '修改建议:', bold: true, color: '006600' })],
|
||||
spacing: { before: 100 },
|
||||
})
|
||||
);
|
||||
item.suggestions.forEach(s => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `• ${s}`,
|
||||
indent: { left: 720 },
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 方法学评估
|
||||
if (report.methodologyReview) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `二、方法学评估(${report.methodologyReview.overall_score}分)`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 400, after: 200 },
|
||||
})
|
||||
);
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: '总体评价:', bold: true }), new TextRun(report.methodologyReview.summary)],
|
||||
spacing: { after: 200 },
|
||||
})
|
||||
);
|
||||
|
||||
report.methodologyReview.parts.forEach((part) => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `${part.part}(${part.score}分)`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 },
|
||||
})
|
||||
);
|
||||
|
||||
if (part.issues.length === 0) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: '✓ 未发现问题', color: '006600' })],
|
||||
indent: { left: 720 },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
part.issues.forEach(issue => {
|
||||
const severityText = issue.severity === 'major' ? '【严重】' : '【轻微】';
|
||||
const severityColor = issue.severity === 'major' ? 'CC0000' : 'FF9900';
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: severityText, bold: true, color: severityColor }),
|
||||
new TextRun({ text: ` ${issue.type}:`, bold: true }),
|
||||
new TextRun(issue.description),
|
||||
],
|
||||
indent: { left: 720 },
|
||||
})
|
||||
);
|
||||
|
||||
if (issue.suggestion) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: '建议:', bold: true, color: '006600' }),
|
||||
new TextRun(issue.suggestion),
|
||||
],
|
||||
indent: { left: 1080 },
|
||||
spacing: { after: 100 },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 页脚
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: '————————————————————————', color: 'AAAAAA' }),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 600 },
|
||||
})
|
||||
);
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: '本报告由AI智能审稿系统自动生成', italics: true, color: '888888', size: 20 }),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
})
|
||||
);
|
||||
|
||||
// 创建文档
|
||||
const doc = new Document({
|
||||
sections: [{
|
||||
properties: {},
|
||||
children,
|
||||
}],
|
||||
});
|
||||
|
||||
// 生成并下载
|
||||
const blob = await Packer.toBlob(doc);
|
||||
const fileName = `审稿报告_${report.fileName.replace(/\.[^.]+$/, '')}.docx`;
|
||||
saveAs(blob, fileName);
|
||||
|
||||
message.success({ content: '报告已导出为Word文档', key: 'export', duration: 2 });
|
||||
} catch (error) {
|
||||
console.error('导出报告失败:', error);
|
||||
message.error({ content: '导出失败,请重试', key: 'export', duration: 3 });
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins > 0 ? `${mins}分${secs}秒` : `${secs}秒`;
|
||||
};
|
||||
|
||||
const statusInfo = STATUS_INFO[task.status];
|
||||
const StatusIcon = statusInfo.icon;
|
||||
|
||||
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">
|
||||
<FileText className="w-5 h-5 text-indigo-500" />
|
||||
{task.fileName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isCompleted && (
|
||||
<button
|
||||
onClick={handleExportReport}
|
||||
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">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 进度显示(审查中) */}
|
||||
{isProcessing && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 mb-8">
|
||||
{/* 状态头部 */}
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<StatusIcon className={`w-6 h-6 ${statusInfo.color} ${isProcessing ? 'animate-spin' : ''}`} />
|
||||
<span className={`text-lg font-semibold ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<span className="text-slate-400 text-sm">
|
||||
已用时 {formatTime(elapsedTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 - 根据选择的智能体动态显示 */}
|
||||
<div className="flex items-center justify-between mb-6 px-8">
|
||||
{progressSteps.map((step, index) => {
|
||||
const stepStatus = getStepStatus(step.key);
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold
|
||||
${stepStatus === 'completed' ? 'bg-green-500 text-white' : ''}
|
||||
${stepStatus === 'active' ? 'bg-indigo-500 text-white animate-pulse' : ''}
|
||||
${stepStatus === 'pending' ? 'bg-slate-200 text-slate-400' : ''}
|
||||
`}>
|
||||
{stepStatus === 'completed' ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : stepStatus === 'active' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs mt-2 ${
|
||||
stepStatus === 'active' ? 'text-indigo-600 font-medium' : 'text-slate-500'
|
||||
}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < progressSteps.length - 1 && (
|
||||
<div className={`w-20 h-1 mx-2 rounded ${
|
||||
getStepStatus(progressSteps[index + 1].key) === 'completed' || stepStatus === 'completed'
|
||||
? 'bg-green-200'
|
||||
: 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="text-center text-slate-500 text-sm bg-slate-50 rounded-lg py-4">
|
||||
<p>AI 正在分析您的稿件,这可能需要 1-3 分钟</p>
|
||||
<p className="text-slate-400 mt-1">请耐心等待,完成后将自动显示结果</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态 */}
|
||||
{isFailed && (
|
||||
<div className="bg-red-50 rounded-xl border border-red-200 p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-red-700 mb-2">审查失败</h2>
|
||||
<p className="text-red-600 text-sm">{task.errorMessage || '未知错误,请重试'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 完成状态 - 显示报告 */}
|
||||
{isCompleted && report && (
|
||||
<>
|
||||
{/* 分数卡片 */}
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg p-6 mb-8 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">综合评分</h2>
|
||||
<p className="text-indigo-100 text-sm">
|
||||
审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-5xl font-bold">{report.overallScore || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab切换 */}
|
||||
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-6 w-fit mx-auto">
|
||||
{report.editorialReview && (
|
||||
<button
|
||||
onClick={() => setActiveTab('editorial')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
activeTab === 'editorial'
|
||||
? 'font-bold bg-white text-indigo-600 shadow-sm'
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
稿约规范性 ({report.editorialReview.overall_score}分)
|
||||
</button>
|
||||
)}
|
||||
{report.methodologyReview && (
|
||||
<button
|
||||
onClick={() => setActiveTab('methodology')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
activeTab === '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>
|
||||
|
||||
{/* 报告内容 */}
|
||||
{activeTab === 'editorial' && report.editorialReview && (
|
||||
<EditorialReport data={report.editorialReview} />
|
||||
)}
|
||||
{activeTab === 'methodology' && report.methodologyReview && (
|
||||
<MethodologyReport data={report.methodologyReview} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
307
frontend-v2/src/modules/rvw/components/TaskTable.tsx
Normal file
307
frontend-v2/src/modules/rvw/components/TaskTable.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 任务表格组件
|
||||
*/
|
||||
import { FileText, FileType2, Loader2, Play, Eye, RefreshCw, Trash2 } 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;
|
||||
onDeleteTask: (task: ReviewTask) => void;
|
||||
}
|
||||
|
||||
export default function TaskTable({
|
||||
tasks,
|
||||
selectedIds,
|
||||
onSelectChange,
|
||||
onViewReport,
|
||||
onRunTask,
|
||||
onDeleteTask
|
||||
}: 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.methodologyScore !== undefined || task.methodologyStatus) && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
(task.methodologyScore !== undefined ? task.methodologyScore >= 80 : task.methodologyStatus === '通过') ? 'bg-green-500' :
|
||||
(task.methodologyScore !== undefined ? task.methodologyScore >= 60 : task.methodologyStatus === '存疑') ? 'bg-amber-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className="text-slate-600">方法学:</span>
|
||||
<span className={`font-bold ${
|
||||
(task.methodologyScore !== undefined ? task.methodologyScore >= 80 : task.methodologyStatus === '通过') ? 'text-green-700' :
|
||||
(task.methodologyScore !== undefined ? task.methodologyScore >= 60 : task.methodologyStatus === '存疑') ? 'text-amber-700' : 'text-red-700'
|
||||
}`}>
|
||||
{task.methodologyScore !== undefined ? `${task.methodologyScore}分` : task.methodologyStatus}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 渲染操作按钮
|
||||
const renderActions = (task: ReviewTask) => {
|
||||
// 待审稿:[开始审稿] [删除]
|
||||
if (task.status === 'pending') {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => onRunTask(task)}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white 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>
|
||||
<button
|
||||
onClick={() => onDeleteTask(task)}
|
||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1.5 rounded-md transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理中:[查看进度]
|
||||
if (['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(task.status)) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onViewReport(task)}
|
||||
className="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"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
查看进度
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 已完成:[查看报告] [重新审稿] [删除]
|
||||
if (task.status === 'completed') {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => onRunTask(task)}
|
||||
className="text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 p-1.5 rounded-md transition-colors"
|
||||
title="重新审稿"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteTask(task)}
|
||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1.5 rounded-md transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 失败:[重新审稿] [删除]
|
||||
if (task.status === 'failed') {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
重新审稿
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteTask(task)}
|
||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1.5 rounded-md transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
15
frontend-v2/src/modules/rvw/components/index.ts
Normal file
15
frontend-v2/src/modules/rvw/components/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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';
|
||||
export { default as TaskDetail } from './TaskDetail';
|
||||
|
||||
@@ -5,498 +5,20 @@
|
||||
* - 稿约评审:评估稿件是否符合期刊投稿要求
|
||||
* - 方法学评审:评估临床研究的方法学质量
|
||||
*
|
||||
* @version Phase 3 - 前端重构
|
||||
* @version Phase 3 - 前端重构(迁移到 frontend-v2)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, Routes, Route, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Upload,
|
||||
Button,
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
message,
|
||||
Card,
|
||||
Spin,
|
||||
Modal,
|
||||
Checkbox,
|
||||
Progress,
|
||||
Tabs,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Popconfirm
|
||||
} from 'antd'
|
||||
import {
|
||||
UploadOutlined,
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
DownloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { UploadFile, UploadProps } from 'antd'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
const { TabPane } = Tabs
|
||||
|
||||
// API 基础路径
|
||||
const API_BASE = '/api/v2/rvw'
|
||||
|
||||
// 任务类型定义
|
||||
interface ReviewTask {
|
||||
id: string
|
||||
fileName: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
selectedAgents: string[]
|
||||
editorialScore?: number
|
||||
methodologyStatus?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
editorialReview?: any
|
||||
methodologyReview?: any
|
||||
}
|
||||
|
||||
// 智能体选择弹窗
|
||||
const AgentSelectModal: React.FC<{
|
||||
visible: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (agents: string[]) => void
|
||||
loading?: boolean
|
||||
}> = ({ visible, onCancel, onConfirm, loading }) => {
|
||||
const [selected, setSelected] = useState<string[]>(['editorial', 'methodology'])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="选择审稿智能体"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={() => onConfirm(selected)}
|
||||
confirmLoading={loading}
|
||||
okText="开始审稿"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="py-4">
|
||||
<Checkbox.Group
|
||||
value={selected}
|
||||
onChange={(vals) => setSelected(vals as string[])}
|
||||
>
|
||||
<Space direction="vertical" size="middle">
|
||||
<Checkbox value="editorial">
|
||||
<div>
|
||||
<div className="font-medium">📝 稿约评审智能体</div>
|
||||
<div className="text-gray-500 text-sm">评估稿件是否符合期刊投稿要求(11项标准)</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
<Checkbox value="methodology">
|
||||
<div>
|
||||
<div className="font-medium">🔬 方法学评审智能体</div>
|
||||
<div className="text-gray-500 text-sm">评估临床研究的方法学质量(20项检查点)</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
{selected.length === 0 && (
|
||||
<div className="text-red-500 mt-2">请至少选择一个智能体</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// 任务列表页面
|
||||
const TaskListPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [tasks, setTasks] = useState<ReviewTask[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [showAgentModal, setShowAgentModal] = useState(false)
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||
const [runningTaskId, setRunningTaskId] = useState<string | null>(null)
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${API_BASE}/tasks`)
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setTasks(data.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载任务列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
}, [])
|
||||
|
||||
// 上传文件
|
||||
const handleUpload = async (agents: string[]) => {
|
||||
if (!pendingFile || agents.length === 0) return
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', pendingFile)
|
||||
formData.append('selectedAgents', JSON.stringify(agents))
|
||||
|
||||
const res = await fetch(`${API_BASE}/tasks`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success('稿件上传成功,开始审稿...')
|
||||
setShowAgentModal(false)
|
||||
setPendingFile(null)
|
||||
loadTasks()
|
||||
} else {
|
||||
message.error(data.error || '上传失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = async (taskId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
message.success('删除成功')
|
||||
loadTasks()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 运行审稿
|
||||
const handleRun = async (taskId: string, agents: string[]) => {
|
||||
try {
|
||||
setRunningTaskId(taskId)
|
||||
const res = await fetch(`${API_BASE}/tasks/${taskId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ selectedAgents: agents }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
message.success('审稿任务已启动')
|
||||
loadTasks()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('启动审稿失败')
|
||||
} finally {
|
||||
setRunningTaskId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传配置
|
||||
const uploadProps: UploadProps = {
|
||||
beforeUpload: (file) => {
|
||||
setPendingFile(file)
|
||||
setShowAgentModal(true)
|
||||
return false
|
||||
},
|
||||
showUploadList: false,
|
||||
accept: '.pdf,.doc,.docx,.txt',
|
||||
}
|
||||
|
||||
// 状态标签
|
||||
const getStatusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
|
||||
pending: { color: 'default', icon: <ClockCircleOutlined />, text: '待审稿' },
|
||||
processing: { color: 'processing', icon: <Spin size="small" />, text: '审稿中' },
|
||||
completed: { color: 'success', icon: <CheckCircleOutlined />, text: '已完成' },
|
||||
failed: { color: 'error', icon: <ExclamationCircleOutlined />, text: '失败' },
|
||||
}
|
||||
const c = config[status] || config.pending
|
||||
return <Tag color={c.color} icon={c.icon}>{c.text}</Tag>
|
||||
}
|
||||
|
||||
// 表格列
|
||||
const columns = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
render: (name: string) => (
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
render: (status: string) => getStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: '智能体',
|
||||
dataIndex: 'selectedAgents',
|
||||
key: 'selectedAgents',
|
||||
width: 200,
|
||||
render: (agents: string[]) => (
|
||||
<Space>
|
||||
{agents?.includes('editorial') && <Tag color="blue">稿约</Tag>}
|
||||
{agents?.includes('methodology') && <Tag color="purple">方法学</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '稿约评分',
|
||||
dataIndex: 'editorialScore',
|
||||
key: 'editorialScore',
|
||||
width: 100,
|
||||
render: (score: number) => score ? `${score}分` : '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: any, record: ReviewTask) => (
|
||||
<Space>
|
||||
{record.status === 'completed' && (
|
||||
<Tooltip title="查看报告">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate(`/rvw/report/${record.id}`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{record.status === 'pending' && (
|
||||
<Tooltip title="开始审稿">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={runningTaskId === record.id}
|
||||
onClick={() => handleRun(record.id, record.selectedAgents || ['editorial', 'methodology'])}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定删除此任务?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>📋 智能期刊审稿系统</Title>
|
||||
<Text type="secondary">上传稿件,AI智能评估稿约符合度和方法学质量</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadTasks}>刷新</Button>
|
||||
<Upload {...uploadProps}>
|
||||
<Button type="primary" icon={<UploadOutlined />}>上传稿件</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AgentSelectModal
|
||||
visible={showAgentModal}
|
||||
onCancel={() => {
|
||||
setShowAgentModal(false)
|
||||
setPendingFile(null)
|
||||
}}
|
||||
onConfirm={handleUpload}
|
||||
loading={uploading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 报告详情页面
|
||||
const ReportDetailPage: React.FC = () => {
|
||||
const { taskId } = useParams<{ taskId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [task, setTask] = useState<ReviewTask | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadReport = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tasks/${taskId}/report`)
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setTask(data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载报告失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
if (taskId) loadReport()
|
||||
}, [taskId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-96">
|
||||
<Spin size="large" tip="加载报告中..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<Text type="secondary">报告不存在</Text>
|
||||
<br />
|
||||
<Button onClick={() => navigate('/rvw')}>返回列表</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Button onClick={() => navigate('/rvw')} className="mb-2">← 返回列表</Button>
|
||||
<Title level={3} style={{ margin: 0 }}>{task.fileName}</Title>
|
||||
</div>
|
||||
<Button icon={<DownloadOutlined />}>导出报告</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultActiveKey="editorial">
|
||||
{task.editorialReview && (
|
||||
<TabPane tab="📝 稿约评审" key="editorial">
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<Title level={4}>总体评分:{task.editorialScore || 0}分</Title>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Title level={5}>总体评价</Title>
|
||||
<Paragraph>{task.editorialReview.overallAssessment}</Paragraph>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Title level={5}>详细评审结果</Title>
|
||||
{task.editorialReview.criteria?.map((item: any, index: number) => (
|
||||
<Card key={index} size="small" className="mb-2">
|
||||
<div className="flex justify-between">
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color={item.passed ? 'success' : 'error'}>
|
||||
{item.passed ? '通过' : '不通过'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text type="secondary">{item.comment}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Title level={5}>修改建议</Title>
|
||||
<ul>
|
||||
{task.editorialReview.suggestions?.map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPane>
|
||||
)}
|
||||
|
||||
{task.methodologyReview && (
|
||||
<TabPane tab="🔬 方法学评审" key="methodology">
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<Title level={4}>
|
||||
评审结论:
|
||||
<Tag color={task.methodologyReview.overallConclusion === 'acceptable' ? 'success' : 'warning'}>
|
||||
{task.methodologyReview.overallConclusion === 'acceptable' ? '可接受' :
|
||||
task.methodologyReview.overallConclusion === 'needs_revision' ? '需修改' : '不可接受'}
|
||||
</Tag>
|
||||
</Title>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Title level={5}>总体评价</Title>
|
||||
<Paragraph>{task.methodologyReview.overallAssessment}</Paragraph>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Title level={5}>检查点结果</Title>
|
||||
{task.methodologyReview.checkpoints?.map((item: any, index: number) => (
|
||||
<Card key={index} size="small" className="mb-2">
|
||||
<div className="flex justify-between">
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color={
|
||||
item.status === 'pass' ? 'success' :
|
||||
item.status === 'fail' ? 'error' : 'warning'
|
||||
}>
|
||||
{item.status === 'pass' ? '通过' :
|
||||
item.status === 'fail' ? '不通过' : '部分通过'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text type="secondary">{item.comment}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Title level={5}>改进建议</Title>
|
||||
<ul>
|
||||
{task.methodologyReview.recommendations?.map((r: string, i: number) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
|
||||
// 模块主入口
|
||||
const RVWModule: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<TaskListPage />} />
|
||||
<Route path="report/:taskId" element={<ReportDetailPage />} />
|
||||
<Route index element={<Dashboard />} />
|
||||
{/* 可以在这里添加更多路由,如 /report/:taskId */}
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default RVWModule
|
||||
);
|
||||
};
|
||||
|
||||
export default RVWModule;
|
||||
|
||||
284
frontend-v2/src/modules/rvw/pages/Dashboard.tsx
Normal file
284
frontend-v2/src/modules/rvw/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* RVW审稿系统 - 主Dashboard页面
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import {
|
||||
Sidebar,
|
||||
Header,
|
||||
FilterChips,
|
||||
TaskTable,
|
||||
BatchToolbar,
|
||||
AgentModal,
|
||||
ReportDetail,
|
||||
TaskDetail,
|
||||
} from '../components';
|
||||
import * as api from '../api';
|
||||
import type { ReviewTask, ReviewReport, TaskFilters, AgentType } from '../types';
|
||||
import '../styles/index.css';
|
||||
|
||||
export default function Dashboard() {
|
||||
// ==================== State ====================
|
||||
const [currentView, setCurrentView] = useState<'dashboard' | 'archive'>('dashboard');
|
||||
const [tasks, setTasks] = useState<ReviewTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [filters, setFilters] = useState<TaskFilters>({ status: 'all', timeRange: 'all' });
|
||||
const [agentModalVisible, setAgentModalVisible] = useState(false);
|
||||
const [pendingTaskForRun, setPendingTaskForRun] = useState<ReviewTask | null>(null);
|
||||
|
||||
// 报告详情
|
||||
const [reportDetail, setReportDetail] = useState<ReviewReport | null>(null);
|
||||
|
||||
// 任务详情(支持进度显示)
|
||||
const [viewingTask, setViewingTask] = useState<ReviewTask | null>(null);
|
||||
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
|
||||
|
||||
// ==================== 数据加载 ====================
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getTasks(filters.status !== 'all' ? filters.status : undefined);
|
||||
|
||||
// 时间筛选
|
||||
let filtered = data;
|
||||
if (filters.timeRange === 'today') {
|
||||
const today = new Date().toDateString();
|
||||
filtered = data.filter(t => new Date(t.createdAt).toDateString() === today);
|
||||
} else if (filters.timeRange === 'week') {
|
||||
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
filtered = data.filter(t => new Date(t.createdAt).getTime() > weekAgo);
|
||||
}
|
||||
|
||||
setTasks(filtered);
|
||||
} catch (error) {
|
||||
console.error('加载任务失败:', error);
|
||||
message.error('加载任务列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// 轮询更新进行中的任务
|
||||
useEffect(() => {
|
||||
const processingTasks = tasks.filter(t =>
|
||||
t.status === 'extracting' || t.status === 'reviewing'
|
||||
);
|
||||
|
||||
if (processingTasks.length === 0) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
for (const task of processingTasks) {
|
||||
try {
|
||||
const updated = await api.getTask(task.id);
|
||||
setTasks(prev => prev.map(t => t.id === updated.id ? updated : t));
|
||||
} catch (error) {
|
||||
console.error('更新任务状态失败:', error);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [tasks]);
|
||||
|
||||
// ==================== 统计数据 ====================
|
||||
const counts = {
|
||||
all: tasks.length,
|
||||
pending: tasks.filter(t => t.status === 'pending').length,
|
||||
completed: tasks.filter(t => t.status === 'completed').length,
|
||||
};
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
const handleUpload = async (files: FileList) => {
|
||||
const uploadPromises = Array.from(files).map(async (file) => {
|
||||
try {
|
||||
message.loading({ content: `正在上传 ${file.name}...`, key: file.name });
|
||||
await api.uploadManuscript(file);
|
||||
message.success({ content: `${file.name} 上传成功`, key: file.name, duration: 2 });
|
||||
} catch (error: any) {
|
||||
message.error({ content: `${file.name} 上传失败: ${error.message}`, key: file.name, duration: 3 });
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
loadTasks();
|
||||
};
|
||||
|
||||
const handleRunTask = (task: ReviewTask) => {
|
||||
setPendingTaskForRun(task);
|
||||
setAgentModalVisible(true);
|
||||
};
|
||||
|
||||
const handleRunBatch = () => {
|
||||
setPendingTaskForRun(null); // 批量模式
|
||||
setAgentModalVisible(true);
|
||||
};
|
||||
|
||||
const handleConfirmRun = async (agents: AgentType[]) => {
|
||||
// 🔥 保存到局部变量,避免onClose后丢失
|
||||
const taskToRun = pendingTaskForRun;
|
||||
|
||||
// 立即关闭弹窗
|
||||
setAgentModalVisible(false);
|
||||
setPendingTaskForRun(null);
|
||||
|
||||
try {
|
||||
if (taskToRun) {
|
||||
// 单个任务 - 启动后跳转到详情页显示进度
|
||||
message.loading({ content: '正在启动审查...', key: 'run' });
|
||||
const { jobId } = await api.runTask(taskToRun.id, agents);
|
||||
message.success({ content: '审查已启动', key: 'run', duration: 2 });
|
||||
|
||||
// 更新任务状态后跳转到详情页(传递jobId)
|
||||
const updatedTask = await api.getTask(taskToRun.id);
|
||||
setCurrentJobId(jobId);
|
||||
setViewingTask(updatedTask);
|
||||
return;
|
||||
} else {
|
||||
// 批量任务
|
||||
const pendingIds = selectedIds.filter(id => {
|
||||
const task = tasks.find(t => t.id === id);
|
||||
return task && task.status === 'pending';
|
||||
});
|
||||
|
||||
if (pendingIds.length === 0) {
|
||||
message.warning('没有待处理的任务');
|
||||
return;
|
||||
}
|
||||
|
||||
message.loading({ content: `正在启动 ${pendingIds.length} 个任务...`, key: 'run' });
|
||||
await api.batchRunTasks(pendingIds, agents);
|
||||
message.success({ content: `${pendingIds.length} 个任务已启动`, key: 'run', duration: 2 });
|
||||
setSelectedIds([]);
|
||||
}
|
||||
|
||||
loadTasks();
|
||||
} catch (error: any) {
|
||||
message.error({ content: error.message || '启动失败', key: 'run', duration: 3 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewReport = async (task: ReviewTask) => {
|
||||
// 直接使用TaskDetail视图(支持进度和报告)
|
||||
setViewingTask(task);
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (task: ReviewTask) => {
|
||||
if (!window.confirm(`确定要删除 "${task.fileName}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
message.loading({ content: '正在删除...', key: 'delete' });
|
||||
await api.deleteTask(task.id);
|
||||
message.success({ content: '删除成功', key: 'delete', duration: 2 });
|
||||
loadTasks();
|
||||
} catch (error: any) {
|
||||
message.error({ content: error.message || '删除失败', key: 'delete', duration: 3 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
setReportDetail(null);
|
||||
};
|
||||
|
||||
// 返回列表并刷新
|
||||
const handleBackFromDetail = () => {
|
||||
setViewingTask(null);
|
||||
setCurrentJobId(null);
|
||||
loadTasks();
|
||||
};
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
// 任务详情视图(支持进度显示)
|
||||
if (viewingTask) {
|
||||
return (
|
||||
<div className="h-full flex overflow-hidden bg-slate-50">
|
||||
<Sidebar
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
/>
|
||||
<TaskDetail task={viewingTask} jobId={currentJobId} onBack={handleBackFromDetail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 报告详情视图(旧版,保留兼容)
|
||||
if (reportDetail) {
|
||||
return (
|
||||
<div className="h-full flex overflow-hidden bg-slate-50">
|
||||
<Sidebar
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
/>
|
||||
<ReportDetail report={reportDetail} onBack={handleBackToList} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 主仪表盘视图
|
||||
return (
|
||||
<div className="h-full flex overflow-hidden bg-slate-50">
|
||||
<Sidebar
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
/>
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0 bg-white">
|
||||
<div className="flex-1 flex flex-col h-full relative fade-in">
|
||||
{/* 顶部操作区 */}
|
||||
<header className="bg-white px-8 pt-6 pb-4 border-b border-gray-100 flex-shrink-0 z-10">
|
||||
<Header onUpload={handleUpload} />
|
||||
<FilterChips
|
||||
filters={filters}
|
||||
counts={counts}
|
||||
onFilterChange={setFilters}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* 列表区域 */}
|
||||
<div className="flex-1 overflow-auto bg-slate-50/50 p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskTable
|
||||
tasks={tasks}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={setSelectedIds}
|
||||
onViewReport={handleViewReport}
|
||||
onRunTask={handleRunTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 批量操作工具栏 */}
|
||||
<BatchToolbar
|
||||
selectedCount={selectedIds.length}
|
||||
onRunBatch={handleRunBatch}
|
||||
onClearSelection={() => setSelectedIds([])}
|
||||
/>
|
||||
|
||||
{/* 智能体选择弹窗 */}
|
||||
<AgentModal
|
||||
visible={agentModalVisible}
|
||||
taskCount={pendingTaskForRun ? 1 : selectedIds.length}
|
||||
onClose={() => {
|
||||
setAgentModalVisible(false);
|
||||
setPendingTaskForRun(null);
|
||||
}}
|
||||
onConfirm={handleConfirmRun}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
233
frontend-v2/src/modules/rvw/styles/index.css
Normal file
233
frontend-v2/src/modules/rvw/styles/index.css
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* RVW模块样式
|
||||
* 基于原型图 V7 的高保真还原
|
||||
*/
|
||||
|
||||
/* ==================== 状态标签 ==================== */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tag-blue {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #dbeafe;
|
||||
}
|
||||
|
||||
.tag-purple {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
border-color: #ede9fe;
|
||||
}
|
||||
|
||||
.tag-green {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border-color: #dcfce7;
|
||||
}
|
||||
|
||||
.tag-amber {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border-color: #fef3c7;
|
||||
}
|
||||
|
||||
.tag-red {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.tag-gray {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ==================== 筛选 Chips ==================== */
|
||||
.filter-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background-color: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-color: #bfdbfe;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==================== 侧边栏 Tooltip ==================== */
|
||||
.sidebar-tooltip {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: 12px;
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 50;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-btn:hover .sidebar-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ==================== 动画 ==================== */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 表格悬停效果 ==================== */
|
||||
.task-table tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.task-table tbody tr:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.task-table tbody tr.selected {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
/* ==================== 评分环 ==================== */
|
||||
.score-circle {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.score-circle.pass {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.score-circle.warn {
|
||||
border-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.score-circle.fail {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ==================== 按钮样式 ==================== */
|
||||
.btn-primary {
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #4338ca;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ==================== 滚动条美化 ==================== */
|
||||
.overflow-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.overflow-auto::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-auto::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* ==================== 响应式调整 ==================== */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar-tooltip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
95
frontend-v2/src/modules/rvw/types/index.ts
Normal file
95
frontend-v2/src/modules/rvw/types/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* RVW模块类型定义
|
||||
*/
|
||||
|
||||
// 任务状态
|
||||
export type TaskStatus =
|
||||
| 'pending' // 待处理
|
||||
| 'extracting' // 提取文本中
|
||||
| 'reviewing' // 审查中
|
||||
| 'reviewing_editorial' // 正在审查稿约规范性
|
||||
| 'reviewing_methodology' // 正在审查方法学
|
||||
| 'completed' // 已完成
|
||||
| 'failed'; // 失败
|
||||
|
||||
// 智能体类型
|
||||
export type AgentType = 'editorial' | 'methodology';
|
||||
|
||||
// 审查任务
|
||||
export interface ReviewTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
status: TaskStatus;
|
||||
selectedAgents: AgentType[];
|
||||
wordCount?: number;
|
||||
overallScore?: number;
|
||||
editorialScore?: number;
|
||||
methodologyScore?: number; // 方法学分数
|
||||
methodologyStatus?: string; // 方法学状态(通过/存疑/不通过)
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
// 规范性评估项
|
||||
export interface EditorialItem {
|
||||
criterion: string;
|
||||
status: 'pass' | 'warning' | 'fail';
|
||||
score: number;
|
||||
issues?: string[];
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
// 规范性评估结果
|
||||
export interface EditorialReviewResult {
|
||||
overall_score: number;
|
||||
summary: string;
|
||||
items: EditorialItem[];
|
||||
}
|
||||
|
||||
// 方法学问题
|
||||
export interface MethodologyIssue {
|
||||
type: string;
|
||||
severity: 'major' | 'minor';
|
||||
description: string;
|
||||
location: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
// 方法学评估部分
|
||||
export interface MethodologyPart {
|
||||
part: string;
|
||||
score: number;
|
||||
issues: MethodologyIssue[];
|
||||
}
|
||||
|
||||
// 方法学评估结果
|
||||
export interface MethodologyReviewResult {
|
||||
overall_score: number;
|
||||
summary: string;
|
||||
parts: MethodologyPart[];
|
||||
}
|
||||
|
||||
// 完整审查报告
|
||||
export interface ReviewReport extends ReviewTask {
|
||||
editorialReview?: EditorialReviewResult;
|
||||
methodologyReview?: MethodologyReviewResult;
|
||||
modelUsed?: string;
|
||||
}
|
||||
|
||||
// API响应
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 筛选条件
|
||||
export interface TaskFilters {
|
||||
status: 'all' | 'pending' | 'completed';
|
||||
timeRange: 'all' | 'today' | 'week';
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ export { default as Placeholder } from './Placeholder';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2
frontend-v2/src/vite-env.d.ts
vendored
2
frontend-v2/src/vite-env.d.ts
vendored
@@ -31,6 +31,8 @@ interface ImportMeta {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user