feat(rvw): Complete RVW module development Phase 1-3

Summary:
- Migrate backend to modules/rvw with v2 API routes (/api/v2/rvw)
- Add new database fields: selectedAgents, editorialScore, methodologyStatus, picoExtract, isArchived
- Create frontend module in frontend-v2/src/modules/rvw
- Implement Dashboard with task list, filtering, batch operations
- Implement ReportDetail with dual tabs (editorial/methodology)
- Implement AgentModal for intelligent agent selection
- Register RVW module in moduleRegistry.ts
- Add navigation entry in TopNavigation
- Update documentation for RVW module status (v3.0)
- Update system status document (v2.9)

Features:
- User can select agents: editorial, methodology, or both
- Support batch task execution
- Task status filtering
- Replace console.log with logger service
- Maintain v1 API backward compatibility

Tested: Frontend and backend verified locally
Status: 85% complete (Phase 1-3 done)
This commit is contained in:
2026-01-07 22:39:08 +08:00
parent 06028c6952
commit 179afa2c6b
226 changed files with 5860 additions and 21 deletions

View File

@@ -6,10 +6,15 @@ import ChatPage from './pages/ChatPage'
import KnowledgePage from './pages/KnowledgePage'
import HistoryPage from './pages/HistoryPage'
import ReviewPage from './pages/ReviewPage'
import { RvwDashboard } from './pages/rvw'
function App() {
return (
<Routes>
{/* RVW模块 - 独立布局 */}
<Route path="/rvw" element={<RvwDashboard />} />
{/* 主系统 */}
<Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} />
<Route path="chat" element={<ChatPage />} />

View File

@@ -13,6 +13,7 @@ import {
UserOutlined,
SettingOutlined,
LogoutOutlined,
FileSearchOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { ProjectSelector } from '../components/ProjectSelector'
@@ -174,16 +175,32 @@ const MainLayout = () => {
borderBottom: '1px solid #f0f0f0',
}}
>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: '16px',
width: 64,
height: 64,
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: '16px',
width: 64,
height: 64,
}}
/>
{/* 预审稿快捷入口 */}
<Button
type="primary"
icon={<FileSearchOutlined />}
onClick={() => navigate('/rvw')}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
fontWeight: 600,
}}
>
稿
</Button>
</div>
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="bottomRight">
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>

View File

@@ -0,0 +1,236 @@
/**
* RVW审稿系统 - 主Dashboard页面
*/
import { useState, useEffect, useCallback } from 'react';
import { message } from 'antd';
import {
Sidebar,
Header,
FilterChips,
TaskTable,
BatchToolbar,
AgentModal,
ReportDetail,
} from './components';
import * as api from './api';
import type { ReviewTask, ReviewReport, TaskFilters, AgentType } from './types';
import './styles.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 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[]) => {
try {
if (pendingTaskForRun) {
// 单个任务
message.loading({ content: '正在启动审查...', key: 'run' });
await api.runTask(pendingTaskForRun.id, agents);
message.success({ content: '审查已启动', key: 'run', duration: 2 });
} 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) => {
try {
message.loading({ content: '加载报告中...', key: 'report' });
const report = await api.getTaskReport(task.id);
setReportDetail(report);
message.destroy('report');
} catch (error: any) {
message.error({ content: '加载报告失败', key: 'report', duration: 3 });
}
};
const handleBackToList = () => {
setReportDetail(null);
};
// ==================== 渲染 ====================
// 报告详情视图
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}
/>
)}
</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>
);
}

View File

@@ -0,0 +1,129 @@
/**
* 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!;
}
// 运行审查任务
export async function runTask(taskId: string, agents: AgentType[]): Promise<void> {
const response = await axios.post<ApiResponse<void>>(`${API_BASE}/tasks/${taskId}/run`, { agents });
if (!response.data.success) {
throw new Error(response.data.error || '运行失败');
}
}
// 批量运行审查任务
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' });
}

View File

@@ -0,0 +1,123 @@
/**
* 智能体选择弹窗
*/
import { useState } from 'react';
import { PlayCircle, X } from 'lucide-react';
import type { AgentType } from '../types';
interface AgentModalProps {
visible: boolean;
taskCount: number;
onClose: () => void;
onConfirm: (agents: AgentType[]) => void;
}
export default function AgentModal({ visible, taskCount, onClose, onConfirm }: AgentModalProps) {
const [selectedAgents, setSelectedAgents] = useState<AgentType[]>(['editorial']);
const toggleAgent = (agent: AgentType) => {
if (selectedAgents.includes(agent)) {
// 至少保留一个
if (selectedAgents.length > 1) {
setSelectedAgents(selectedAgents.filter(a => a !== agent));
}
} else {
setSelectedAgents([...selectedAgents, agent]);
}
};
const handleConfirm = () => {
onConfirm(selectedAgents);
onClose();
};
if (!visible) return null;
return (
<div className="fixed inset-0 bg-slate-900/50 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-[400px] overflow-hidden transform transition-all scale-100 fade-in">
{/* 头部 */}
<div className="bg-slate-900 p-5 text-white flex items-center justify-between">
<h3 className="font-bold text-lg flex items-center gap-2">
<PlayCircle className="w-5 h-5 text-indigo-400" />
稿
</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* 内容 */}
<div className="p-6 space-y-4">
<p className="text-sm text-slate-600 mb-4">
{taskCount > 1 ? `已选择 ${taskCount} 个稿件,请选择审稿维度:` : '请选择审稿维度:'}
</p>
{/* 规范性智能体 */}
<label
className={`flex items-start gap-3 p-4 border rounded-xl cursor-pointer transition-all ${
selectedAgents.includes('editorial')
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}`}
>
<input
type="checkbox"
checked={selectedAgents.includes('editorial')}
onChange={() => toggleAgent('editorial')}
className="mt-1 w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
/>
<div className="flex-1">
<span className="block font-bold text-slate-800 text-sm">稿</span>
<span className="block text-xs text-slate-500 mt-0.5">
11
</span>
</div>
<span className="tag tag-blue"></span>
</label>
{/* 方法学智能体 */}
<label
className={`flex items-start gap-3 p-4 border rounded-xl cursor-pointer transition-all ${
selectedAgents.includes('methodology')
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}`}
>
<input
type="checkbox"
checked={selectedAgents.includes('methodology')}
onChange={() => toggleAgent('methodology')}
className="mt-1 w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
/>
<div className="flex-1">
<span className="block font-bold text-slate-800 text-sm"></span>
<span className="block text-xs text-slate-500 mt-0.5">
20
</span>
</div>
<span className="tag tag-purple"></span>
</label>
</div>
{/* 底部按钮 */}
<div className="p-4 bg-slate-50 flex justify-end gap-3 border-t border-gray-100">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 hover:bg-gray-200 rounded-lg transition-colors"
>
</button>
<button
onClick={handleConfirm}
disabled={selectedAgents.length === 0}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-bold rounded-lg shadow-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
/**
* 批量操作浮动工具栏
*/
import { Play, X } from 'lucide-react';
interface BatchToolbarProps {
selectedCount: number;
onRunBatch: () => void;
onClearSelection: () => void;
}
export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelection }: BatchToolbarProps) {
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-8 left-1/2 transform -translate-x-1/2 bg-slate-800 text-white px-5 py-3 rounded-full shadow-2xl flex items-center gap-6 z-30 fade-in border border-slate-700">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-indigo-500 flex items-center justify-center text-[10px] font-bold">
{selectedCount}
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="h-4 w-px bg-slate-600" />
<button
onClick={onRunBatch}
className="text-sm font-bold text-white hover:text-indigo-300 flex items-center gap-2 transition-colors"
>
<Play className="w-4 h-4 text-green-400" />
稿
</button>
<button
onClick={onClearSelection}
className="text-slate-400 hover:text-white ml-2"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,108 @@
/**
* 规范性评估报告组件
*/
import { AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
import type { EditorialReviewResult } from '../types';
import ScoreRing from './ScoreRing';
interface EditorialReportProps {
data: EditorialReviewResult;
}
export default function EditorialReport({ data }: EditorialReportProps) {
const getStatusIcon = (status: 'pass' | 'warning' | 'fail') => {
switch (status) {
case 'pass':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'warning':
return <AlertTriangle className="w-5 h-5 text-amber-500" />;
case 'fail':
return <XCircle className="w-5 h-5 text-red-500" />;
}
};
const getStatusTag = (status: 'pass' | 'warning' | 'fail') => {
switch (status) {
case 'pass':
return <span className="tag tag-green"></span>;
case 'warning':
return <span className="tag tag-amber"></span>;
case 'fail':
return <span className="tag tag-red"></span>;
}
};
const getItemBgClass = (status: 'pass' | 'warning' | 'fail') => {
switch (status) {
case 'pass':
return '';
case 'warning':
return 'bg-amber-50/50';
case 'fail':
return 'bg-red-50/50';
}
};
return (
<div className="space-y-6 fade-in">
{/* 总分卡片 */}
<div className={`bg-white p-6 rounded-2xl shadow-sm border flex items-center gap-8 ${
data.overall_score >= 80 ? 'border-green-200' :
data.overall_score >= 60 ? 'border-amber-200' : 'border-red-200'
}`}>
<ScoreRing score={data.overall_score} size="medium" />
<div className="flex-1">
<h3 className="font-bold text-lg text-slate-800">
{data.overall_score >= 80 ? '基本符合稿约规范' :
data.overall_score >= 60 ? '部分符合稿约规范' : '不符合稿约规范'}
</h3>
<p className="text-slate-600 text-sm mt-1">{data.summary}</p>
</div>
</div>
{/* 检测详情 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 bg-slate-50 border-b border-gray-200 font-bold text-sm text-slate-700">
{data.items.length}
</div>
<div className="divide-y divide-gray-100">
{data.items.map((item, index) => (
<div key={index} className={`p-5 ${getItemBgClass(item.status)}`}>
<div className="flex gap-4">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<h4 className="font-bold text-sm text-slate-800">{item.criterion}</h4>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{item.score}</span>
{getStatusTag(item.status)}
</div>
</div>
{item.issues && item.issues.length > 0 && (
<div className="mt-3 space-y-1">
{item.issues.map((issue, i) => (
<p key={i} className="text-sm text-slate-600"> {issue}</p>
))}
</div>
)}
{item.suggestions && item.suggestions.length > 0 && (
<div className="mt-3 bg-white border border-gray-200 p-3 rounded-lg">
<p className="text-xs font-bold text-slate-500 mb-1"></p>
{item.suggestions.map((suggestion, i) => (
<p key={i} className="text-xs text-slate-600"> {suggestion}</p>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
/**
* 筛选Chips组件
*/
import type { TaskFilters } from '../types';
interface FilterChipsProps {
filters: TaskFilters;
counts: { all: number; pending: number; completed: number };
onFilterChange: (filters: TaskFilters) => void;
}
export default function FilterChips({ filters, counts, onFilterChange }: FilterChipsProps) {
const statusOptions = [
{ value: 'all' as const, label: '全部', count: counts.all },
{ value: 'pending' as const, label: '待处理', count: counts.pending },
{ value: 'completed' as const, label: '已完成', count: counts.completed },
];
const timeOptions = [
{ value: 'all' as const, label: '不限' },
{ value: 'today' as const, label: '今天' },
{ value: 'week' as const, label: '近7天' },
];
return (
<div className="flex items-center justify-between">
{/* 状态筛选 */}
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider mr-2">:</span>
{statusOptions.map(option => (
<button
key={option.value}
onClick={() => onFilterChange({ ...filters, status: option.value })}
className={`filter-chip ${filters.status === option.value ? 'active' : ''}`}
>
{option.label}
{option.count !== undefined && (
<span className={`ml-1 text-xs px-1.5 rounded-full ${
filters.status === option.value ? 'bg-black/10' : 'bg-slate-200'
}`}>
{option.count}
</span>
)}
</button>
))}
</div>
<div className="h-4 w-px bg-gray-200 mx-2" />
{/* 时间筛选 */}
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider mr-2">:</span>
{timeOptions.map(option => (
<button
key={option.value}
onClick={() => onFilterChange({ ...filters, timeRange: option.value })}
className={`filter-chip ${filters.timeRange === option.value ? 'active' : ''}`}
>
{option.label}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
/**
* Dashboard头部组件
*/
import { useRef } from 'react';
import { BrainCircuit, UploadCloud } from 'lucide-react';
interface HeaderProps {
onUpload: (files: FileList) => void;
}
export default function Header({ onUpload }: HeaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
onUpload(e.target.files);
// 重置input以允许选择相同文件
e.target.value = '';
}
};
return (
<div className="flex justify-between items-center mb-6">
{/* Logo区域 */}
<div className="flex items-center gap-3">
<div className="bg-indigo-50 p-2 rounded-lg text-indigo-700">
<BrainCircuit className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-800">稿</h1>
<p className="text-xs text-slate-500"></p>
</div>
</div>
{/* 上传按钮 */}
<div className="flex gap-3">
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx"
className="hidden"
onChange={handleFileChange}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-bold flex items-center gap-2 shadow-sm transition-all hover:-translate-y-0.5"
>
<UploadCloud className="w-4 h-4" />
稿
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
/**
* 方法学评估报告组件
*/
import { XCircle, AlertTriangle, CheckCircle } from 'lucide-react';
import type { MethodologyReviewResult } from '../types';
import ScoreRing from './ScoreRing';
interface MethodologyReportProps {
data: MethodologyReviewResult;
}
export default function MethodologyReport({ data }: MethodologyReportProps) {
const getSeverityStyle = (severity: 'major' | 'minor') => {
return severity === 'major'
? { border: 'border-red-200', bg: 'bg-red-50', icon: <XCircle className="w-4 h-4 text-red-500" />, label: '严重' }
: { border: 'border-amber-200', bg: 'bg-amber-50', icon: <AlertTriangle className="w-4 h-4 text-amber-500" />, label: '轻微' };
};
const getOverallStatus = () => {
if (data.overall_score >= 80) return { label: '通过', color: 'text-green-700', bg: 'bg-green-50' };
if (data.overall_score >= 60) return { label: '存疑', color: 'text-amber-700', bg: 'bg-amber-50' };
return { label: '不通过', color: 'text-red-700', bg: 'bg-red-50' };
};
const status = getOverallStatus();
return (
<div className="space-y-6 fade-in">
{/* 总分卡片 */}
<div className={`bg-white p-6 rounded-2xl shadow-sm border flex items-center gap-8 ${
data.overall_score >= 80 ? 'border-green-200' :
data.overall_score >= 60 ? 'border-amber-200' : 'border-red-200'
}`}>
<ScoreRing score={data.overall_score} size="medium" />
<div className="flex-1">
<h3 className="font-bold text-lg text-slate-800 flex items-center gap-2">
<span className={`text-sm px-2 py-0.5 rounded ${status.bg} ${status.color}`}>
{status.label}
</span>
</h3>
<p className="text-slate-600 text-sm mt-1">{data.summary}</p>
</div>
</div>
{/* 分项详情 */}
{data.parts.map((part, partIndex) => (
<div key={partIndex} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 bg-slate-50 border-b border-gray-200 flex justify-between items-center">
<span className="font-bold text-sm text-slate-700">{part.part}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{part.score}</span>
{part.issues.length === 0 ? (
<span className="tag tag-green flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<span className="tag tag-amber">
{part.issues.length}
</span>
)}
</div>
</div>
{part.issues.length > 0 ? (
<div className="divide-y divide-gray-100">
{part.issues.map((issue, issueIndex) => {
const severity = getSeverityStyle(issue.severity);
return (
<div key={issueIndex} className={`p-5 ${severity.bg}`}>
<div className="flex gap-3">
<div className="mt-0.5">{severity.icon}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-sm text-slate-800">{issue.type}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
issue.severity === 'major' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'
}`}>
{severity.label}
</span>
</div>
<p className="text-sm text-slate-600">{issue.description}</p>
{issue.location && (
<p className="text-xs text-slate-400 mt-1">
{issue.location}
</p>
)}
{issue.suggestion && (
<div className="mt-3 bg-white border border-gray-200 p-3 rounded-lg">
<p className="text-xs text-slate-600">
<strong></strong>{issue.suggestion}
</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="p-6 text-center text-slate-500 text-sm">
<CheckCircle className="w-8 h-8 text-green-400 mx-auto mb-2" />
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,110 @@
/**
* 报告详情页组件
*/
import { useState } from 'react';
import { ArrowLeft, FileCheck, Tag } from 'lucide-react';
import type { ReviewReport } from '../types';
import EditorialReport from './EditorialReport';
import MethodologyReport from './MethodologyReport';
interface ReportDetailProps {
report: ReviewReport;
onBack: () => void;
}
export default function ReportDetail({ report, onBack }: ReportDetailProps) {
const [activeTab, setActiveTab] = useState<'editorial' | 'methodology'>('editorial');
const hasEditorial = !!report.editorialReview;
const hasMethodology = !!report.methodologyReview;
// 如果只有方法学,默认显示方法学
const effectiveTab = activeTab === 'editorial' && !hasEditorial && hasMethodology ? 'methodology' : activeTab;
return (
<div className="flex-1 flex flex-col h-full bg-slate-50 relative fade-in">
{/* 顶部导航栏 */}
<header className="h-16 bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-20 shadow-sm">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 text-slate-500 hover:text-slate-800 transition-colors px-2 py-1 rounded hover:bg-slate-100"
>
<ArrowLeft className="w-5 h-5" />
<span className="text-sm font-medium"></span>
</button>
<div className="h-6 w-px bg-slate-200" />
<div>
<h1 className="text-base font-bold text-slate-800 flex items-center gap-2">
{report.fileName}
{report.overallScore && (
<span className={`tag ${
report.overallScore >= 80 ? 'tag-green' :
report.overallScore >= 60 ? 'tag-amber' : 'tag-red'
}`}>
{report.overallScore}
</span>
)}
</h1>
</div>
</div>
<div className="flex items-center gap-3">
<button className="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm font-medium hover:bg-indigo-700 transition shadow-sm flex items-center gap-2">
<FileCheck className="w-4 h-4" />
</button>
</div>
</header>
{/* 内容区域 */}
<div className="flex-1 overflow-auto p-8 max-w-5xl mx-auto w-full">
{/* Tab切换 */}
{(hasEditorial || hasMethodology) && (
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-8 w-fit mx-auto">
{hasEditorial && (
<button
onClick={() => setActiveTab('editorial')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
effectiveTab === 'editorial'
? 'font-bold bg-white text-indigo-600 shadow-sm'
: 'font-medium text-slate-500 hover:text-slate-700'
}`}
>
稿 ({report.editorialReview?.overall_score})
</button>
)}
{hasMethodology && (
<button
onClick={() => setActiveTab('methodology')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
effectiveTab === 'methodology'
? 'font-bold bg-white text-indigo-600 shadow-sm'
: 'font-medium text-slate-500 hover:text-slate-700'
}`}
>
({report.methodologyReview?.overall_score})
</button>
)}
</div>
)}
{/* 报告内容 */}
{effectiveTab === 'editorial' && report.editorialReview && (
<EditorialReport data={report.editorialReview} />
)}
{effectiveTab === 'methodology' && report.methodologyReview && (
<MethodologyReport data={report.methodologyReview} />
)}
{/* 无数据状态 */}
{!hasEditorial && !hasMethodology && (
<div className="text-center py-12 text-slate-500">
<Tag className="w-12 h-12 mx-auto mb-4 text-slate-300" />
<p></p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
/**
* 评分环组件
*/
interface ScoreRingProps {
score: number;
size?: 'small' | 'medium' | 'large';
showLabel?: boolean;
}
export default function ScoreRing({ score, size = 'medium', showLabel = true }: ScoreRingProps) {
const sizeStyles = {
small: 'w-12 h-12 text-lg border-4',
medium: 'w-20 h-20 text-2xl border-6',
large: 'w-24 h-24 text-3xl border-8',
};
const getScoreStatus = (score: number) => {
if (score >= 80) return { class: 'pass', label: 'Pass', bgColor: 'bg-green-50', borderColor: 'border-green-500', textColor: 'text-green-700' };
if (score >= 60) return { class: 'warn', label: 'Warning', bgColor: 'bg-amber-50', borderColor: 'border-amber-500', textColor: 'text-amber-700' };
return { class: 'fail', label: 'Fail', bgColor: 'bg-red-50', borderColor: 'border-red-500', textColor: 'text-red-700' };
};
const status = getScoreStatus(score);
return (
<div
className={`rounded-full flex flex-col items-center justify-center ${sizeStyles[size]} ${status.bgColor} ${status.borderColor} ${status.textColor}`}
style={{ borderWidth: size === 'small' ? 4 : size === 'medium' ? 6 : 8 }}
>
<span className="font-bold">{score}</span>
{showLabel && size !== 'small' && (
<span className="text-[10px] font-bold uppercase">{status.label}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
/**
* RVW侧边栏组件
*/
import { LayoutGrid, Archive, Settings, BrainCircuit } from 'lucide-react';
interface SidebarProps {
currentView: 'dashboard' | 'archive';
onViewChange: (view: 'dashboard' | 'archive') => void;
onSettingsClick?: () => void;
}
export default function Sidebar({ currentView, onViewChange, onSettingsClick }: SidebarProps) {
return (
<aside className="w-[72px] bg-slate-900 flex flex-col items-center py-6 gap-4 z-20 shadow-xl flex-shrink-0 relative">
{/* Logo */}
<div
className="w-10 h-10 bg-indigo-500 rounded-xl flex items-center justify-center text-white shadow-lg mb-4"
title="智能审稿系统"
>
<BrainCircuit className="w-6 h-6" />
</div>
{/* 审稿工作台 */}
<button
onClick={() => onViewChange('dashboard')}
className={`sidebar-btn w-10 h-10 rounded-lg flex items-center justify-center transition-colors relative group
${currentView === 'dashboard'
? 'bg-white/10 text-white'
: 'text-slate-400 hover:bg-white/10 hover:text-white'
}`}
title="审稿工作台"
>
<LayoutGrid className="w-5 h-5" />
<span className="sidebar-tooltip">稿</span>
</button>
{/* 历史归档 */}
<button
onClick={() => onViewChange('archive')}
className={`sidebar-btn w-10 h-10 rounded-lg flex items-center justify-center transition-colors relative group
${currentView === 'archive'
? 'bg-white/10 text-white'
: 'text-slate-400 hover:bg-white/10 hover:text-white'
}`}
title="历史归档"
>
<Archive className="w-5 h-5" />
<span className="sidebar-tooltip"></span>
</button>
{/* 底部区域 */}
<div className="mt-auto flex flex-col gap-4 relative">
{/* 系统设置 */}
<button
onClick={onSettingsClick}
className="sidebar-btn w-10 h-10 rounded-lg text-slate-400 flex items-center justify-center hover:bg-white/10 hover:text-white transition-colors relative group"
title="系统设置"
>
<Settings className="w-5 h-5" />
<span className="sidebar-tooltip"></span>
</button>
{/* 用户头像 */}
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gradient-to-tr from-indigo-500 to-purple-500 flex items-center justify-center text-xs font-bold text-white border-2 border-slate-700 cursor-pointer hover:border-white transition-all shadow-md">
</div>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,252 @@
/**
* 任务表格组件
*/
import { FileText, FileType2, Loader2, Play, Eye } from 'lucide-react';
import type { ReviewTask } from '../types';
import { formatFileSize, formatTime } from '../api';
interface TaskTableProps {
tasks: ReviewTask[];
selectedIds: string[];
onSelectChange: (ids: string[]) => void;
onViewReport: (task: ReviewTask) => void;
onRunTask: (task: ReviewTask) => void;
}
export default function TaskTable({
tasks,
selectedIds,
onSelectChange,
onViewReport,
onRunTask
}: TaskTableProps) {
const allSelected = tasks.length > 0 && selectedIds.length === tasks.length;
const toggleSelectAll = () => {
if (allSelected) {
onSelectChange([]);
} else {
onSelectChange(tasks.map(t => t.id));
}
};
const toggleSelect = (id: string) => {
if (selectedIds.includes(id)) {
onSelectChange(selectedIds.filter(i => i !== id));
} else {
onSelectChange([...selectedIds, id]);
}
};
// 获取文件图标
const getFileIcon = (fileName: string) => {
if (fileName.endsWith('.pdf')) {
return <FileText className="w-5 h-5" />;
}
return <FileType2 className="w-5 h-5" />;
};
// 获取文件图标容器样式
const getFileIconStyle = (fileName: string) => {
if (fileName.endsWith('.pdf')) {
return 'bg-red-50 text-red-600 border-red-100';
}
return 'bg-blue-50 text-blue-600 border-blue-100';
};
// 渲染智能体标签
const renderAgentTags = (task: ReviewTask) => {
if (!task.selectedAgents || task.selectedAgents.length === 0) {
return <span className="tag tag-gray"></span>;
}
return (
<div className="flex gap-1.5">
{task.selectedAgents.includes('editorial') && (
<span className="tag tag-blue"></span>
)}
{task.selectedAgents.includes('methodology') && (
<span className="tag tag-purple"></span>
)}
</div>
);
};
// 渲染结果摘要
const renderResultSummary = (task: ReviewTask) => {
if (task.status === 'pending') {
return <span className="text-xs text-slate-400 italic">...</span>;
}
if (task.status === 'extracting' || task.status === 'reviewing') {
return (
<div className="flex items-center gap-2 text-xs text-indigo-600">
<Loader2 className="w-3 h-3 animate-spin" />
<span>{task.status === 'extracting' ? '提取文本中...' : '审查中...'}</span>
</div>
);
}
if (task.status === 'failed') {
return <span className="text-xs text-red-500"></span>;
}
if (task.status === 'completed') {
return (
<div className="flex flex-col gap-1.5">
{task.editorialScore !== undefined && (
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full ${task.editorialScore >= 80 ? 'bg-green-500' : task.editorialScore >= 60 ? 'bg-amber-500' : 'bg-red-500'}`} />
<span className="text-slate-600">:</span>
<span className={`font-bold ${task.editorialScore >= 80 ? 'text-green-700' : task.editorialScore >= 60 ? 'text-amber-700' : 'text-red-700'}`}>
{task.editorialScore}
</span>
</div>
)}
{task.methodologyStatus && (
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full ${
task.methodologyStatus === '通过' ? 'bg-green-500' :
task.methodologyStatus === '存疑' ? 'bg-amber-500' : 'bg-red-500'
}`} />
<span className="text-slate-600">:</span>
<span className={`font-bold ${
task.methodologyStatus === '通过' ? 'text-green-700' :
task.methodologyStatus === '存疑' ? 'text-amber-700' : 'text-red-700'
}`}>
{task.methodologyStatus}
</span>
</div>
)}
</div>
);
}
return null;
};
// 渲染操作按钮
const renderActions = (task: ReviewTask) => {
if (task.status === 'completed') {
return (
<button
onClick={() => onViewReport(task)}
className="text-indigo-600 font-bold hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs flex items-center gap-1"
>
<Eye className="w-3 h-3" />
</button>
);
}
if (task.status === 'pending') {
return (
<button
onClick={() => onRunTask(task)}
className="border border-indigo-200 text-indigo-600 hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs font-medium flex items-center gap-1"
>
<Play className="w-3 h-3" />
</button>
);
}
if (task.status === 'extracting' || task.status === 'reviewing') {
return (
<span className="text-xs text-slate-400 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
</span>
);
}
return null;
};
if (tasks.length === 0) {
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-12 text-center">
<FileText className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500">稿稿</p>
</div>
);
}
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 border-b border-gray-200 text-gray-500 font-semibold uppercase tracking-wider text-xs">
<tr>
<th className="px-6 py-4 w-12">
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
/>
</th>
<th className="px-6 py-4 w-1/3"> / </th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4">稿</th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tasks.map(task => (
<tr
key={task.id}
className={`hover:bg-slate-50 group transition-colors ${selectedIds.includes(task.id) ? 'bg-blue-50' : ''}`}
>
<td className="px-6 py-4">
<input
type="checkbox"
checked={selectedIds.includes(task.id)}
onChange={() => toggleSelect(task.id)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
/>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg border ${getFileIconStyle(task.fileName)}`}>
{getFileIcon(task.fileName)}
</div>
<div>
<div
className={`font-bold text-slate-800 text-base mb-0.5 ${task.status === 'completed' ? 'cursor-pointer hover:text-indigo-600' : ''}`}
onClick={() => task.status === 'completed' && onViewReport(task)}
>
{task.fileName}
</div>
<div className="text-xs text-slate-400 flex items-center gap-2">
<span className="bg-slate-100 px-1.5 rounded">{formatFileSize(task.fileSize)}</span>
{task.wordCount && (
<>
<span></span>
<span>{task.wordCount.toLocaleString()} </span>
</>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-slate-500 font-mono text-xs">
{formatTime(task.createdAt)}
</td>
<td className="px-6 py-4">
{renderAgentTags(task)}
</td>
<td className="px-6 py-4">
{renderResultSummary(task)}
</td>
<td className="px-6 py-4 text-right">
{renderActions(task)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,14 @@
/**
* RVW组件导出
*/
export { default as Sidebar } from './Sidebar';
export { default as Header } from './Header';
export { default as FilterChips } from './FilterChips';
export { default as TaskTable } from './TaskTable';
export { default as BatchToolbar } from './BatchToolbar';
export { default as AgentModal } from './AgentModal';
export { default as ScoreRing } from './ScoreRing';
export { default as EditorialReport } from './EditorialReport';
export { default as MethodologyReport } from './MethodologyReport';
export { default as ReportDetail } from './ReportDetail';

View File

@@ -0,0 +1,7 @@
/**
* RVW模块入口
*/
export { default as RvwDashboard } from './Dashboard';
export * from './types';
export * from './api';

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

View File

@@ -0,0 +1,92 @@
/**
* RVW模块类型定义
*/
// 任务状态
export type TaskStatus =
| 'pending' // 待处理
| 'extracting' // 提取文本中
| 'reviewing' // 审查中
| '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;
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';
}