feat(rvw,asl): RVW V3.0 smart review + ASL deep research history + stability
RVW module (V3.0 Smart Review Enhancement): - Add LLM data validation via PromptService (RVW_DATA_VALIDATION) - Add ClinicalAssessmentSkill with FINER-based evaluation (RVW_CLINICAL) - Remove all numeric scores from UI (editorial, methodology, overall) - Implement partial_completed status with Promise.allSettled - Add error_details JSON field to ReviewTask for granular failure info - Fix overallStatus logic: warning status now counts as success - Restructure ForensicsReport: per-table LLM results, remove top-level block - Refactor ClinicalReport: structured collapsible sections - Increase all skill timeouts to 300s for long manuscripts (20+ pages) - Increase DataForensics LLM timeout to 180s, pg-boss to 15min - Executor default fallback timeout 30s -> 60s ASL module: - Add deep research history with sidebar accordion UI - Implement waterfall flow for historical task display - Upgrade Unifuncs DeepSearch API from S2 to S3 with fallback - Add ASL_SR module seed for admin configurability - Fix new search button inconsistency Docs: - Update RVW module status to V3.0 - Update deployment changelist - Add 0305 deployment summary DB Migration: - Add error_details JSONB column to rvw_schema.review_tasks Tested: All 4 review modules verified, partial completion working Made-with: Cursor
This commit is contained in:
@@ -485,6 +485,7 @@ import type {
|
||||
GenerateRequirementRequest,
|
||||
GenerateRequirementResponse,
|
||||
DeepResearchTask,
|
||||
DeepResearchTaskSummary,
|
||||
} from '../types/deepResearch';
|
||||
|
||||
/**
|
||||
@@ -519,6 +520,20 @@ export async function executeDeepResearchTask(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 V2.0 任务历史列表(最近 100 条,不含 draft)
|
||||
*/
|
||||
export async function listDeepResearchTasks(): Promise<ApiResponse<DeepResearchTaskSummary[]>> {
|
||||
return request('/research/v2/tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 V2.0 检索任务
|
||||
*/
|
||||
export async function deleteDeepResearchTask(taskId: string): Promise<ApiResponse<void>> {
|
||||
return request(`/research/tasks/${taskId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 V2.0 任务详情(状态 + 日志 + 结果)
|
||||
*/
|
||||
@@ -714,6 +729,8 @@ export const aslApi = {
|
||||
getDeepResearchDataSources,
|
||||
generateRequirement,
|
||||
executeDeepResearchTask,
|
||||
listDeepResearchTasks,
|
||||
deleteDeepResearchTask,
|
||||
getDeepResearchTask,
|
||||
|
||||
// 工具 3:全文智能提取
|
||||
|
||||
@@ -1,160 +1,288 @@
|
||||
/**
|
||||
* ASL模块布局组件
|
||||
*
|
||||
* 左侧导航栏 + 右侧内容区
|
||||
* 参考原型:AI智能文献-标题摘要初筛原型.html
|
||||
* ASL 模块布局 — 互斥手风琴侧边栏
|
||||
*
|
||||
* 参考 v6.html 设计 + Protocol Agent (AIA) 模块 UI 风格
|
||||
* - 面板 A:智能文献检索(历史列表 + 新建检索)
|
||||
* - 面板 B:系统综述项目(需要 ASL_SR 模块权限)
|
||||
* - 两个面板 Header 始终可见,内容区互斥展开
|
||||
*/
|
||||
|
||||
import { Layout, Menu } from 'antd';
|
||||
import { useState, useCallback, useMemo, createContext, useContext } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Popconfirm, message } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
DatabaseOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
CheckSquareOutlined,
|
||||
UnorderedListOutlined,
|
||||
ApartmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
History,
|
||||
MessageSquare,
|
||||
FolderKanban,
|
||||
Trash2,
|
||||
ListFilter,
|
||||
FileSearch,
|
||||
FileText,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../../framework/auth/AuthContext';
|
||||
import { aslApi } from '../api';
|
||||
import type { DeepResearchTaskSummary } from '../types/deepResearch';
|
||||
import '../styles/asl-sidebar.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
// ─── Context ────────────────────────────────────
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
interface ASLLayoutContextValue {
|
||||
refreshHistory: () => void;
|
||||
}
|
||||
|
||||
const ASLLayoutContext = createContext<ASLLayoutContextValue>({
|
||||
refreshHistory: () => {},
|
||||
});
|
||||
|
||||
export function useASLLayout() {
|
||||
return useContext(ASLLayoutContext);
|
||||
}
|
||||
|
||||
// ─── 日期分组 ───────────────────────────────────
|
||||
|
||||
function groupByDate(tasks: DeepResearchTaskSummary[]) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
const weekAgo = new Date(today.getTime() - 7 * 86400000);
|
||||
|
||||
const groups: { label: string; items: DeepResearchTaskSummary[] }[] = [
|
||||
{ label: '今天', items: [] },
|
||||
{ label: '昨天', items: [] },
|
||||
{ label: '最近 7 天', items: [] },
|
||||
{ label: '更早', items: [] },
|
||||
];
|
||||
|
||||
for (const task of tasks) {
|
||||
const d = new Date(task.createdAt);
|
||||
if (d >= today) groups[0].items.push(task);
|
||||
else if (d >= yesterday) groups[1].items.push(task);
|
||||
else if (d >= weekAgo) groups[2].items.push(task);
|
||||
else groups[3].items.push(task);
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
// ─── 主组件 ─────────────────────────────────────
|
||||
|
||||
type PanelType = 'SEARCH' | 'PROJECT';
|
||||
|
||||
const SR_NAV_ITEMS = [
|
||||
{ label: '标题摘要初筛', path: '/literature/screening/title/settings', icon: ListFilter, matchPrefix: '/literature/screening/title' },
|
||||
{ label: '全文复筛', path: '/literature/screening/fulltext/settings', icon: FileSearch, matchPrefix: '/literature/screening/fulltext' },
|
||||
{ label: '全文智能提取', path: '/literature/extraction/setup', icon: FileText, matchPrefix: '/literature/extraction' },
|
||||
{ label: 'SR 图表生成器', path: '/literature/charting', icon: BarChart3, matchPrefix: '/literature/charting' },
|
||||
{ label: 'Meta 分析引擎', path: '/literature/meta-analysis', icon: TrendingUp, matchPrefix: '/literature/meta-analysis' },
|
||||
];
|
||||
|
||||
const ASLLayout = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasModule } = useAuth();
|
||||
const hasSR = hasModule('ASL_SR');
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
key: '/literature/research/deep',
|
||||
icon: <SearchOutlined />,
|
||||
label: '1. 智能文献检索',
|
||||
},
|
||||
{
|
||||
key: 'title-screening',
|
||||
icon: <FilterOutlined />,
|
||||
label: '2. 标题摘要初筛',
|
||||
children: [
|
||||
{
|
||||
key: '/literature/screening/title/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '设置与启动',
|
||||
},
|
||||
{
|
||||
key: '/literature/screening/title/workbench',
|
||||
icon: <CheckSquareOutlined />,
|
||||
label: '审核工作台',
|
||||
},
|
||||
{
|
||||
key: '/literature/screening/title/results',
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: '初筛结果',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'extraction',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '3. 全文智能提取',
|
||||
children: [
|
||||
{
|
||||
key: '/literature/extraction/setup',
|
||||
icon: <SettingOutlined />,
|
||||
label: '配置与启动',
|
||||
},
|
||||
{
|
||||
key: '/literature/extraction/workbench',
|
||||
icon: <CheckSquareOutlined />,
|
||||
label: '审核工作台',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/literature/charting',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: '4. SR 图表生成器',
|
||||
},
|
||||
{
|
||||
key: '/literature/meta-analysis',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '5. Meta 分析引擎',
|
||||
},
|
||||
];
|
||||
const [expandedPanel, setExpandedPanel] = useState<PanelType>('SEARCH');
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key.startsWith('/')) {
|
||||
navigate(key);
|
||||
}
|
||||
};
|
||||
const currentTaskId = useMemo(() => {
|
||||
const match = location.pathname.match(/\/research\/deep\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
}, [location.pathname]);
|
||||
|
||||
// 获取当前选中的菜单项和展开的子菜单
|
||||
const currentPath = location.pathname;
|
||||
const selectedKeys = [currentPath];
|
||||
|
||||
// 根据当前路径确定展开的菜单
|
||||
const getOpenKeys = () => {
|
||||
if (currentPath.includes('screening/title')) return ['title-screening'];
|
||||
if (currentPath.includes('/extraction')) return ['extraction'];
|
||||
if (currentPath.includes('/charting')) return [];
|
||||
if (currentPath.includes('/meta-analysis')) return [];
|
||||
return [];
|
||||
};
|
||||
const openKeys = getOpenKeys();
|
||||
const { data: historyResp } = useQuery({
|
||||
queryKey: ['deep-research-history'],
|
||||
queryFn: () => aslApi.listDeepResearchTasks(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const historyTasks = historyResp?.data ?? [];
|
||||
const groupedHistory = useMemo(() => groupByDate(historyTasks), [historyTasks]);
|
||||
|
||||
// 智能文献检索页面使用全屏布局(无左侧导航栏装饰)
|
||||
const isResearchPage = currentPath.includes('/research/');
|
||||
const refreshHistory = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['deep-research-history'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const togglePanel = useCallback((panel: PanelType) => {
|
||||
setExpandedPanel((prev) =>
|
||||
prev === panel
|
||||
? panel === 'SEARCH' ? 'PROJECT' : 'SEARCH'
|
||||
: panel
|
||||
);
|
||||
}, []);
|
||||
|
||||
const selectTask = useCallback(
|
||||
(taskId: string) => navigate(`/literature/research/deep/${taskId}`),
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleNewSearch = useCallback(() => {
|
||||
navigate('/literature/research/deep');
|
||||
}, [navigate]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (taskId: string) => {
|
||||
setDeletingId(taskId);
|
||||
try {
|
||||
await aslApi.deleteDeepResearchTask(taskId);
|
||||
message.success('已删除');
|
||||
refreshHistory();
|
||||
if (currentTaskId === taskId) {
|
||||
navigate('/literature/research/deep');
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '删除失败');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[currentTaskId, navigate, refreshHistory]
|
||||
);
|
||||
|
||||
const isSearchExpanded = expandedPanel === 'SEARCH';
|
||||
const contextValue = useMemo(() => ({ refreshHistory }), [refreshHistory]);
|
||||
|
||||
return (
|
||||
<Layout className="h-screen">
|
||||
{/* 左侧导航栏 */}
|
||||
<Sider
|
||||
width={250}
|
||||
className="bg-gray-50 border-r border-gray-200"
|
||||
theme="light"
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-bold text-gray-800">
|
||||
<FilterOutlined className="mr-2" />
|
||||
AI智能文献
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
标题摘要初筛 MVP
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="border-none"
|
||||
style={{ height: 'calc(100vh - 80px)', overflowY: 'auto' }}
|
||||
/>
|
||||
</Sider>
|
||||
<ASLLayoutContext.Provider value={contextValue}>
|
||||
<div style={{ height: '100vh', display: 'flex', overflow: 'hidden' }}>
|
||||
{/* ── 侧边栏 ── */}
|
||||
<div className="asl-sidebar">
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<Layout>
|
||||
<Content className={isResearchPage ? "overflow-auto" : "bg-white overflow-auto"}>
|
||||
{/* ── 面板 A Header ── */}
|
||||
<div
|
||||
className="asl-panel-header"
|
||||
onClick={() => togglePanel('SEARCH')}
|
||||
>
|
||||
<div className={`asl-panel-header-left ${isSearchExpanded ? 'active' : ''}`}>
|
||||
<Sparkles size={16} className="header-icon" />
|
||||
<span>智能文献检索</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`asl-panel-chevron ${isSearchExpanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 面板 A Content ── */}
|
||||
<div className={`asl-panel-content ${isSearchExpanded ? '' : 'collapsed'}`}>
|
||||
<button className="asl-new-btn" onClick={handleNewSearch}>
|
||||
<Plus size={16} />
|
||||
新建智能检索
|
||||
</button>
|
||||
|
||||
<div className="asl-list-section-label">
|
||||
<span>最近检索历史</span>
|
||||
<History size={14} />
|
||||
</div>
|
||||
|
||||
<div className="asl-history-list">
|
||||
{historyTasks.length === 0 ? (
|
||||
<div className="asl-empty-state">暂无检索记录</div>
|
||||
) : (
|
||||
groupedHistory.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="asl-date-group-label">{group.label}</div>
|
||||
{group.items.map((task) => {
|
||||
const isActive = currentTaskId === task.id;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`asl-conv-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => selectTask(task.id)}
|
||||
>
|
||||
<MessageSquare size={16} className="conv-icon" />
|
||||
<span
|
||||
className={`asl-conv-status ${task.status}`}
|
||||
/>
|
||||
<span className="asl-conv-title">{task.query}</span>
|
||||
<Popconfirm
|
||||
title="确认删除此检索记录?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete(task.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<button
|
||||
className="asl-conv-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={deletingId === task.id}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 分割线 ── */}
|
||||
<div className="asl-divider" />
|
||||
|
||||
{/* ── 面板 B Header ── */}
|
||||
<div
|
||||
className="asl-panel-header"
|
||||
onClick={() => togglePanel('PROJECT')}
|
||||
>
|
||||
<div className={`asl-panel-header-left ${!isSearchExpanded ? 'active' : ''}`}>
|
||||
<FolderKanban size={16} className="header-icon" />
|
||||
<span>系统综述项目 (SR)</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`asl-panel-chevron ${!isSearchExpanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 面板 B Content ── */}
|
||||
<div className={`asl-panel-content ${!isSearchExpanded ? '' : 'collapsed'}`}>
|
||||
{hasSR ? (
|
||||
<div className="asl-nav-list">
|
||||
{SR_NAV_ITEMS.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={`asl-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<Icon size={16} className="nav-icon" />
|
||||
<span className="asl-nav-item-label">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="asl-sr-locked">
|
||||
<Lock size={32} className="asl-sr-locked-icon" />
|
||||
<p className="asl-sr-locked-text">请联系管理员开通系统综述功能</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', background: '#F9FAFB' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</div>
|
||||
</div>
|
||||
</ASLLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ASLLayout;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ const { Text } = Typography;
|
||||
interface ResultsViewProps {
|
||||
task: DeepResearchTask;
|
||||
onNewSearch: () => void;
|
||||
hideBanner?: boolean;
|
||||
}
|
||||
|
||||
const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch }) => {
|
||||
const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch, hideBanner }) => {
|
||||
const { synthesisReport, resultList, resultCount, query, completedAt, taskId } = task;
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
@@ -129,28 +130,30 @@ const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch }) => {
|
||||
return (
|
||||
<div>
|
||||
{/* 完成横幅 */}
|
||||
<Card className="mb-6 !bg-green-50 !border-green-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleFilled className="text-2xl text-green-500" />
|
||||
<div>
|
||||
<Text strong className="text-lg block">Deep Research 完成</Text>
|
||||
<Text type="secondary" className="text-sm">
|
||||
「{query}」 — 找到 {resultCount || 0} 篇文献
|
||||
{completedAt && ` · ${new Date(completedAt).toLocaleString()}`}
|
||||
</Text>
|
||||
{!hideBanner && (
|
||||
<Card className="mb-6 !bg-green-50 !border-green-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleFilled className="text-2xl text-green-500" />
|
||||
<div>
|
||||
<Text strong className="text-lg block">Deep Research 完成</Text>
|
||||
<Text type="secondary" className="text-sm">
|
||||
「{query}」 — 找到 {resultCount || 0} 篇文献
|
||||
{completedAt && ` · ${new Date(completedAt).toLocaleString()}`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
|
||||
导出 Word
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>
|
||||
新建检索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
|
||||
导出 Word
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>
|
||||
新建检索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI 综合报告 — Markdown 渲染 */}
|
||||
{synthesisReport && (
|
||||
|
||||
@@ -24,6 +24,7 @@ interface StrategyConfirmProps {
|
||||
intentSummary: IntentSummary | null;
|
||||
onConfirm: (confirmedRequirement: string) => void;
|
||||
collapsed?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
|
||||
@@ -31,11 +32,13 @@ const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
|
||||
intentSummary,
|
||||
onConfirm,
|
||||
collapsed,
|
||||
readOnly,
|
||||
}) => {
|
||||
const [editedRequirement, setEditedRequirement] = useState(generatedRequirement);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (collapsed) {
|
||||
if (collapsed && !readOnly) {
|
||||
return (
|
||||
<Card size="small" className="!bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -46,6 +49,64 @@ const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="!bg-white"
|
||||
title={
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer select-none"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-green-500" />
|
||||
<span>检索需求书</span>
|
||||
</div>
|
||||
<Text type="secondary" className="text-xs font-normal">
|
||||
{expanded ? '收起' : '展开详情'}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{intentSummary && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AimOutlined className="text-blue-500" />
|
||||
<Text className="text-sm">{intentSummary.objective}</Text>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs">
|
||||
<span><Text type="secondary">P 人群</Text> <Text className="ml-1">{intentSummary.population || '—'}</Text></span>
|
||||
<span><Text type="secondary">I 干预</Text> <Text className="ml-1">{intentSummary.intervention || '—'}</Text></span>
|
||||
<span><Text type="secondary">C 对照</Text> <Text className="ml-1">{intentSummary.comparison || '—'}</Text></span>
|
||||
<span><Text type="secondary">O 结局</Text> <Text className="ml-1">{intentSummary.outcome || '—'}</Text></span>
|
||||
</div>
|
||||
{intentSummary.meshTerms && intentSummary.meshTerms.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap mt-2">
|
||||
<TagsOutlined className="text-green-500 text-xs" />
|
||||
{intentSummary.meshTerms.map((term, i) => (
|
||||
<Tag key={i} color="green" className="!text-xs !m-0">{term}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{intentSummary.studyDesign && intentSummary.studyDesign.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap mt-1">
|
||||
{intentSummary.studyDesign.map((s, i) => (
|
||||
<Tag key={i} color="blue" className="!text-xs !m-0">{s}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && generatedRequirement && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg text-sm leading-relaxed whitespace-pre-wrap text-gray-700 max-h-[400px] overflow-y-auto">
|
||||
{generatedRequirement}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true);
|
||||
message.success('检索指令已保存');
|
||||
|
||||
@@ -71,6 +71,7 @@ const ASLModule = () => {
|
||||
|
||||
{/* Deep Research V2.0 */}
|
||||
<Route path="research/deep" element={<DeepResearchPage />} />
|
||||
<Route path="research/deep/:taskId" element={<DeepResearchPage />} />
|
||||
|
||||
{/* 标题摘要初筛 */}
|
||||
<Route path="screening/title">
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
* Deep Research V2.0 主页面 — 瀑布流布局
|
||||
*
|
||||
* Phase 0: Landing(全屏居中搜索)
|
||||
* Phase 1+: 配置 → 策略 → 执行 → 结果,依次累积展示
|
||||
* Phase 1+: 配置 -> 策略 -> 执行 -> 结果,依次累积展示
|
||||
*
|
||||
* 支持从 URL 参数 :taskId 恢复历史任务。
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Card, Button, Typography, message } from 'antd';
|
||||
import { CheckCircleFilled, DownloadOutlined, PlusOutlined, ClockCircleOutlined, CloseCircleFilled } from '@ant-design/icons';
|
||||
import { getAccessToken } from '../../../framework/auth/api';
|
||||
import { aslApi } from '../api';
|
||||
import { useDeepResearchTask } from '../hooks/useDeepResearchTask';
|
||||
import { useASLLayout } from '../components/ASLLayout';
|
||||
import LandingView from '../components/deep-research/LandingView';
|
||||
import SetupPanel from '../components/deep-research/SetupPanel';
|
||||
import StrategyConfirm from '../components/deep-research/StrategyConfirm';
|
||||
@@ -19,28 +25,74 @@ import type { IntentSummary, GenerateRequirementResponse } from '../types/deepRe
|
||||
type Phase = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
const DeepResearchPage = () => {
|
||||
const { taskId: urlTaskId } = useParams<{ taskId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { refreshHistory } = useASLLayout();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>(0);
|
||||
const [query, setQuery] = useState('');
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [generatedRequirement, setGeneratedRequirement] = useState('');
|
||||
const [intentSummary, setIntentSummary] = useState<IntentSummary | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [restoredFromUrl, setRestoredFromUrl] = useState(false);
|
||||
|
||||
const strategyRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 轮询 hook(phase >= 3 或从 URL 恢复的任务)
|
||||
const shouldPoll = phase >= 3 || (restoredFromUrl && !!taskId);
|
||||
const { task, isRunning, isCompleted, isFailed } = useDeepResearchTask({
|
||||
taskId,
|
||||
enabled: phase >= 3,
|
||||
enabled: shouldPoll,
|
||||
});
|
||||
|
||||
// 从 URL :taskId 恢复历史任务,或 URL 失去 taskId 时重置
|
||||
useEffect(() => {
|
||||
if (isCompleted && phase === 3) {
|
||||
if (urlTaskId && urlTaskId !== taskId) {
|
||||
setTaskId(urlTaskId);
|
||||
setRestoredFromUrl(true);
|
||||
} else if (!urlTaskId && taskId) {
|
||||
setPhase(0);
|
||||
setTaskId(null);
|
||||
setQuery('');
|
||||
setGeneratedRequirement('');
|
||||
setIntentSummary(null);
|
||||
setRestoredFromUrl(false);
|
||||
}
|
||||
}, [urlTaskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 恢复的任务加载完成后设置正确的 phase
|
||||
useEffect(() => {
|
||||
if (!restoredFromUrl || !task) return;
|
||||
|
||||
setQuery(task.query || '');
|
||||
if (task.confirmedRequirement) {
|
||||
setGeneratedRequirement(task.confirmedRequirement);
|
||||
}
|
||||
if (task.aiIntentSummary) {
|
||||
setIntentSummary(task.aiIntentSummary);
|
||||
}
|
||||
|
||||
if (task.status === 'completed') {
|
||||
setPhase(4);
|
||||
} else if (task.status === 'running' || task.status === 'pending') {
|
||||
setPhase(3);
|
||||
} else if (task.status === 'failed') {
|
||||
setPhase(4);
|
||||
} else if (task.status === 'draft') {
|
||||
setPhase(2);
|
||||
}
|
||||
}, [restoredFromUrl, task]);
|
||||
|
||||
// 正常流程中:执行完成自动进入结果页
|
||||
useEffect(() => {
|
||||
if (isCompleted && phase === 3 && !restoredFromUrl) {
|
||||
setPhase(4);
|
||||
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
|
||||
}
|
||||
}, [isCompleted, phase]);
|
||||
}, [isCompleted, phase, restoredFromUrl]);
|
||||
|
||||
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
|
||||
@@ -52,6 +104,7 @@ const DeepResearchPage = () => {
|
||||
setGeneratedRequirement('');
|
||||
setIntentSummary(null);
|
||||
setTaskId(null);
|
||||
setRestoredFromUrl(false);
|
||||
}, []);
|
||||
|
||||
const handleSetupSubmit = useCallback(async (
|
||||
@@ -86,10 +139,13 @@ const DeepResearchPage = () => {
|
||||
setPhase(3);
|
||||
scrollTo(terminalRef);
|
||||
message.success('Deep Research 已启动');
|
||||
// 任务进入 pending 后刷新侧边栏历史列表
|
||||
navigate(`/literature/research/deep/${taskId}`, { replace: true });
|
||||
refreshHistory();
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '启动失败');
|
||||
}
|
||||
}, [taskId]);
|
||||
}, [taskId, navigate, refreshHistory]);
|
||||
|
||||
const handleNewSearch = useCallback(() => {
|
||||
setPhase(0);
|
||||
@@ -97,9 +153,69 @@ const DeepResearchPage = () => {
|
||||
setTaskId(null);
|
||||
setGeneratedRequirement('');
|
||||
setIntentSummary(null);
|
||||
setRestoredFromUrl(false);
|
||||
navigate('/literature/research/deep', { replace: true });
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, []);
|
||||
}, [navigate]);
|
||||
|
||||
// ── 从 URL 恢复的任务:直接展示结果或执行中状态 ──
|
||||
if (restoredFromUrl && task) {
|
||||
const isFinished = task.status === 'completed' || task.status === 'failed';
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-gray-50 pb-16">
|
||||
<div className="max-w-5xl mx-auto px-6 pt-6">
|
||||
{/* 顶部横幅:完成/失败状态 + 导出按钮 */}
|
||||
{isFinished && (
|
||||
<HistoryBanner task={task} onNewSearch={handleNewSearch} />
|
||||
)}
|
||||
{!isFinished && (
|
||||
<Card size="small" className="mb-4 !bg-blue-50 !border-blue-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClockCircleOutlined className="text-lg text-blue-500" />
|
||||
<Typography.Text strong>「{task.query}」 — 深度检索执行中...</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 检索需求书(PICOS + 完整指令) */}
|
||||
{task.confirmedRequirement && task.aiIntentSummary && (
|
||||
<div className="mt-4">
|
||||
<StrategyConfirm
|
||||
generatedRequirement={task.confirmedRequirement}
|
||||
intentSummary={task.aiIntentSummary}
|
||||
onConfirm={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 执行日志 */}
|
||||
<div className="mt-4">
|
||||
<AgentTerminal task={task} isRunning={!isFinished && isRunning} isFailed={isFailed} />
|
||||
</div>
|
||||
|
||||
{/* 结果区(报告 + 文献表格),横幅已在顶部展示 */}
|
||||
{isFinished && (
|
||||
<div className="mt-4">
|
||||
<ResultsView task={task} onNewSearch={handleNewSearch} hideBanner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 恢复中但 task 还未加载 — loading
|
||||
if (restoredFromUrl && !task) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gray-50">
|
||||
<div className="text-slate-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 正常新建流程 ──
|
||||
if (phase === 0) {
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-gray-50">
|
||||
@@ -158,4 +274,79 @@ const DeepResearchPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// ── 历史记录顶部横幅(完成/失败 + 导出) ──
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function HistoryBanner({ task, onNewSearch }: { task: any; onNewSearch: () => void }) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const isCompleted = task.status === 'completed';
|
||||
|
||||
const handleExportWord = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const res = await fetch(`/api/v1/asl/research/tasks/${task.taskId}/export-word`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?([^;]+)/);
|
||||
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'DeepResearch.docx';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isCompleted) {
|
||||
return (
|
||||
<Card size="small" className="mb-4 !bg-red-50 !border-red-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CloseCircleFilled className="text-lg text-red-500" />
|
||||
<div>
|
||||
<Text strong>检索失败</Text>
|
||||
<Text type="secondary" className="text-sm ml-2">「{task.query}」</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>新建检索</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" className="mb-4 !bg-green-50 !border-green-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleFilled className="text-xl text-green-500" />
|
||||
<div>
|
||||
<Text strong>Deep Research 完成</Text>
|
||||
<Text type="secondary" className="text-sm ml-2">
|
||||
「{task.query}」 — {task.resultCount || 0} 篇文献
|
||||
{task.completedAt && ` · ${new Date(task.completedAt).toLocaleString()}`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
|
||||
导出 Word
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>新建检索</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeepResearchPage;
|
||||
|
||||
373
frontend-v2/src/modules/asl/styles/asl-sidebar.css
Normal file
373
frontend-v2/src/modules/asl/styles/asl-sidebar.css
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* ASL 互斥手风琴侧边栏样式
|
||||
* 对齐 Protocol Agent (AIA) 模块风格
|
||||
*/
|
||||
|
||||
/* ── 侧边栏容器 ── */
|
||||
.asl-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F9FAFB;
|
||||
border-right: 1px solid #E5E7EB;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 面板 Header ── */
|
||||
.asl-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
}
|
||||
|
||||
.asl-panel-header:hover {
|
||||
background: #F3F4F6;
|
||||
}
|
||||
|
||||
.asl-panel-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.asl-panel-header-left.active {
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.asl-panel-header-left .header-icon {
|
||||
color: #9CA3AF;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.asl-panel-header-left.active .header-icon {
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.asl-panel-chevron {
|
||||
color: #9CA3AF;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.asl-panel-chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ── 面板内容区(可展开/折叠) ── */
|
||||
.asl-panel-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asl-panel-content.collapsed {
|
||||
flex: 0;
|
||||
min-height: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 新建按钮 ── */
|
||||
.asl-new-btn {
|
||||
margin: 8px 12px;
|
||||
padding: 8px 12px;
|
||||
background: #6366F1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asl-new-btn:hover {
|
||||
background: #4F46E5;
|
||||
}
|
||||
|
||||
/* ── 列表区域 ── */
|
||||
.asl-list-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #9CA3AF;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asl-history-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #CBD5E1 transparent;
|
||||
}
|
||||
|
||||
.asl-history-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.asl-history-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.asl-history-list::-webkit-scrollbar-thumb {
|
||||
background: #CBD5E1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.asl-history-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #94A3B8;
|
||||
}
|
||||
|
||||
/* ── 日期分组标签 ── */
|
||||
.asl-date-group-label {
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
/* ── 历史列表项(对齐 Protocol Agent .conv-item) ── */
|
||||
.asl-conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #6B7280;
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.asl-conv-item:hover {
|
||||
background: #F3F4F6;
|
||||
}
|
||||
|
||||
.asl-conv-item.active {
|
||||
background: #EEF2FF;
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.asl-conv-item .conv-icon {
|
||||
flex-shrink: 0;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.asl-conv-item.active .conv-icon {
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.asl-conv-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asl-conv-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asl-conv-status.completed {
|
||||
background: #10B981;
|
||||
}
|
||||
|
||||
.asl-conv-status.running {
|
||||
background: #F59E0B;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.asl-conv-status.failed {
|
||||
background: #EF4444;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── 删除按钮(对齐 Protocol Agent .conv-delete) ── */
|
||||
.asl-conv-delete {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #EF4444;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asl-conv-item:hover .asl-conv-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.asl-conv-delete:hover {
|
||||
background: #FEE2E2;
|
||||
}
|
||||
|
||||
/* ── 空状态 ── */
|
||||
.asl-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: #9CA3AF;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── 面板 B 占位 ── */
|
||||
.asl-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asl-placeholder-icon {
|
||||
color: #D1D5DB;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.asl-placeholder-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6B7280;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.asl-placeholder-desc {
|
||||
font-size: 12px;
|
||||
color: #9CA3AF;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asl-disabled-btn {
|
||||
margin: 0 16px 16px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #D1D5DB;
|
||||
background: white;
|
||||
color: #9CA3AF;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
cursor: not-allowed;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 分割线 ── */
|
||||
.asl-divider {
|
||||
height: 1px;
|
||||
background: #E5E7EB;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── SR 工具导航项 ── */
|
||||
.asl-nav-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #CBD5E1 transparent;
|
||||
}
|
||||
|
||||
.asl-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #6B7280;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.asl-nav-item:hover {
|
||||
background: #F3F4F6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.asl-nav-item.active {
|
||||
background: #EEF2FF;
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.asl-nav-item .nav-icon {
|
||||
flex-shrink: 0;
|
||||
color: #9CA3AF;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.asl-nav-item.active .nav-icon {
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.asl-nav-item-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── SR 未开通提示 ── */
|
||||
.asl-sr-locked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asl-sr-locked-icon {
|
||||
color: #D1D5DB;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.asl-sr-locked-text {
|
||||
font-size: 13px;
|
||||
color: #9CA3AF;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -74,3 +74,12 @@ export interface GenerateRequirementResponse {
|
||||
}
|
||||
|
||||
export type DeepResearchStep = 'landing' | 'setup' | 'strategy' | 'executing' | 'results';
|
||||
|
||||
export interface DeepResearchTaskSummary {
|
||||
id: string;
|
||||
query: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
resultCount: number | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,29 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm, isS
|
||||
</div>
|
||||
<span className="tag tag-purple">深度</span>
|
||||
</label>
|
||||
|
||||
{/* 临床专业评估智能体 */}
|
||||
<label
|
||||
className={`flex items-start gap-3 p-4 border rounded-xl cursor-pointer transition-all ${
|
||||
selectedAgents.includes('clinical')
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes('clinical')}
|
||||
onChange={() => toggleAgent('clinical')}
|
||||
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">
|
||||
基于 FINER 标准评估创新性、临床价值、科学性、可行性
|
||||
</span>
|
||||
</div>
|
||||
<span className="tag tag-pink">专业</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
|
||||
181
frontend-v2/src/modules/rvw/components/ClinicalReport.tsx
Normal file
181
frontend-v2/src/modules/rvw/components/ClinicalReport.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 临床专业评估报告组件
|
||||
* 将 LLM 返回的 Markdown 报告按章节拆分为结构化卡片
|
||||
* 展现风格与 EditorialReport / MethodologyReport 统一
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Stethoscope, ChevronDown, ChevronUp, AlertTriangle, CheckCircle, Lightbulb, TrendingUp } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { ClinicalReviewResult } from '../types';
|
||||
|
||||
interface ClinicalReportProps {
|
||||
data: ClinicalReviewResult;
|
||||
}
|
||||
|
||||
interface ReportSection {
|
||||
title: string;
|
||||
content: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function parseSections(markdown: string): { summary: string; sections: ReportSection[] } {
|
||||
const lines = markdown.split('\n');
|
||||
const sections: ReportSection[] = [];
|
||||
let summary = '';
|
||||
let currentSection: ReportSection | null = null;
|
||||
let contentLines: string[] = [];
|
||||
let summaryCollected = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const h2Match = line.match(/^##\s+(.+)/);
|
||||
const h3Match = line.match(/^###\s+(.+)/);
|
||||
|
||||
if (h2Match || h3Match) {
|
||||
if (currentSection) {
|
||||
currentSection.content = contentLines.join('\n').trim();
|
||||
if (currentSection.content) sections.push(currentSection);
|
||||
} else if (!summaryCollected) {
|
||||
summary = contentLines.join('\n').trim();
|
||||
summaryCollected = true;
|
||||
}
|
||||
currentSection = {
|
||||
title: (h2Match ? h2Match[1] : h3Match![1]).trim(),
|
||||
content: '',
|
||||
level: h2Match ? 2 : 3,
|
||||
};
|
||||
contentLines = [];
|
||||
} else if (line.match(/^#\s+/)) {
|
||||
if (currentSection) {
|
||||
currentSection.content = contentLines.join('\n').trim();
|
||||
if (currentSection.content) sections.push(currentSection);
|
||||
currentSection = null;
|
||||
}
|
||||
contentLines = [];
|
||||
} else {
|
||||
contentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection) {
|
||||
currentSection.content = contentLines.join('\n').trim();
|
||||
if (currentSection.content) sections.push(currentSection);
|
||||
} else if (!summaryCollected) {
|
||||
summary = contentLines.join('\n').trim();
|
||||
}
|
||||
|
||||
return { summary, sections };
|
||||
}
|
||||
|
||||
function getSectionIcon(title: string) {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes('创新') || t.includes('interesting')) return { color: 'text-purple-500', bg: 'bg-purple-50', border: 'border-purple-100' };
|
||||
if (t.includes('临床价值') || t.includes('relevant') || t.includes('相关')) return { color: 'text-blue-500', bg: 'bg-blue-50', border: 'border-blue-100' };
|
||||
if (t.includes('科学') || t.includes('假设')) return { color: 'text-indigo-500', bg: 'bg-indigo-50', border: 'border-indigo-100' };
|
||||
if (t.includes('可行') || t.includes('feasib') || t.includes('伦理')) return { color: 'text-green-500', bg: 'bg-green-50', border: 'border-green-100' };
|
||||
if (t.includes('优化') || t.includes('建议') || t.includes('结论')) return { color: 'text-amber-500', bg: 'bg-amber-50', border: 'border-amber-100' };
|
||||
if (t.includes('明确') || t.includes('pico') || t.includes('问题')) return { color: 'text-pink-500', bg: 'bg-pink-50', border: 'border-pink-100' };
|
||||
return { color: 'text-slate-500', bg: 'bg-slate-50', border: 'border-slate-100' };
|
||||
}
|
||||
|
||||
export default function ClinicalReport({ data }: ClinicalReportProps) {
|
||||
const { summary, sections } = parseSections(data.report);
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggleSection = (idx: number) => {
|
||||
const next = new Set(collapsedSections);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
next.add(idx);
|
||||
}
|
||||
setCollapsedSections(next);
|
||||
};
|
||||
|
||||
if (sections.length === 0) {
|
||||
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-pink-50 to-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Stethoscope className="w-5 h-5 text-pink-600" />
|
||||
<h3 className="font-bold text-lg text-slate-800">临床专业评估</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">基于 FINER 标准的研究选题系统评估</p>
|
||||
</div>
|
||||
<div className="p-6 prose prose-sm prose-slate max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.report}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-pink-50 to-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Stethoscope className="w-5 h-5 text-pink-600" />
|
||||
<h3 className="font-bold text-lg text-slate-800">临床专业评估</h3>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="text-slate-600 text-sm leading-relaxed mb-4 prose prose-sm prose-slate max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{summary}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 mb-4">基于 FINER 标准的研究选题系统评估</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">{sections.length}</span> 个评估维度</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分项标题 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-pink-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">共 {sections.length} 项</span>
|
||||
</div>
|
||||
|
||||
{/* 各评估维度卡片 */}
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, idx) => {
|
||||
const style = getSectionIcon(section.title);
|
||||
const isCollapsed = collapsedSections.has(idx);
|
||||
|
||||
return (
|
||||
<div key={idx} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div
|
||||
className={`px-5 py-4 border-b ${style.bg} ${style.border} cursor-pointer hover:brightness-95 transition-all`}
|
||||
onClick={() => toggleSection(idx)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lightbulb className={`w-5 h-5 ${style.color}`} />
|
||||
<h4 className="font-semibold text-slate-800">{section.title}</h4>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && section.content && (
|
||||
<div className="px-5 py-4 prose prose-sm prose-slate max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{section.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ interface EditorialReportProps {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -46,63 +45,34 @@ export default function EditorialReport({ data }: EditorialReportProps) {
|
||||
}
|
||||
};
|
||||
|
||||
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}`}>{Number(data.overall_score).toFixed(1)}</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 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>
|
||||
|
||||
{/* 评估摘要 */}
|
||||
<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>
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
)}
|
||||
{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>
|
||||
@@ -130,14 +100,9 @@ export default function EditorialReport({ data }: EditorialReportProps) {
|
||||
{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>
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${colors.badge}`}>
|
||||
{getStatusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
MousePointerClick
|
||||
} from 'lucide-react';
|
||||
import type { ForensicsResult, ForensicsIssue, ForensicsTable } from '../types';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface ForensicsReportProps {
|
||||
data: ForensicsResult;
|
||||
@@ -65,7 +67,13 @@ const ISSUE_TYPE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function ForensicsReport({ data }: ForensicsReportProps) {
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||
const hasLlm = !!(data?.llmReport || Object.keys(data?.llmTableReports || {}).length > 0);
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(() => {
|
||||
if (hasLlm) {
|
||||
return new Set((data?.tables || []).map(t => t.id));
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
const [highlightedCell, setHighlightedCell] = useState<string | null>(null);
|
||||
|
||||
// 防御性检查:确保所有数组和对象存在
|
||||
@@ -73,6 +81,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
|
||||
const issues = data?.issues || [];
|
||||
const methods = data?.methods || [];
|
||||
const summary = data?.summary || { totalTables: 0, totalIssues: 0, errorCount: 0, warningCount: 0 };
|
||||
const llmTableReports = data?.llmTableReports || {};
|
||||
|
||||
// 创建 tableId -> caption 映射,用于显示友好的表格名称
|
||||
const tableIdToCaption: Record<string, string> = {};
|
||||
@@ -271,6 +280,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
|
||||
expanded={expandedTables.has(table.id)}
|
||||
onToggle={() => toggleTable(table.id)}
|
||||
highlightedCell={highlightedCell}
|
||||
llmTableReport={llmTableReports[table.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -296,9 +306,10 @@ interface TableCardProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
highlightedCell: string | null;
|
||||
llmTableReport?: string;
|
||||
}
|
||||
|
||||
function TableCard({ table, expanded, onToggle, highlightedCell }: TableCardProps) {
|
||||
function TableCard({ table, expanded, onToggle, highlightedCell, llmTableReport }: TableCardProps) {
|
||||
// 防御性检查:确保 issues 数组存在
|
||||
const issues = table.issues || [];
|
||||
const hasIssues = issues.length > 0;
|
||||
@@ -397,12 +408,27 @@ function TableCard({ table, expanded, onToggle, highlightedCell }: TableCardProp
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表格问题 */}
|
||||
{/* LLM 核查结果(该表格) */}
|
||||
{llmTableReport && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="bg-indigo-50/60 rounded-lg p-4 border border-indigo-100">
|
||||
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<FlaskConical className="w-3.5 h-3.5" />
|
||||
AI 核查结果
|
||||
</p>
|
||||
<div className="prose prose-sm prose-slate max-w-none text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{llmTableReport}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 规则验证详情 */}
|
||||
{issues.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
该表格发现的问题
|
||||
规则验证详情
|
||||
</p>
|
||||
{issues.map((issue, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
|
||||
@@ -9,7 +9,6 @@ interface MethodologyReportProps {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -20,81 +19,42 @@ export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
: { 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}`}>{Number(data.overall_score).toFixed(1)}</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 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>
|
||||
</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>
|
||||
|
||||
{/* 评估摘要 */}
|
||||
<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>
|
||||
{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>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
{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>
|
||||
@@ -121,24 +81,16 @@ export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
)}
|
||||
<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}分
|
||||
{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>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
* 支持显示审稿进度和结果
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot, Info } from 'lucide-react';
|
||||
import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot, Info, AlertTriangle } 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 ForensicsReport from './ForensicsReport';
|
||||
import ClinicalReport from './ClinicalReport';
|
||||
import * as api from '../api';
|
||||
import { message } from 'antd';
|
||||
|
||||
type TabType = 'editorial' | 'methodology' | 'forensics';
|
||||
type TabType = 'editorial' | 'methodology' | 'forensics' | 'clinical';
|
||||
|
||||
interface TaskDetailProps {
|
||||
task: ReviewTask;
|
||||
@@ -29,6 +30,7 @@ const STATUS_INFO: Record<TaskStatus, { label: string; color: string; icon: any
|
||||
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 },
|
||||
partial_completed: { label: '部分完成', color: 'text-amber-500', icon: AlertTriangle },
|
||||
failed: { label: '审查失败', color: 'text-red-500', icon: AlertCircle },
|
||||
};
|
||||
|
||||
@@ -45,6 +47,9 @@ const getProgressSteps = (selectedAgents: string[]) => {
|
||||
if (selectedAgents.includes('methodology')) {
|
||||
steps.push({ key: 'methodology', label: '方法学评估' });
|
||||
}
|
||||
if (selectedAgents.includes('clinical')) {
|
||||
steps.push({ key: 'clinical', label: '临床评估' });
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
@@ -59,7 +64,8 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
void jobId;
|
||||
|
||||
const isProcessing = ['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(task.status);
|
||||
const isCompleted = task.status === 'completed';
|
||||
const isCompleted = task.status === 'completed' || task.status === 'partial_completed';
|
||||
const isPartial = task.status === 'partial_completed';
|
||||
const isFailed = task.status === 'failed';
|
||||
|
||||
// 轮询任务状态
|
||||
@@ -71,8 +77,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
const updated = await api.getTask(task.id);
|
||||
setTask(updated);
|
||||
|
||||
// 如果完成了,加载报告
|
||||
if (updated.status === 'completed') {
|
||||
if (updated.status === 'completed' || updated.status === 'partial_completed') {
|
||||
const reportData = await api.getTaskReport(task.id);
|
||||
setReport(reportData);
|
||||
}
|
||||
@@ -108,13 +113,14 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
// 报告加载后自动设置正确的 Tab
|
||||
useEffect(() => {
|
||||
if (report) {
|
||||
// 优先显示有数据的 Tab
|
||||
if (report.editorialReview) {
|
||||
setActiveTab('editorial');
|
||||
} else if (report.methodologyReview) {
|
||||
setActiveTab('methodology');
|
||||
} else if (report.forensicsResult) {
|
||||
setActiveTab('forensics');
|
||||
} else if (report.clinicalReview) {
|
||||
setActiveTab('clinical');
|
||||
}
|
||||
}
|
||||
}, [report]);
|
||||
@@ -146,7 +152,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
if (stepKey === 'methodology' && hasMethodology) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
if (task.status === 'completed') return 'completed';
|
||||
if (task.status === 'completed' || task.status === 'partial_completed') return 'completed';
|
||||
if (task.status === 'failed') {
|
||||
if (['upload', 'extract'].includes(stepKey)) return 'completed';
|
||||
return 'pending';
|
||||
@@ -197,11 +203,11 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '综合评分', bold: true })] })],
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '审查维度', bold: true })] })],
|
||||
width: { size: 2000, type: WidthType.DXA },
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(`${report.overallScore != null ? Number(report.overallScore).toFixed(1) : '-'} 分`)],
|
||||
children: [new Paragraph(report.selectedAgents?.map(a => a === 'editorial' ? '稿约规范性' : a === 'methodology' ? '方法学' : a === 'clinical' ? '临床评估' : a).join('、') || '-')],
|
||||
width: { size: 7000, type: WidthType.DXA },
|
||||
}),
|
||||
],
|
||||
@@ -244,7 +250,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
if (report.editorialReview) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `一、稿约规范性评估(${report.editorialReview.overall_score}分)`,
|
||||
text: '一、稿约规范性评估',
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 400, after: 200 },
|
||||
})
|
||||
@@ -262,7 +268,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `${i + 1}. ${item.criterion}(${item.score}分)- ${statusText}`,
|
||||
text: `${i + 1}. ${item.criterion} - ${statusText}`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 },
|
||||
})
|
||||
@@ -307,7 +313,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
if (report.methodologyReview) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `二、方法学评估(${report.methodologyReview.overall_score}分)`,
|
||||
text: '二、方法学评估',
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 400, after: 200 },
|
||||
})
|
||||
@@ -321,13 +327,13 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
);
|
||||
|
||||
report.methodologyReview.parts.forEach((part) => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `${part.part}(${part.score}分)`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 },
|
||||
})
|
||||
);
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: part.part,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 },
|
||||
})
|
||||
);
|
||||
|
||||
if (part.issues.length === 0) {
|
||||
children.push(
|
||||
@@ -369,6 +375,41 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
});
|
||||
}
|
||||
|
||||
// 临床专业评估
|
||||
if (report.clinicalReview) {
|
||||
const sectionNum = report.methodologyReview ? '三' : '二';
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum}、临床专业评估`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 400, after: 200 },
|
||||
})
|
||||
);
|
||||
|
||||
const clinicalLines = report.clinicalReview.report.split('\n');
|
||||
for (const line of clinicalLines) {
|
||||
if (!line.trim()) continue;
|
||||
if (line.startsWith('# ')) {
|
||||
children.push(new Paragraph({
|
||||
text: line.replace(/^#+\s*/, ''),
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 },
|
||||
}));
|
||||
} else if (line.startsWith('## ') || line.startsWith('### ')) {
|
||||
children.push(new Paragraph({
|
||||
text: line.replace(/^#+\s*/, ''),
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 150, after: 80 },
|
||||
}));
|
||||
} else {
|
||||
children.push(new Paragraph({
|
||||
text: line,
|
||||
spacing: { after: 60 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页脚
|
||||
children.push(
|
||||
new Paragraph({
|
||||
@@ -525,19 +566,50 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 部分完成警告 */}
|
||||
{isPartial && task.errorDetails && (
|
||||
<div className="bg-amber-50 rounded-xl border border-amber-200 p-5 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-800 mb-1">部分模块未能完成</h3>
|
||||
<p className="text-amber-700 text-sm mb-3">
|
||||
{task.errorDetails.successCount} 个模块成功,{task.errorDetails.errorCount + task.errorDetails.timeoutCount} 个模块失败。已展示成功模块的结果。
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{task.errorDetails.failedSkills.map((skill, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
skill.status === 'timeout' ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{skill.status === 'timeout' ? '超时' : '失败'}
|
||||
</span>
|
||||
<span className="text-amber-800 font-medium">{skill.skillName}</span>
|
||||
<span className="text-amber-600">— {skill.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg p-5 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 className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-white/90" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">审查完成</h2>
|
||||
<p className="text-indigo-100 text-sm">
|
||||
审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
|
||||
{report.completedAt && ` · ${new Date(report.completedAt).toLocaleString('zh-CN')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-5xl font-bold">{report.overallScore != null ? Number(report.overallScore).toFixed(1) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -552,7 +624,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
稿约规范性 ({report.editorialReview.overall_score}分)
|
||||
稿约规范性
|
||||
</button>
|
||||
)}
|
||||
{report.methodologyReview && (
|
||||
@@ -564,7 +636,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
方法学评估 ({report.methodologyReview.overall_score}分)
|
||||
方法学评估
|
||||
</button>
|
||||
)}
|
||||
{report.forensicsResult && (
|
||||
@@ -579,6 +651,18 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
数据验证 ({report.forensicsResult.summary.totalIssues || 0}个问题)
|
||||
</button>
|
||||
)}
|
||||
{report.clinicalReview && (
|
||||
<button
|
||||
onClick={() => setActiveTab('clinical')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
activeTab === 'clinical'
|
||||
? 'font-bold bg-white text-indigo-600 shadow-sm'
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
临床评估
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 非 docx 文件无数据验证提示 */}
|
||||
@@ -611,6 +695,9 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
{activeTab === 'forensics' && report.forensicsResult && (
|
||||
<ForensicsReport data={report.forensicsResult} />
|
||||
)}
|
||||
{activeTab === 'clinical' && report.clinicalReview && (
|
||||
<ClinicalReport data={report.clinicalReview} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,8 +111,8 @@ export default function TaskTable({
|
||||
);
|
||||
}
|
||||
|
||||
// 已完成:[查看报告] [重新审稿] [删除]
|
||||
if (task.status === 'completed') {
|
||||
// 已完成 / 部分完成:[查看报告] [重新审稿] [删除]
|
||||
if (task.status === 'completed' || task.status === 'partial_completed') {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
@@ -214,8 +214,8 @@ export default function TaskTable({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
className={`font-bold text-slate-800 text-sm mb-0.5 truncate ${task.status === 'completed' ? 'cursor-pointer hover:text-indigo-600' : ''}`}
|
||||
onClick={() => task.status === 'completed' && onViewReport(task)}
|
||||
className={`font-bold text-slate-800 text-sm mb-0.5 truncate ${(task.status === 'completed' || task.status === 'partial_completed') ? 'cursor-pointer hover:text-indigo-600' : ''}`}
|
||||
onClick={() => (task.status === 'completed' || task.status === 'partial_completed') && onViewReport(task)}
|
||||
title={task.fileName}
|
||||
>
|
||||
{task.fileName}
|
||||
@@ -229,7 +229,7 @@ export default function TaskTable({
|
||||
</>
|
||||
)}
|
||||
{/* 将结果摘要整合到这里 */}
|
||||
{task.status === 'completed' && task.editorialScore !== undefined && (
|
||||
{(task.status === 'completed' || task.status === 'partial_completed') && task.editorialScore !== undefined && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className={`text-[10px] font-medium ${task.editorialScore >= 80 ? 'text-green-600' : task.editorialScore >= 60 ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
@@ -237,7 +237,13 @@ export default function TaskTable({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{task.status === 'completed' && task.methodologyScore !== undefined && (
|
||||
{task.status === 'partial_completed' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-[10px] font-medium text-amber-600">部分完成</span>
|
||||
</>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'partial_completed') && task.methodologyScore !== undefined && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className={`text-[10px] font-medium ${task.methodologyScore >= 80 ? 'text-green-600' : task.methodologyScore >= 60 ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
|
||||
@@ -10,10 +10,11 @@ export type TaskStatus =
|
||||
| 'reviewing_editorial' // 正在审查稿约规范性
|
||||
| 'reviewing_methodology' // 正在审查方法学
|
||||
| 'completed' // 已完成
|
||||
| 'partial_completed' // 部分完成(部分模块成功,部分失败/超时)
|
||||
| 'failed'; // 失败
|
||||
|
||||
// 智能体类型
|
||||
export type AgentType = 'editorial' | 'methodology';
|
||||
export type AgentType = 'editorial' | 'methodology' | 'clinical';
|
||||
|
||||
// 审查任务
|
||||
export interface ReviewTask {
|
||||
@@ -28,6 +29,19 @@ export interface ReviewTask {
|
||||
methodologyScore?: number; // 方法学分数
|
||||
methodologyStatus?: string; // 方法学状态(通过/存疑/不通过)
|
||||
errorMessage?: string;
|
||||
errorDetails?: {
|
||||
failedSkills: Array<{
|
||||
skillId: string;
|
||||
skillName: string;
|
||||
status: 'error' | 'timeout';
|
||||
error: string;
|
||||
executionTime: number;
|
||||
}>;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
timeoutCount: number;
|
||||
totalSkills: number;
|
||||
};
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
durationSeconds?: number;
|
||||
@@ -110,6 +124,14 @@ export interface ForensicsResult {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
llmReport?: string;
|
||||
llmTableReports?: Record<string, string>;
|
||||
}
|
||||
|
||||
// 临床专业评估结果
|
||||
export interface ClinicalReviewResult {
|
||||
report: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// 完整审查报告
|
||||
@@ -117,6 +139,7 @@ export interface ReviewReport extends ReviewTask {
|
||||
editorialReview?: EditorialReviewResult;
|
||||
methodologyReview?: MethodologyReviewResult;
|
||||
forensicsResult?: ForensicsResult;
|
||||
clinicalReview?: ClinicalReviewResult;
|
||||
modelUsed?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user