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:
2026-01-10 22:52:15 +08:00
parent 179afa2c6b
commit 440f75255e
237 changed files with 3942 additions and 657 deletions

View File

@@ -85,6 +85,8 @@ vite.config.*.timestamp-*

View File

@@ -52,6 +52,8 @@ exec nginx -g 'daemon off;'

View File

@@ -208,6 +208,8 @@ http {

View File

@@ -21,6 +21,8 @@
"dayjs": "^1.11.19",
"dexie": "^4.2.1",
"diff-match-patch": "^1.0.5",
"docx": "^9.5.1",
"file-saver": "^2.0.5",
"immer": "^11.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.555.0",
@@ -34,6 +36,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/file-saver": "^2.0.7",
"@types/lodash": "^4.17.21",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
@@ -3146,6 +3149,13 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -4415,6 +4425,12 @@
"node": ">=18"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
@@ -4882,6 +4898,41 @@
"dev": true,
"license": "MIT"
},
"node_modules/docx": {
"version": "9.5.1",
"resolved": "https://registry.npmmirror.com/docx/-/docx-9.5.1.tgz",
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.0.1",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5362,6 +5413,12 @@
"node": ">=16.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
@@ -5733,6 +5790,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
@@ -5812,6 +5879,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "11.0.0",
"resolved": "https://registry.npmmirror.com/immer/-/immer-11.0.0.tgz",
@@ -5849,6 +5922,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
@@ -5992,6 +6071,12 @@
"node": ">=0.12.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
@@ -6143,6 +6228,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
@@ -6167,6 +6264,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -6380,6 +6486,12 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
@@ -6595,6 +6707,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
@@ -6910,6 +7028,12 @@
"node": ">=6"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
@@ -7184,6 +7308,27 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
@@ -7370,6 +7515,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.4",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz",
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
@@ -7474,6 +7628,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/shallowequal/-/shallowequal-1.1.0.tgz",
@@ -7577,6 +7737,21 @@
"node": ">=0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-convert": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
@@ -8183,7 +8358,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
@@ -8562,6 +8736,24 @@
"node": ">=0.8"
}
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",

View File

@@ -23,6 +23,8 @@
"dayjs": "^1.11.19",
"dexie": "^4.2.1",
"diff-match-patch": "^1.0.5",
"docx": "^9.5.1",
"file-saver": "^2.0.5",
"immer": "^11.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.555.0",
@@ -36,6 +38,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/file-saver": "^2.0.7",
"@types/lodash": "^4.17.21",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",

View File

@@ -555,6 +555,8 @@ export default FulltextDetailDrawer;

View File

@@ -148,6 +148,8 @@ export const useAssets = (activeTab: AssetTabType) => {

View File

@@ -138,6 +138,8 @@ export const useRecentTasks = () => {

View File

@@ -337,6 +337,8 @@ export default DropnaDialog;

View File

@@ -422,6 +422,8 @@ export default MetricTimePanel;

View File

@@ -308,6 +308,8 @@ export default PivotPanel;

View File

@@ -108,6 +108,8 @@ export function useSessionStatus({

View File

@@ -100,6 +100,8 @@ export interface DataStats {

View File

@@ -96,6 +96,8 @@ export type AssetTabType = 'all' | 'processed' | 'raw';

View File

@@ -218,3 +218,5 @@ export const documentSelectionApi = {

View File

@@ -286,3 +286,5 @@ export default KnowledgePage;

View File

@@ -224,3 +224,5 @@ export const useKnowledgeBaseStore = create<KnowledgeBaseState>((set, get) => ({

View File

@@ -41,3 +41,5 @@ export interface BatchTemplate {

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

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让调用方控制关闭时机
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>
);
}

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

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

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

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

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

View File

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

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

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,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';
}

View File

@@ -51,6 +51,8 @@ export { default as Placeholder } from './Placeholder';

View File

@@ -31,6 +31,8 @@ interface ImportMeta {

File diff suppressed because one or more lines are too long