feat(aia): Protocol Agent MVP complete with one-click generation and Word export
- Add one-click research protocol generation with streaming output - Implement Word document export via Pandoc integration - Add dynamic dual-panel layout with resizable split pane - Implement collapsible content for StatePanel stages - Add conversation history management with title auto-update - Fix scroll behavior, markdown rendering, and UI layout issues - Simplify conversation creation logic for reliability
This commit is contained in:
@@ -60,5 +60,6 @@ export default apiClient;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -259,5 +259,6 @@ export async function logout(): Promise<void> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,5 +25,6 @@ export * from './api';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -50,4 +50,5 @@ export async function fetchUserModules(): Promise<string[]> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -131,3 +131,4 @@ export default ModulePermissionModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -42,3 +42,4 @@ export default AdminModule;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -207,3 +207,4 @@ export const TENANT_TYPE_NAMES: Record<TenantType, string> = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,3 +21,4 @@ export { ChatWorkspace } from './ChatWorkspace';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Protocol Agent 页面
|
||||
*
|
||||
* 100%还原原型图0119的精致设计
|
||||
* 三栏布局:可折叠侧边栏 + 聊天区 + 状态面板
|
||||
* V2: 支持动态布局 + 文档预览
|
||||
* - 要素收集阶段: Chat 65% : Context 35%
|
||||
* - 方案生成阶段: Chat 35% : Document 65%
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -13,11 +14,21 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { ChatArea } from './components/ChatArea';
|
||||
import { StatePanel } from './components/StatePanel';
|
||||
import { DocumentPanel } from './components/DocumentPanel';
|
||||
import { ResizableSplitPane } from './components/ResizableSplitPane';
|
||||
import { ViewSwitcher } from './components/ViewSwitcher';
|
||||
import { useProtocolContext } from './hooks/useProtocolContext';
|
||||
import { useProtocolConversations } from './hooks/useProtocolConversations';
|
||||
import { useProtocolGeneration } from './hooks/useProtocolGeneration';
|
||||
import { getAccessToken } from '../../../framework/auth/api';
|
||||
import './styles/protocol-agent.css';
|
||||
|
||||
// 布局比例配置
|
||||
const LAYOUT_RATIOS = {
|
||||
context: 65, // 要素收集阶段:Chat 65%
|
||||
document: 35, // 方案生成阶段:Chat 35%
|
||||
};
|
||||
|
||||
export const ProtocolAgentPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { conversationId } = useParams<{ conversationId?: string }>();
|
||||
@@ -32,11 +43,27 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
createConversation,
|
||||
selectConversation,
|
||||
deleteConversation,
|
||||
updateConversationTitle,
|
||||
} = useProtocolConversations(conversationId);
|
||||
|
||||
// 上下文状态
|
||||
const { context, refreshContext } = useProtocolContext(currentConversation?.id);
|
||||
|
||||
// 方案生成状态
|
||||
const {
|
||||
generatedContent,
|
||||
currentSection,
|
||||
isGenerating,
|
||||
hasGeneratedContent,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
startGeneration,
|
||||
exportWord,
|
||||
} = useProtocolGeneration({ conversationId: currentConversation?.id });
|
||||
|
||||
// Word 导出状态
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// 处理阶段数据编辑更新
|
||||
const handleStageUpdate = useCallback(async (stageCode: string, data: Record<string, unknown>) => {
|
||||
if (!currentConversation?.id) return;
|
||||
@@ -63,33 +90,31 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
await refreshContext();
|
||||
}, [currentConversation?.id, refreshContext]);
|
||||
|
||||
// 使用ref避免无限循环
|
||||
const hasTriedCreate = useRef(false);
|
||||
// 自动创建会话(首次进入时)
|
||||
const hasAutoCreated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只在首次进入且无conversationId时尝试创建一次
|
||||
if (!conversationId && !currentConversation && !hasTriedCreate.current) {
|
||||
hasTriedCreate.current = true;
|
||||
console.log('[ProtocolAgentPage] 自动创建新对话...');
|
||||
|
||||
createConversation().then(newConv => {
|
||||
if (newConv) {
|
||||
console.log('[ProtocolAgentPage] 新对话创建成功:', newConv.id);
|
||||
navigate(`/ai-qa/protocol-agent/${newConv.id}`, { replace: true });
|
||||
} else {
|
||||
console.error('[ProtocolAgentPage] 创建对话失败');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[ProtocolAgentPage] 创建对话异常:', err);
|
||||
});
|
||||
// 如果已有 conversationId 或 currentConversation,无需创建
|
||||
if (conversationId || currentConversation || hasAutoCreated.current) {
|
||||
return;
|
||||
}
|
||||
}, [conversationId, currentConversation, navigate]); // 移除createConversation依赖
|
||||
|
||||
hasAutoCreated.current = true;
|
||||
console.log('[ProtocolAgentPage] 自动创建新对话...');
|
||||
|
||||
createConversation().then(newConv => {
|
||||
if (newConv) {
|
||||
console.log('[ProtocolAgentPage] 新对话创建成功:', newConv.id);
|
||||
navigate(`/ai-qa/protocol-agent/${newConv.id}`, { replace: true });
|
||||
}
|
||||
});
|
||||
}, [conversationId, currentConversation, createConversation, navigate]);
|
||||
|
||||
// 获取当前阶段信息
|
||||
const currentStageName = context?.stageName || '科学问题梳理';
|
||||
const currentStageIndex = context?.stages?.findIndex(s => s.status === 'current') ?? 0;
|
||||
|
||||
// 创建新对话
|
||||
// 创建新对话(立即创建,保证功能正常)
|
||||
const handleNewConversation = async () => {
|
||||
const newConv = await createConversation();
|
||||
if (newConv) {
|
||||
@@ -110,6 +135,35 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
navigate('/ai-qa');
|
||||
};
|
||||
|
||||
// 更新对话标题
|
||||
const handleTitleUpdate = (title: string) => {
|
||||
if (currentConversation?.id) {
|
||||
updateConversationTitle(currentConversation.id, title);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理 Word 导出
|
||||
const handleExportWord = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportWord();
|
||||
} catch (error) {
|
||||
alert(`导出失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理章节讨论(Phase 2)
|
||||
const handleSectionDiscuss = (sectionId: string, sectionTitle: string) => {
|
||||
// TODO: 实现章节讨论模式
|
||||
console.log('讨论章节:', sectionId, sectionTitle);
|
||||
alert(`"讨论与优化" 功能将在 Phase 2 实现\n\n章节: ${sectionTitle}`);
|
||||
};
|
||||
|
||||
// 计算当前布局比例
|
||||
const currentLayoutRatio = LAYOUT_RATIOS[viewMode];
|
||||
|
||||
// 如果没有conversationId,显示等待状态
|
||||
if (!conversationId) {
|
||||
return (
|
||||
@@ -182,17 +236,17 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
<div className="sidebar-content">
|
||||
<div className="sidebar-header">
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="back-to-hub-btn"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
<ChevronLeft size={18} />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
<span className="sidebar-title">研究方案</span>
|
||||
</div>
|
||||
|
||||
<button className="new-chat-btn" onClick={handleNewConversation}>
|
||||
<Plus size={18} />
|
||||
<span>新建方案</span>
|
||||
<button className="new-chat-btn compact" onClick={handleNewConversation}>
|
||||
<Plus size={14} />
|
||||
<span>新建对话</span>
|
||||
</button>
|
||||
|
||||
<div className="conversations-list">
|
||||
@@ -241,6 +295,13 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{/* 视图切换器 */}
|
||||
<ViewSwitcher
|
||||
currentView={viewMode}
|
||||
onViewChange={setViewMode}
|
||||
documentDisabled={!hasGeneratedContent && !isGenerating}
|
||||
documentDisabledTooltip="请先生成研究方案"
|
||||
/>
|
||||
<div className="current-stage-badge">
|
||||
<span className="badge-label">当前阶段:</span>
|
||||
<span className="badge-value">Step {currentStageIndex + 1}: {currentStageName}</span>
|
||||
@@ -251,17 +312,43 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 聊天 + 状态面板 */}
|
||||
{/* 聊天 + 右侧面板(动态切换) */}
|
||||
<div className="workspace-body">
|
||||
{/* 聊天区域 */}
|
||||
<ChatArea
|
||||
conversationId={currentConversation?.id}
|
||||
context={context}
|
||||
onContextUpdate={refreshContext}
|
||||
<ResizableSplitPane
|
||||
defaultLeftRatio={currentLayoutRatio}
|
||||
minLeftRatio={25}
|
||||
maxLeftRatio={80}
|
||||
enableDrag={true}
|
||||
storageKey={`protocol-agent-split-${viewMode}`}
|
||||
leftPanel={
|
||||
<ChatArea
|
||||
conversationId={currentConversation?.id}
|
||||
context={context}
|
||||
onContextUpdate={refreshContext}
|
||||
onTitleUpdate={handleTitleUpdate}
|
||||
/>
|
||||
}
|
||||
rightPanel={
|
||||
viewMode === 'context' ? (
|
||||
<StatePanel
|
||||
context={context}
|
||||
onStageUpdate={handleStageUpdate}
|
||||
onStartGeneration={startGeneration}
|
||||
canGenerate={context?.canGenerate}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPanel
|
||||
content={generatedContent}
|
||||
isGenerating={isGenerating}
|
||||
currentSection={currentSection || undefined}
|
||||
onExportWord={handleExportWord}
|
||||
isExporting={isExporting}
|
||||
onSectionDiscuss={handleSectionDiscuss}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 状态面板 */}
|
||||
<StatePanel context={context} onStageUpdate={handleStageUpdate} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -64,3 +64,4 @@ export const ActionCardComponent: React.FC<ActionCardProps> = ({ card }) => {
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ interface ChatAreaProps {
|
||||
conversationId?: string;
|
||||
context: ProtocolContext | null;
|
||||
onContextUpdate: () => void;
|
||||
/** 更新对话标题(首次发送消息时调用) */
|
||||
onTitleUpdate?: (title: string) => void;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -96,11 +98,15 @@ function parseExtractedData(content: string): {
|
||||
export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
conversationId,
|
||||
context,
|
||||
onContextUpdate
|
||||
onContextUpdate,
|
||||
onTitleUpdate,
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const prevConversationIdRef = useRef<string | undefined>(undefined);
|
||||
const isFirstMount = useRef(true);
|
||||
|
||||
// 使用通用 useAIStream hook 实现流式输出(打字机效果)
|
||||
const {
|
||||
@@ -130,19 +136,15 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
scrollToBottom();
|
||||
}, [messages, streamContent, scrollToBottom]);
|
||||
|
||||
// 初始化欢迎消息
|
||||
useEffect(() => {
|
||||
if (conversationId && messages.length === 0) {
|
||||
const currentStage = context?.currentStage || 'scientific_question';
|
||||
const stageName = STAGE_NAMES[currentStage] || '科学问题梳理';
|
||||
|
||||
setMessages([{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: `您好!我是研究方案制定助手,将帮助您系统地完成临床研究方案的核心要素设计。
|
||||
|
||||
我们将一起完成以下5个关键步骤:
|
||||
|
||||
// 生成欢迎消息(紧凑版本)
|
||||
const createWelcomeMessage = useCallback((currentStage?: string): Message => {
|
||||
const stage = currentStage || 'scientific_question';
|
||||
const stageName = STAGE_NAMES[stage] || '科学问题梳理';
|
||||
|
||||
return {
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: `您好!我是研究方案制定助手,将帮助您系统地完成临床研究方案的核心要素设计。我们将一起完成以下5个关键步骤:
|
||||
1️⃣ **科学问题梳理** - 明确研究要解决的核心问题
|
||||
2️⃣ **PICO要素** - 确定研究人群、干预、对照和结局
|
||||
3️⃣ **研究设计** - 选择合适的研究类型和方法
|
||||
@@ -151,17 +153,77 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
|
||||
完成这5个要素后,您可以**一键生成完整的研究方案**并下载为Word文档。
|
||||
|
||||
---
|
||||
|
||||
📍 **当前阶段**: ${stageName}
|
||||
|
||||
让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`,
|
||||
stage: currentStage,
|
||||
stageName,
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
stage,
|
||||
stageName,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载历史消息(当 conversationId 变化时)
|
||||
useEffect(() => {
|
||||
// 首次挂载或 conversationId 变化时执行
|
||||
const shouldUpdate = isFirstMount.current || conversationId !== prevConversationIdRef.current;
|
||||
|
||||
if (!shouldUpdate) {
|
||||
return;
|
||||
}
|
||||
}, [conversationId, messages.length, context]);
|
||||
|
||||
isFirstMount.current = false;
|
||||
prevConversationIdRef.current = conversationId;
|
||||
|
||||
// 如果没有 conversationId,显示欢迎消息(等待自动创建会话)
|
||||
if (!conversationId) {
|
||||
setMessages([createWelcomeMessage(context?.currentStage)]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadHistoryMessages = async () => {
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await fetch(`/api/v1/aia/protocol-agent/messages/${conversationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const historyMessages = result.data?.messages || [];
|
||||
|
||||
if (historyMessages.length > 0) {
|
||||
// 有历史消息:加载历史 + 欢迎消息在最前
|
||||
const loadedMessages: Message[] = historyMessages.map((m: any) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
thinkingContent: m.thinkingContent,
|
||||
timestamp: new Date(m.createdAt),
|
||||
}));
|
||||
|
||||
// 在历史消息前添加欢迎消息
|
||||
setMessages([createWelcomeMessage(context?.currentStage), ...loadedMessages]);
|
||||
} else {
|
||||
// 没有历史消息:只显示欢迎消息
|
||||
setMessages([createWelcomeMessage(context?.currentStage)]);
|
||||
}
|
||||
} else {
|
||||
// API 失败:显示欢迎消息
|
||||
setMessages([createWelcomeMessage(context?.currentStage)]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatArea] 加载历史消息失败:', error);
|
||||
// 出错也显示欢迎消息
|
||||
setMessages([createWelcomeMessage(context?.currentStage)]);
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadHistoryMessages();
|
||||
}, [conversationId, context?.currentStage, createWelcomeMessage]);
|
||||
|
||||
// 处理流式响应完成
|
||||
useEffect(() => {
|
||||
@@ -222,6 +284,9 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
|
||||
const userContent = input.trim();
|
||||
|
||||
// 检查是否是首次用户消息(只有欢迎消息时)
|
||||
const isFirstUserMessage = messages.filter(m => m.role === 'user').length === 0;
|
||||
|
||||
// 添加用户消息
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
@@ -232,11 +297,23 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
|
||||
// 使用 useAIStream 发送消息(流式输出)
|
||||
// 首次消息时更新对话标题(锦上添花,不影响核心功能)
|
||||
if (isFirstUserMessage && onTitleUpdate) {
|
||||
try {
|
||||
const newTitle = userContent.length > 20
|
||||
? userContent.slice(0, 20) + '...'
|
||||
: userContent;
|
||||
onTitleUpdate(newTitle);
|
||||
} catch (e) {
|
||||
console.warn('[ChatArea] 更新标题失败,不影响对话:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
await sendStreamMessage(userContent, {
|
||||
conversationId,
|
||||
});
|
||||
}, [input, conversationId, isStreaming, sendStreamMessage]);
|
||||
}, [input, conversationId, isStreaming, sendStreamMessage, messages, onTitleUpdate]);
|
||||
|
||||
/**
|
||||
* 处理同步到方案
|
||||
@@ -321,6 +398,14 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
<section className="chat-area">
|
||||
{/* 聊天历史 */}
|
||||
<div className="chat-container" ref={chatContainerRef}>
|
||||
{/* 加载历史消息时显示加载状态 */}
|
||||
{isLoadingHistory && (
|
||||
<div className="loading-history">
|
||||
<div className="loading-spinner" />
|
||||
<span>加载历史消息...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id}>
|
||||
{msg.role === 'user' && (
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* DocumentPanel - 文档预览面板
|
||||
*
|
||||
* A4 纸张效果预览,显示生成的研究方案
|
||||
* 支持:
|
||||
* - 流式渲染 + 打字机效果
|
||||
* - 分章节显示
|
||||
* - "讨论与优化" 按钮(Phase 2)
|
||||
* - 复制、导出 Word
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { Copy, Download, Loader2, MessageSquareText, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface DocumentPanelProps {
|
||||
/** Markdown 内容 */
|
||||
content: string;
|
||||
/** 是否正在生成中 */
|
||||
isGenerating?: boolean;
|
||||
/** 当前生成的章节名称 */
|
||||
currentSection?: string;
|
||||
/** 导出 Word 回调 */
|
||||
onExportWord?: () => void;
|
||||
/** 导出中状态 */
|
||||
isExporting?: boolean;
|
||||
/** 章节讨论回调(Phase 2)*/
|
||||
onSectionDiscuss?: (sectionId: string, sectionTitle: string) => void;
|
||||
}
|
||||
|
||||
export const DocumentPanel: React.FC<DocumentPanelProps> = ({
|
||||
content,
|
||||
isGenerating = false,
|
||||
currentSection,
|
||||
onExportWord,
|
||||
isExporting = false,
|
||||
onSectionDiscuss,
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const prevGeneratingRef = useRef(false);
|
||||
|
||||
// 监测用户是否手动滚动
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!contentRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentRef.current;
|
||||
// 如果用户离底部超过 100px,认为是手动滚动
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
setAutoScroll(isNearBottom);
|
||||
setShowScrollToBottom(!isNearBottom);
|
||||
}, []);
|
||||
|
||||
// 生成开始时重置到顶部
|
||||
useEffect(() => {
|
||||
if (isGenerating && !prevGeneratingRef.current && contentRef.current) {
|
||||
// 刚开始生成时滚动到顶部
|
||||
contentRef.current.scrollTop = 0;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
prevGeneratingRef.current = isGenerating;
|
||||
}, [isGenerating]);
|
||||
|
||||
// 自动滚动到最新内容(只在 autoScroll 开启时)
|
||||
useEffect(() => {
|
||||
if (isGenerating && autoScroll && contentRef.current) {
|
||||
// 使用平滑滚动跟随内容
|
||||
contentRef.current.scrollTo({
|
||||
top: contentRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [content, isGenerating, autoScroll]);
|
||||
|
||||
// 滚动到底部按钮点击
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({
|
||||
top: contentRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
setAutoScroll(true);
|
||||
setShowScrollToBottom(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 复制到剪贴板
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
// TODO: 显示 toast 提示
|
||||
alert('已复制到剪贴板');
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 空状态
|
||||
if (!content && !isGenerating) {
|
||||
return (
|
||||
<div className="document-panel">
|
||||
<div className="document-empty">
|
||||
<div className="empty-icon">📄</div>
|
||||
<h3>尚未生成研究方案</h3>
|
||||
<p>完成研究摘要后,点击"一键生成研究方案"开始撰写</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="document-panel">
|
||||
{/* 工具栏 */}
|
||||
<div className="document-toolbar">
|
||||
<div className="toolbar-left">
|
||||
{isGenerating && (
|
||||
<div className="generating-status">
|
||||
<Loader2 size={14} className="spinner" />
|
||||
<span>正在撰写{currentSection ? `「${currentSection}」` : '...'}...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleCopy}
|
||||
disabled={!content}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span>复制</span>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn primary"
|
||||
onClick={onExportWord}
|
||||
disabled={isExporting || !content}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spinner" />
|
||||
<span>导出中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={14} />
|
||||
<span>导出 Word</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* A4 纸张预览 */}
|
||||
<div
|
||||
className="document-scroll-area"
|
||||
ref={contentRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="a4-paper">
|
||||
<div className={`paper-content ${isGenerating ? 'typing' : ''}`}>
|
||||
<ProtocolMarkdownRenderer
|
||||
content={content}
|
||||
onSectionDiscuss={onSectionDiscuss}
|
||||
/>
|
||||
|
||||
{/* 打字机光标 */}
|
||||
{isGenerating && <span className="typing-cursor">|</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部留白 */}
|
||||
<div style={{ height: '60px' }} />
|
||||
</div>
|
||||
|
||||
{/* 滚动到底部按钮(用户手动滚动后显示) */}
|
||||
{showScrollToBottom && isGenerating && (
|
||||
<button
|
||||
className="scroll-to-bottom-btn"
|
||||
onClick={scrollToBottom}
|
||||
title="跟随最新内容"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
<span>跟随最新</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 研究方案 Markdown 渲染器
|
||||
* 支持章节级别的"讨论与优化"按钮
|
||||
*/
|
||||
interface ProtocolMarkdownRendererProps {
|
||||
content: string;
|
||||
onSectionDiscuss?: (sectionId: string, sectionTitle: string) => void;
|
||||
}
|
||||
|
||||
const ProtocolMarkdownRenderer: React.FC<ProtocolMarkdownRendererProps> = ({
|
||||
content,
|
||||
onSectionDiscuss,
|
||||
}) => {
|
||||
const elements = useMemo(() => {
|
||||
const lines = content.split('\n');
|
||||
const result: React.ReactNode[] = [];
|
||||
let key = 0;
|
||||
let currentParagraph: string[] = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (currentParagraph.length > 0) {
|
||||
const text = currentParagraph.join(' ').trim();
|
||||
if (text) {
|
||||
result.push(
|
||||
<p key={key++} style={{ textAlign: 'justify', textIndent: '2em' }}>
|
||||
{formatInlineText(text)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
currentParagraph = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 主标题 #
|
||||
if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) {
|
||||
flushParagraph();
|
||||
result.push(
|
||||
<h1 key={key++}>{formatInlineText(trimmed.slice(2))}</h1>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 二级标题 ## (章节标题,添加讨论按钮)
|
||||
if (trimmed.startsWith('## ')) {
|
||||
flushParagraph();
|
||||
const titleText = trimmed.slice(3);
|
||||
const match = titleText.match(/^(\d+)\.\s*(.+)$/);
|
||||
const sectionNumber = match?.[1];
|
||||
const sectionTitle = match?.[2] || titleText;
|
||||
|
||||
result.push(
|
||||
<div className="section-header" key={key++}>
|
||||
<h2>{formatInlineText(titleText)}</h2>
|
||||
{onSectionDiscuss && sectionNumber && (
|
||||
<button
|
||||
className="section-discuss-btn"
|
||||
onClick={() => onSectionDiscuss(`section_${sectionNumber}`, sectionTitle)}
|
||||
title="与 AI 讨论优化此章节"
|
||||
>
|
||||
<MessageSquareText size={12} />
|
||||
<span>讨论与优化</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 三级标题 ###
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushParagraph();
|
||||
result.push(
|
||||
<h3 key={key++}>{formatInlineText(trimmed.slice(4))}</h3>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
if (trimmed.match(/^[\-\*]\s+/)) {
|
||||
flushParagraph();
|
||||
result.push(
|
||||
<li key={key++} style={{ marginLeft: '2em' }}>
|
||||
{formatInlineText(trimmed.replace(/^[\-\*]\s+/, ''))}
|
||||
</li>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (trimmed.match(/^\d+\.\s+/)) {
|
||||
flushParagraph();
|
||||
result.push(
|
||||
<li key={key++} style={{ marginLeft: '2em' }}>
|
||||
{formatInlineText(trimmed.replace(/^\d+\.\s+/, ''))}
|
||||
</li>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 空行
|
||||
if (trimmed === '') {
|
||||
flushParagraph();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通文本,累积到段落
|
||||
currentParagraph.push(trimmed);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return result;
|
||||
}, [content, onSectionDiscuss]);
|
||||
|
||||
return <>{elements}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理行内格式(粗体、斜体)
|
||||
*/
|
||||
function formatInlineText(text: string): React.ReactNode {
|
||||
// 简单的粗体处理
|
||||
const parts: React.ReactNode[] = [];
|
||||
const regex = /\*\*([^*]+)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let key = 0;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={key++}>{text.slice(lastIndex, match.index)}</span>);
|
||||
}
|
||||
parts.push(<strong key={key++}>{match[1]}</strong>);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={key++}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts.length === 0 ? text : parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
export default DocumentPanel;
|
||||
|
||||
@@ -83,9 +83,15 @@ function parseMarkdown(text: string): React.ReactNode[] {
|
||||
// 非列表内容,先清空列表
|
||||
flushList();
|
||||
|
||||
// 空行
|
||||
// 空行 - 只添加小间距,不添加 br
|
||||
if (line.trim() === '') {
|
||||
elements.push(<br key={key++} />);
|
||||
// 跳过空行,段落之间的间距由 CSS margin 控制
|
||||
continue;
|
||||
}
|
||||
|
||||
// 分隔线 ---
|
||||
if (line.trim() === '---' || line.trim() === '***') {
|
||||
elements.push(<hr key={key++} className="md-divider" />);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -220,3 +226,4 @@ export const MarkdownContent: React.FC<MarkdownContentProps> = ({
|
||||
|
||||
export default MarkdownContent;
|
||||
|
||||
|
||||
|
||||
@@ -50,3 +50,4 @@ function formatTime(date: Date): string {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* ResizableSplitPane - 可拖拽分栏组件
|
||||
*
|
||||
* 支持:
|
||||
* - 动态调整左右面板比例
|
||||
* - 拖拽手柄
|
||||
* - 比例记忆 (localStorage)
|
||||
* - 平滑过渡动画
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface ResizableSplitPaneProps {
|
||||
/** 左侧面板 */
|
||||
leftPanel: React.ReactNode;
|
||||
/** 右侧面板 */
|
||||
rightPanel: React.ReactNode;
|
||||
/** 默认左侧宽度百分比 (0-100) */
|
||||
defaultLeftRatio?: number;
|
||||
/** 最小左侧宽度百分比 */
|
||||
minLeftRatio?: number;
|
||||
/** 最大左侧宽度百分比 */
|
||||
maxLeftRatio?: number;
|
||||
/** 是否启用拖拽 */
|
||||
enableDrag?: boolean;
|
||||
/** 比例变化回调 */
|
||||
onRatioChange?: (leftRatio: number) => void;
|
||||
/** localStorage 存储 key */
|
||||
storageKey?: string;
|
||||
/** CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ResizableSplitPane: React.FC<ResizableSplitPaneProps> = ({
|
||||
leftPanel,
|
||||
rightPanel,
|
||||
defaultLeftRatio = 65,
|
||||
minLeftRatio = 25,
|
||||
maxLeftRatio = 80,
|
||||
enableDrag = true,
|
||||
onRatioChange,
|
||||
storageKey = 'protocol-agent-split-ratio',
|
||||
className = '',
|
||||
}) => {
|
||||
// 从 localStorage 读取保存的比例,否则使用默认值
|
||||
const [leftRatio, setLeftRatio] = useState<number>(() => {
|
||||
if (storageKey) {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsed = parseFloat(saved);
|
||||
if (!isNaN(parsed) && parsed >= minLeftRatio && parsed <= maxLeftRatio) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultLeftRatio;
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当 defaultLeftRatio 变化时更新(用于视图切换时的自动调整)
|
||||
useEffect(() => {
|
||||
setLeftRatio(defaultLeftRatio);
|
||||
}, [defaultLeftRatio]);
|
||||
|
||||
// 保存到 localStorage
|
||||
useEffect(() => {
|
||||
if (storageKey && !isDragging) {
|
||||
localStorage.setItem(storageKey, leftRatio.toString());
|
||||
}
|
||||
}, [leftRatio, storageKey, isDragging]);
|
||||
|
||||
// 拖拽处理
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!enableDrag) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, [enableDrag]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
|
||||
// 限制范围
|
||||
const clampedRatio = Math.max(minLeftRatio, Math.min(maxLeftRatio, newRatio));
|
||||
setLeftRatio(clampedRatio);
|
||||
onRatioChange?.(clampedRatio);
|
||||
}, [isDragging, minLeftRatio, maxLeftRatio, onRatioChange]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// 绑定全局鼠标事件
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`resizable-split-pane ${className} ${isDragging ? 'dragging' : ''}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 左侧面板 */}
|
||||
<div
|
||||
className="split-pane-left"
|
||||
style={{
|
||||
width: `${leftRatio}%`,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
transition: isDragging ? 'none' : 'width 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{leftPanel}
|
||||
</div>
|
||||
|
||||
{/* 拖拽手柄 */}
|
||||
{enableDrag && (
|
||||
<div
|
||||
className="split-pane-handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
width: '6px',
|
||||
cursor: 'col-resize',
|
||||
background: isDragging ? '#6366F1' : 'transparent',
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '4px',
|
||||
height: '40px',
|
||||
background: isDragging ? '#fff' : '#D1D5DB',
|
||||
borderRadius: '2px',
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
/>
|
||||
{/* 悬停区域扩大 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const handle = e.currentTarget.previousSibling as HTMLElement;
|
||||
if (handle) handle.style.opacity = '1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDragging) {
|
||||
const handle = e.currentTarget.previousSibling as HTMLElement;
|
||||
if (handle) handle.style.opacity = '0';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右侧面板 */}
|
||||
<div
|
||||
className="split-pane-right"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
transition: isDragging ? 'none' : 'width 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResizableSplitPane;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Stage Card - 阶段状态卡片
|
||||
*
|
||||
* 100%还原原型图阶段卡片设计
|
||||
* 支持:
|
||||
* - 已完成阶段:显示数据 + 编辑按钮 + 折叠展开
|
||||
* - 进行中阶段:显示加载动画
|
||||
* - 待完成阶段:显示添加按钮(手动填写)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Check, Loader2, Edit2 } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Loader2, Edit2, Plus, Circle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type {
|
||||
StageInfo,
|
||||
ScientificQuestionData,
|
||||
@@ -18,7 +21,10 @@ import type {
|
||||
interface StageCardProps {
|
||||
stage: StageInfo;
|
||||
index: number;
|
||||
/** 编辑已完成的数据 */
|
||||
onEdit?: () => void;
|
||||
/** 手动添加数据(未完成阶段) */
|
||||
onAdd?: () => void;
|
||||
}
|
||||
|
||||
const STAGE_TITLES: Record<string, string> = {
|
||||
@@ -29,7 +35,7 @@ const STAGE_TITLES: Record<string, string> = {
|
||||
endpoints: '观察指标',
|
||||
};
|
||||
|
||||
export const StageCard: React.FC<StageCardProps> = ({ stage, index, onEdit }) => {
|
||||
export const StageCard: React.FC<StageCardProps> = ({ stage, index, onEdit, onAdd }) => {
|
||||
const { stageCode, status, data } = stage;
|
||||
const title = STAGE_TITLES[stageCode] || stage.stageName;
|
||||
const number = (index + 1).toString().padStart(2, '0');
|
||||
@@ -47,6 +53,7 @@ export const StageCard: React.FC<StageCardProps> = ({ stage, index, onEdit }) =>
|
||||
<div className="stage-header">
|
||||
<h3 className="stage-number">{number} {title}</h3>
|
||||
<div className="stage-actions">
|
||||
{/* 已完成:显示编辑按钮 */}
|
||||
{status === 'completed' && onEdit && (
|
||||
<button
|
||||
className="edit-btn"
|
||||
@@ -56,8 +63,20 @@ export const StageCard: React.FC<StageCardProps> = ({ stage, index, onEdit }) =>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{/* 待完成:显示添加按钮 */}
|
||||
{status === 'pending' && onAdd && (
|
||||
<button
|
||||
className="add-btn"
|
||||
onClick={onAdd}
|
||||
title="手动添加"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
{/* 状态图标 */}
|
||||
{status === 'completed' && <Check size={14} className="check-icon" />}
|
||||
{status === 'current' && <Loader2 size={14} className="loader-icon animate-spin" />}
|
||||
{status === 'pending' && <Circle size={14} className="pending-icon" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +85,75 @@ export const StageCard: React.FC<StageCardProps> = ({ stage, index, onEdit }) =>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 可折叠内容包装器
|
||||
*/
|
||||
interface CollapsibleContentProps {
|
||||
children: React.ReactNode;
|
||||
/** 预览高度(像素) */
|
||||
previewHeight?: number;
|
||||
/** 默认是否展开 */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
const CollapsibleContent: React.FC<CollapsibleContentProps> = ({
|
||||
children,
|
||||
previewHeight = 80,
|
||||
defaultExpanded = false
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [needsExpand, setNeedsExpand] = useState(false);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 检查内容是否需要折叠
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const contentHeight = contentRef.current.scrollHeight;
|
||||
setNeedsExpand(contentHeight > previewHeight + 20); // 20px 缓冲
|
||||
}
|
||||
}, [children, previewHeight]);
|
||||
|
||||
return (
|
||||
<div className="collapsible-wrapper">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`collapsible-content ${isExpanded ? 'expanded' : 'collapsed'}`}
|
||||
style={{
|
||||
maxHeight: isExpanded ? 'none' : `${previewHeight}px`,
|
||||
overflow: isExpanded ? 'visible' : 'hidden'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 渐变遮罩(折叠时显示) */}
|
||||
{!isExpanded && needsExpand && (
|
||||
<div className="collapse-gradient" />
|
||||
)}
|
||||
|
||||
{/* 展开/收起按钮 */}
|
||||
{needsExpand && (
|
||||
<button
|
||||
className="expand-toggle-btn"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp size={14} />
|
||||
<span>收起</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} />
|
||||
<span>展开全部</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染不同阶段的数据
|
||||
*/
|
||||
@@ -73,28 +161,47 @@ const StageDataRenderer: React.FC<{
|
||||
stageCode: string;
|
||||
data: ScientificQuestionData | PICOData | StudyDesignData | SampleSizeData | EndpointsData;
|
||||
}> = ({ stageCode, data }) => {
|
||||
switch (stageCode) {
|
||||
case 'scientific_question':
|
||||
return <ScientificQuestionCard data={data as ScientificQuestionData} />;
|
||||
case 'pico':
|
||||
return <PICOCard data={data as PICOData} />;
|
||||
case 'study_design':
|
||||
return <StudyDesignCard data={data as StudyDesignData} />;
|
||||
case 'sample_size':
|
||||
return <SampleSizeCard data={data as SampleSizeData} />;
|
||||
case 'endpoints':
|
||||
return <EndpointsCard data={data as EndpointsData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// 根据阶段类型设置不同的预览高度
|
||||
const previewHeights: Record<string, number> = {
|
||||
scientific_question: 60, // 约3行
|
||||
pico: 120, // 约4个元素的预览
|
||||
study_design: 80, // 标签 + 部分特征
|
||||
sample_size: 100, // 结果 + 部分参数
|
||||
endpoints: 120, // 部分指标
|
||||
};
|
||||
|
||||
const previewHeight = previewHeights[stageCode] || 80;
|
||||
|
||||
const renderContent = () => {
|
||||
switch (stageCode) {
|
||||
case 'scientific_question':
|
||||
return <ScientificQuestionCard data={data as ScientificQuestionData} />;
|
||||
case 'pico':
|
||||
return <PICOCard data={data as PICOData} />;
|
||||
case 'study_design':
|
||||
return <StudyDesignCard data={data as StudyDesignData} />;
|
||||
case 'sample_size':
|
||||
return <SampleSizeCard data={data as SampleSizeData} />;
|
||||
case 'endpoints':
|
||||
return <EndpointsCard data={data as EndpointsData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleContent previewHeight={previewHeight}>
|
||||
{renderContent()}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 科学问题卡片
|
||||
*/
|
||||
const ScientificQuestionCard: React.FC<{ data: ScientificQuestionData }> = ({ data }) => (
|
||||
<div className="stage-data">
|
||||
<p className="data-content">{data.content}</p>
|
||||
<div className="stage-data scientific-question-data">
|
||||
<p className="data-content full-content">{data.content || '暂无数据'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -105,19 +212,19 @@ const PICOCard: React.FC<{ data: PICOData }> = ({ data }) => (
|
||||
<div className="stage-data pico-data">
|
||||
<div className="pico-item">
|
||||
<span className="pico-label p">P</span>
|
||||
<span className="pico-value">{data.population}</span>
|
||||
<span className="pico-value full-value">{data.population || '暂无数据'}</span>
|
||||
</div>
|
||||
<div className="pico-item">
|
||||
<span className="pico-label i">I</span>
|
||||
<span className="pico-value">{data.intervention}</span>
|
||||
<span className="pico-value full-value">{data.intervention || '暂无数据'}</span>
|
||||
</div>
|
||||
<div className="pico-item">
|
||||
<span className="pico-label c">C</span>
|
||||
<span className="pico-value">{data.comparison}</span>
|
||||
<span className="pico-value full-value">{data.comparison || '暂无数据'}</span>
|
||||
</div>
|
||||
<div className="pico-item">
|
||||
<span className="pico-label o">O</span>
|
||||
<span className="pico-value">{data.outcome}</span>
|
||||
<span className="pico-value full-value">{data.outcome || '暂无数据'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -126,13 +233,26 @@ const PICOCard: React.FC<{ data: PICOData }> = ({ data }) => (
|
||||
* 研究设计卡片
|
||||
*/
|
||||
const StudyDesignCard: React.FC<{ data: StudyDesignData }> = ({ data }) => (
|
||||
<div className="stage-data">
|
||||
<div className="design-tags">
|
||||
<span className="design-tag">{data.studyType}</span>
|
||||
{data.design?.map((item, i) => (
|
||||
<span key={i} className="design-tag">{item}</span>
|
||||
))}
|
||||
<div className="stage-data study-design-data">
|
||||
<div className="design-main">
|
||||
<span className="design-type-tag">{data.studyType || '暂无数据'}</span>
|
||||
</div>
|
||||
{data.design && data.design.length > 0 && (
|
||||
<div className="design-features">
|
||||
<span className="features-label">设计特征:</span>
|
||||
<div className="design-tags">
|
||||
{data.design.map((item, i) => (
|
||||
<span key={i} className="design-tag">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.details && (
|
||||
<div className="design-details">
|
||||
<span className="details-label">详细说明:</span>
|
||||
<p className="details-text">{data.details}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -141,44 +261,115 @@ const StudyDesignCard: React.FC<{ data: StudyDesignData }> = ({ data }) => (
|
||||
*/
|
||||
const SampleSizeCard: React.FC<{ data: SampleSizeData }> = ({ data }) => (
|
||||
<div className="stage-data sample-size-data">
|
||||
<div className="sample-size-row">
|
||||
<span className="label">总样本量 (N)</span>
|
||||
<span className="value">N = {data.sampleSize}</span>
|
||||
{/* 结果高亮显示 */}
|
||||
<div className="sample-size-result">
|
||||
<span className="result-label">总样本量</span>
|
||||
<span className="result-value">N = {data.sampleSize || '待计算'}</span>
|
||||
</div>
|
||||
|
||||
{/* 计算参数 */}
|
||||
{data.calculation && (
|
||||
<div className="calculation-params">
|
||||
{data.calculation.alpha && (
|
||||
<div className="calculation-section">
|
||||
<div className="section-title">📊 计算参数</div>
|
||||
<div className="params-grid">
|
||||
<div className="param-item">
|
||||
<span>α = {data.calculation.alpha}</span>
|
||||
<span className="param-label">显著性水平</span>
|
||||
<span className="param-value">α = {data.calculation.alpha || 0.05}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.calculation.power && (
|
||||
<div className="param-item">
|
||||
<span>Power = {data.calculation.power}</span>
|
||||
<span className="param-label">检验效能</span>
|
||||
<span className="param-value">1-β = {data.calculation.power || 0.8}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.calculation.effectSize && (
|
||||
<div className="param-item full-width">
|
||||
<span className="param-label">效应量</span>
|
||||
<span className="param-value">{data.calculation.effectSize}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 计算依据 */}
|
||||
{data.rationale && (
|
||||
<div className="calculation-section">
|
||||
<div className="section-title">📝 计算依据</div>
|
||||
<p className="rationale-text full-text">{data.rationale}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失访率 */}
|
||||
{data.dropoutRate && (
|
||||
<div className="calculation-section">
|
||||
<div className="section-title">📉 失访调整</div>
|
||||
<p className="dropout-text">{data.dropoutRate}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分组信息 */}
|
||||
{data.groups && data.groups.length > 0 && (
|
||||
<div className="calculation-section">
|
||||
<div className="section-title">👥 分组安排</div>
|
||||
<div className="groups-list">
|
||||
{data.groups.map((group, i) => (
|
||||
<div key={i} className="group-item">
|
||||
<span className="group-name">{group.name}:</span>
|
||||
<span className="group-size">{group.size}例</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 安全地将值转换为数组
|
||||
*/
|
||||
const toArray = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) return [value];
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 观察指标卡片
|
||||
*/
|
||||
const EndpointsCard: React.FC<{ data: EndpointsData }> = ({ data }) => (
|
||||
<div className="stage-data endpoints-data">
|
||||
const EndpointsCard: React.FC<{ data: EndpointsData }> = ({ data }) => {
|
||||
// 安全处理数据,确保都是数组
|
||||
const primaryOutcomes = toArray(data.outcomes?.primary);
|
||||
const secondaryOutcomes = toArray(data.outcomes?.secondary);
|
||||
const safetyOutcomes = toArray(data.outcomes?.safety);
|
||||
const confounders = toArray(data.confounders);
|
||||
const demographics = toArray(data.baseline?.demographics);
|
||||
const clinicalHistory = toArray(data.baseline?.clinicalHistory);
|
||||
|
||||
return (
|
||||
<div className="stage-data endpoints-data detailed">
|
||||
{/* 基线指标 */}
|
||||
{data.baseline && Object.values(data.baseline).some(arr => arr && arr.length > 0) && (
|
||||
{(demographics.length > 0 || clinicalHistory.length > 0) && (
|
||||
<div className="endpoint-section">
|
||||
<div className="endpoint-title">📊 基线指标</div>
|
||||
<div className="endpoint-tags">
|
||||
{data.baseline.demographics?.map((item, i) => (
|
||||
<span key={i} className="endpoint-tag">{item}</span>
|
||||
))}
|
||||
{data.baseline.clinicalHistory?.map((item, i) => (
|
||||
<span key={i} className="endpoint-tag">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
{demographics.length > 0 && (
|
||||
<div className="endpoint-subsection">
|
||||
<span className="subsection-label">人口学特征:</span>
|
||||
<div className="endpoint-list">
|
||||
{demographics.map((item, i) => (
|
||||
<div key={`demo-${i}`} className="endpoint-item-full">{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{clinicalHistory.length > 0 && (
|
||||
<div className="endpoint-subsection">
|
||||
<span className="subsection-label">临床病史:</span>
|
||||
<div className="endpoint-list">
|
||||
{clinicalHistory.map((item, i) => (
|
||||
<div key={`hist-${i}`} className="endpoint-item-full">{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -186,64 +377,101 @@ const EndpointsCard: React.FC<{ data: EndpointsData }> = ({ data }) => (
|
||||
{data.exposure && (
|
||||
<div className="endpoint-section">
|
||||
<div className="endpoint-title">💊 暴露指标</div>
|
||||
{data.exposure.name && (
|
||||
<div className="endpoint-detail-item">
|
||||
<span className="detail-label">暴露名称:</span>
|
||||
<span className="detail-value">{data.exposure.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.exposure.definition && (
|
||||
<div className="endpoint-detail-item">
|
||||
<span className="detail-label">定义:</span>
|
||||
<span className="detail-value">{data.exposure.definition}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.exposure.measurement && (
|
||||
<div className="endpoint-detail-item">
|
||||
<span className="detail-label">测量方法:</span>
|
||||
<span className="detail-value">{data.exposure.measurement}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.exposure.timing && (
|
||||
<div className="endpoint-detail-item">
|
||||
<span className="detail-label">测量时点:</span>
|
||||
<span className="detail-value">{data.exposure.timing}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.exposure.intervention && (
|
||||
<div className="endpoint-item">
|
||||
<span className="item-label">干预:</span>
|
||||
<span className="item-value">{data.exposure.intervention}</span>
|
||||
<div className="endpoint-detail-item">
|
||||
<span className="detail-label">干预组:</span>
|
||||
<span className="detail-value">{data.exposure.intervention}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.exposure.control && (
|
||||
<div className="endpoint-item">
|
||||
<span className="item-label">对照:</span>
|
||||
<span className="item-value">{data.exposure.control}</span>
|
||||
<div className="endpoint-detail-item">
|
||||
<span className="detail-label">对照组:</span>
|
||||
<span className="detail-value">{data.exposure.control}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结局指标 */}
|
||||
{data.outcomes && (
|
||||
{(primaryOutcomes.length > 0 || secondaryOutcomes.length > 0 || safetyOutcomes.length > 0) && (
|
||||
<div className="endpoint-section">
|
||||
<div className="endpoint-title">🎯 结局指标</div>
|
||||
{data.outcomes.primary && data.outcomes.primary.length > 0 && (
|
||||
{primaryOutcomes.length > 0 && (
|
||||
<div className="endpoint-subsection">
|
||||
<span className="subsection-label">主要:</span>
|
||||
{data.outcomes.primary.map((item, i) => (
|
||||
<span key={i} className="endpoint-tag primary">{item}</span>
|
||||
))}
|
||||
<span className="subsection-label primary-label">主要结局:</span>
|
||||
<div className="endpoint-list">
|
||||
{primaryOutcomes.map((item, i) => (
|
||||
<div key={i} className="endpoint-item-full primary-item">• {item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.outcomes.secondary && data.outcomes.secondary.length > 0 && (
|
||||
{secondaryOutcomes.length > 0 && (
|
||||
<div className="endpoint-subsection">
|
||||
<span className="subsection-label">次要:</span>
|
||||
{data.outcomes.secondary.map((item, i) => (
|
||||
<span key={i} className="endpoint-tag">{item}</span>
|
||||
))}
|
||||
<span className="subsection-label">次要结局:</span>
|
||||
<div className="endpoint-list">
|
||||
{secondaryOutcomes.map((item, i) => (
|
||||
<div key={i} className="endpoint-item-full">• {item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.outcomes.safety && data.outcomes.safety.length > 0 && (
|
||||
{safetyOutcomes.length > 0 && (
|
||||
<div className="endpoint-subsection">
|
||||
<span className="subsection-label">安全:</span>
|
||||
{data.outcomes.safety.map((item, i) => (
|
||||
<span key={i} className="endpoint-tag safety">{item}</span>
|
||||
))}
|
||||
<span className="subsection-label safety-label">安全性指标:</span>
|
||||
<div className="endpoint-list">
|
||||
{safetyOutcomes.map((item, i) => (
|
||||
<div key={i} className="endpoint-item-full safety-item">• {item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 混杂因素 */}
|
||||
{data.confounders && data.confounders.length > 0 && (
|
||||
{confounders.length > 0 && (
|
||||
<div className="endpoint-section">
|
||||
<div className="endpoint-title">⚠️ 混杂因素</div>
|
||||
<div className="endpoint-tags">
|
||||
{data.confounders.map((item, i) => (
|
||||
<span key={i} className="endpoint-tag confounder">{item}</span>
|
||||
<div className="endpoint-list">
|
||||
{confounders.map((item, i) => (
|
||||
<div key={i} className="endpoint-item-full confounder-item">• {item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 随访方案 */}
|
||||
{data.followUp && (
|
||||
<div className="endpoint-section">
|
||||
<div className="endpoint-title">📅 随访方案</div>
|
||||
<p className="followup-text">{data.followUp}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ interface StageEditModalProps {
|
||||
stage: StageInfo;
|
||||
onSave: (stageCode: string, data: Record<string, unknown>) => Promise<void>;
|
||||
onClose: () => void;
|
||||
/** 是否为添加模式(而非编辑模式) */
|
||||
isAdding?: boolean;
|
||||
}
|
||||
|
||||
const STAGE_TITLES: Record<string, string> = {
|
||||
@@ -22,7 +24,7 @@ const STAGE_TITLES: Record<string, string> = {
|
||||
endpoints: '观察指标',
|
||||
};
|
||||
|
||||
export const StageEditModal: React.FC<StageEditModalProps> = ({ stage, onSave, onClose }) => {
|
||||
export const StageEditModal: React.FC<StageEditModalProps> = ({ stage, onSave, onClose, isAdding = false }) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -239,7 +241,7 @@ export const StageEditModal: React.FC<StageEditModalProps> = ({ stage, onSave, o
|
||||
<div className="stage-edit-modal-overlay" onClick={onClose}>
|
||||
<div className="stage-edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>编辑 {STAGE_TITLES[stage.stageCode] || stage.stageName}</h3>
|
||||
<h3>{isAdding ? '添加' : '编辑'} {STAGE_TITLES[stage.stageCode] || stage.stageName}</h3>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -264,3 +266,4 @@ export const StageEditModal: React.FC<StageEditModalProps> = ({ stage, onSave, o
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,33 +1,97 @@
|
||||
/**
|
||||
* State Panel - 方案状态面板
|
||||
*
|
||||
* 100%还原原型图右侧状态面板设计
|
||||
* 支持编辑已同步的数据
|
||||
* 研究摘要视图:
|
||||
* - 显示 5 个阶段的数据卡片
|
||||
* - 已完成阶段:可编辑
|
||||
* - 未完成阶段:可手动添加
|
||||
* - 一键生成按钮
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { FileText, Check, Loader2 } from 'lucide-react';
|
||||
import type { ProtocolContext, StageInfo } from '../types';
|
||||
import { FileText, Loader2 } from 'lucide-react';
|
||||
import type { ProtocolContext, StageInfo, ProtocolStageCode } from '../types';
|
||||
import { StageCard } from './StageCard';
|
||||
import { StageEditModal } from './StageEditModal';
|
||||
|
||||
// 阶段名称映射
|
||||
const STAGE_NAMES: Record<string, string> = {
|
||||
scientific_question: '科学问题',
|
||||
pico: 'PICO',
|
||||
study_design: '研究设计',
|
||||
sample_size: '样本量',
|
||||
endpoints: '观察指标',
|
||||
};
|
||||
|
||||
// 获取空数据模板(用于手动添加)
|
||||
function getEmptyDataForStage(stageCode: ProtocolStageCode): Record<string, unknown> {
|
||||
switch (stageCode) {
|
||||
case 'scientific_question':
|
||||
return { content: '' };
|
||||
case 'pico':
|
||||
return { population: '', intervention: '', comparison: '', outcome: '' };
|
||||
case 'study_design':
|
||||
return { studyType: '', design: [] };
|
||||
case 'sample_size':
|
||||
return { sampleSize: 0, calculation: { alpha: 0.05, power: 0.8 } };
|
||||
case 'endpoints':
|
||||
return { outcomes: { primary: [], secondary: [], safety: [] }, confounders: [] };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface StatePanelProps {
|
||||
context: ProtocolContext | null;
|
||||
onStageUpdate?: (stageCode: string, data: Record<string, unknown>) => Promise<void>;
|
||||
/** 触发一键生成 */
|
||||
onStartGeneration?: () => void;
|
||||
/** 是否可以生成(必填项校验通过) */
|
||||
canGenerate?: boolean;
|
||||
/** 是否正在生成中 */
|
||||
isGenerating?: boolean;
|
||||
}
|
||||
|
||||
export const StatePanel: React.FC<StatePanelProps> = ({ context, onStageUpdate }) => {
|
||||
export const StatePanel: React.FC<StatePanelProps> = ({
|
||||
context,
|
||||
onStageUpdate,
|
||||
onStartGeneration,
|
||||
canGenerate: canGenerateProp,
|
||||
isGenerating = false,
|
||||
}) => {
|
||||
const [editingStage, setEditingStage] = useState<StageInfo | null>(null);
|
||||
// 用于手动添加数据时创建空的 stage
|
||||
const [addingStageCode, setAddingStageCode] = useState<ProtocolStageCode | null>(null);
|
||||
|
||||
// 编辑已完成的阶段
|
||||
const handleEdit = (stage: StageInfo) => {
|
||||
setEditingStage(stage);
|
||||
};
|
||||
|
||||
// 手动添加数据(未完成阶段)
|
||||
const handleAdd = (stageCode: ProtocolStageCode) => {
|
||||
// 创建一个空的 stage 对象用于编辑
|
||||
const emptyStage: StageInfo = {
|
||||
stageCode,
|
||||
stageName: STAGE_NAMES[stageCode] || stageCode,
|
||||
status: 'pending',
|
||||
data: getEmptyDataForStage(stageCode),
|
||||
};
|
||||
setEditingStage(emptyStage);
|
||||
setAddingStageCode(stageCode);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (stageCode: string, newData: Record<string, unknown>) => {
|
||||
if (onStageUpdate) {
|
||||
await onStageUpdate(stageCode, newData);
|
||||
}
|
||||
setEditingStage(null);
|
||||
setAddingStageCode(null);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setEditingStage(null);
|
||||
setAddingStageCode(null);
|
||||
};
|
||||
if (!context) {
|
||||
return (
|
||||
@@ -50,6 +114,9 @@ export const StatePanel: React.FC<StatePanelProps> = ({ context, onStageUpdate }
|
||||
}
|
||||
|
||||
const { stages, progress, canGenerate } = context;
|
||||
|
||||
// 使用 prop 或 context 的 canGenerate
|
||||
const effectiveCanGenerate = canGenerateProp ?? canGenerate;
|
||||
|
||||
return (
|
||||
<aside className="state-panel">
|
||||
@@ -83,30 +150,45 @@ export const StatePanel: React.FC<StatePanelProps> = ({ context, onStageUpdate }
|
||||
stage={stage}
|
||||
index={index}
|
||||
onEdit={stage.status === 'completed' ? () => handleEdit(stage) : undefined}
|
||||
onAdd={stage.status === 'pending' ? () => handleAdd(stage.stageCode) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{/* 编辑/添加弹窗 */}
|
||||
{editingStage && (
|
||||
<StageEditModal
|
||||
stage={editingStage}
|
||||
onSave={handleSaveEdit}
|
||||
onClose={() => setEditingStage(null)}
|
||||
onClose={handleCloseModal}
|
||||
isAdding={addingStageCode !== null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 一键生成按钮 */}
|
||||
{canGenerate && (
|
||||
<div className="generate-section">
|
||||
<button className="generate-btn">
|
||||
<span className="generate-icon">🚀</span>
|
||||
<span className="generate-text">一键生成研究方案</span>
|
||||
</button>
|
||||
<p className="generate-hint">
|
||||
基于5个核心要素生成完整方案
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="generate-section">
|
||||
<button
|
||||
className={`generate-btn ${isGenerating ? 'generating' : ''} ${!effectiveCanGenerate ? 'disabled' : ''}`}
|
||||
onClick={onStartGeneration}
|
||||
disabled={!effectiveCanGenerate || isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 size={18} className="spinner" />
|
||||
<span className="generate-text">正在生成...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="generate-icon">✨</span>
|
||||
<span className="generate-text">一键生成研究方案</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="generate-hint">
|
||||
{effectiveCanGenerate
|
||||
? '已完成 4/5 必填项,可生成完整方案'
|
||||
: `已完成 ${progress}%,请补充必填要素`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -48,3 +48,4 @@ export const SyncButton: React.FC<SyncButtonProps> = ({ syncData, onSync }) => {
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* ViewSwitcher - 视图切换组件
|
||||
*
|
||||
* 在 Header 区域显示,切换 "研究摘要" / "完整方案" 视图
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Database, FileText } from 'lucide-react';
|
||||
|
||||
export type ViewMode = 'context' | 'document';
|
||||
|
||||
interface ViewSwitcherProps {
|
||||
/** 当前视图模式 */
|
||||
currentView: ViewMode;
|
||||
/** 视图切换回调 */
|
||||
onViewChange: (view: ViewMode) => void;
|
||||
/** 是否禁用文档视图(未生成方案时) */
|
||||
documentDisabled?: boolean;
|
||||
/** 禁用提示 */
|
||||
documentDisabledTooltip?: string;
|
||||
}
|
||||
|
||||
export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
|
||||
currentView,
|
||||
onViewChange,
|
||||
documentDisabled = false,
|
||||
documentDisabledTooltip = '请先生成研究方案',
|
||||
}) => {
|
||||
return (
|
||||
<div className="view-switcher">
|
||||
<button
|
||||
className={`view-switch-btn ${currentView === 'context' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('context')}
|
||||
>
|
||||
<Database size={14} />
|
||||
<span>研究摘要</span>
|
||||
</button>
|
||||
<button
|
||||
className={`view-switch-btn ${currentView === 'document' ? 'active' : ''} ${documentDisabled ? 'disabled' : ''}`}
|
||||
onClick={() => !documentDisabled && onViewChange('document')}
|
||||
title={documentDisabled ? documentDisabledTooltip : '查看完整方案'}
|
||||
disabled={documentDisabled}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>完整方案</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewSwitcher;
|
||||
|
||||
@@ -10,3 +10,4 @@ export { ActionCardComponent } from './ActionCard';
|
||||
export { ReflexionMessage } from './ReflexionMessage';
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,3 +6,4 @@ export { useProtocolContext } from './useProtocolContext';
|
||||
export { useProtocolConversations } from './useProtocolConversations';
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,13 +15,14 @@ export function useProtocolConversations(initialConversationId?: string) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
* 获取会话列表(带 agentId 过滤)
|
||||
*/
|
||||
const fetchConversations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await fetch(`${API_BASE}/conversations`, {
|
||||
// 使用 agentId 参数过滤,与 ChatWorkspace 保持一致
|
||||
const response = await fetch(`${API_BASE}/conversations?agentId=PROTOCOL_AGENT`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
@@ -32,13 +33,17 @@ export function useProtocolConversations(initialConversationId?: string) {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const allConversations = result.data || result;
|
||||
// 后端返回格式:{ data: { conversations: [...] } }
|
||||
const conversationsList = result.data?.conversations || result.data || [];
|
||||
|
||||
// 过滤出Protocol Agent的对话(agentId为PROTOCOL_AGENT)
|
||||
const protocolConversations = Array.isArray(allConversations)
|
||||
? allConversations.filter((conv: any) =>
|
||||
conv.agentId === 'PROTOCOL_AGENT' || conv.agent_id === 'PROTOCOL_AGENT'
|
||||
)
|
||||
const protocolConversations = Array.isArray(conversationsList)
|
||||
? conversationsList.map((conv: any) => ({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
agentId: conv.agentId,
|
||||
createdAt: conv.createdAt,
|
||||
updatedAt: conv.updatedAt,
|
||||
}))
|
||||
: [];
|
||||
|
||||
setConversations(protocolConversations);
|
||||
@@ -96,12 +101,24 @@ export function useProtocolConversations(initialConversationId?: string) {
|
||||
* 选择对话
|
||||
*/
|
||||
const selectConversation = useCallback((id: string) => {
|
||||
// 如果是 "new",表示新对话状态,清空当前对话
|
||||
if (id === 'new') {
|
||||
setCurrentConversation(null);
|
||||
return;
|
||||
}
|
||||
const conv = conversations.find(c => c.id === id);
|
||||
if (conv) {
|
||||
setCurrentConversation(conv);
|
||||
}
|
||||
}, [conversations]);
|
||||
|
||||
/**
|
||||
* 清空当前对话(用于新建对话)
|
||||
*/
|
||||
const clearCurrentConversation = useCallback(() => {
|
||||
setCurrentConversation(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*/
|
||||
@@ -125,6 +142,35 @@ export function useProtocolConversations(initialConversationId?: string) {
|
||||
}
|
||||
}, [currentConversation]);
|
||||
|
||||
/**
|
||||
* 更新对话标题
|
||||
*/
|
||||
const updateConversationTitle = useCallback(async (id: string, title: string) => {
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await fetch(`${API_BASE}/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 更新本地状态
|
||||
setConversations(prev =>
|
||||
prev.map(c => c.id === id ? { ...c, title } : c)
|
||||
);
|
||||
if (currentConversation?.id === id) {
|
||||
setCurrentConversation(prev => prev ? { ...prev, title } : null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useProtocolConversations] updateConversationTitle error:', err);
|
||||
}
|
||||
}, [currentConversation]);
|
||||
|
||||
// 初次加载
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
@@ -137,8 +183,11 @@ export function useProtocolConversations(initialConversationId?: string) {
|
||||
createConversation,
|
||||
selectConversation,
|
||||
deleteConversation,
|
||||
updateConversationTitle,
|
||||
clearCurrentConversation,
|
||||
refreshConversations: fetchConversations,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* useProtocolGeneration - 研究方案生成状态管理
|
||||
*
|
||||
* 管理:
|
||||
* - 生成状态(idle/generating/completed)
|
||||
* - 生成内容(流式 Markdown)
|
||||
* - 当前章节
|
||||
* - 视图模式切换
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getAccessToken } from '../../../../framework/auth/api';
|
||||
import type { ViewMode } from '../components/ViewSwitcher';
|
||||
|
||||
interface UseProtocolGenerationOptions {
|
||||
conversationId?: string;
|
||||
onViewModeChange?: (mode: ViewMode) => void;
|
||||
}
|
||||
|
||||
interface GenerationState {
|
||||
status: 'idle' | 'generating' | 'completed' | 'error';
|
||||
content: string;
|
||||
currentSection: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const useProtocolGeneration = (options: UseProtocolGenerationOptions) => {
|
||||
const { conversationId, onViewModeChange } = options;
|
||||
|
||||
const [state, setState] = useState<GenerationState>({
|
||||
status: 'idle',
|
||||
content: '',
|
||||
currentSection: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('context');
|
||||
|
||||
// 切换视图模式
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
onViewModeChange?.(mode);
|
||||
}, [onViewModeChange]);
|
||||
|
||||
// 开始生成方案
|
||||
const startGeneration = useCallback(async () => {
|
||||
if (!conversationId) {
|
||||
console.error('No conversationId provided');
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
status: 'generating',
|
||||
content: '',
|
||||
currentSection: '研究标题',
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 自动切换到文档视图
|
||||
handleViewModeChange('document');
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await fetch('/api/v1/aia/protocol-agent/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId,
|
||||
options: {
|
||||
style: 'academic',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 尝试解析错误信息
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = errorData.error || `生成失败: ${response.status}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 流式读取
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 解析 SSE 格式
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // 保留未完成的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
|
||||
// SSE 结束标识
|
||||
if (data === '[DONE]') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'completed',
|
||||
currentSection: null,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过空数据
|
||||
if (!data) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// OpenAI Compatible 格式: choices[0].delta.content
|
||||
const deltaContent = parsed.choices?.[0]?.delta?.content;
|
||||
|
||||
if (deltaContent) {
|
||||
fullContent += deltaContent;
|
||||
|
||||
// 检测当前章节(## 1. 研究背景)
|
||||
const sectionMatches = fullContent.match(/## (\d+)\. ([^\n]+)/g);
|
||||
let currentSectionTitle = null;
|
||||
if (sectionMatches && sectionMatches.length > 0) {
|
||||
const lastMatch = sectionMatches[sectionMatches.length - 1];
|
||||
currentSectionTitle = lastMatch.replace(/## \d+\. /, '').trim();
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
content: fullContent,
|
||||
currentSection: currentSectionTitle,
|
||||
}));
|
||||
}
|
||||
|
||||
// 检查是否完成
|
||||
const finishReason = parsed.choices?.[0]?.finish_reason;
|
||||
if (finishReason === 'stop') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'completed',
|
||||
currentSection: null,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 解析失败,记录日志但不中断
|
||||
console.warn('SSE 数据解析失败:', data, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保最终状态
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'completed',
|
||||
content: fullContent,
|
||||
currentSection: null,
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('生成方案失败:', error);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
}));
|
||||
}
|
||||
}, [conversationId, handleViewModeChange]);
|
||||
|
||||
// 重置状态
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
status: 'idle',
|
||||
content: '',
|
||||
currentSection: null,
|
||||
error: null,
|
||||
});
|
||||
handleViewModeChange('context');
|
||||
}, [handleViewModeChange]);
|
||||
|
||||
// 导出 Word(使用实际生成的内容)
|
||||
const exportWord = useCallback(async () => {
|
||||
if (!state.content) {
|
||||
console.error('无法导出:尚未生成内容');
|
||||
alert('请先生成研究方案');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
// 直接发送生成的 Markdown 内容到转换服务
|
||||
const response = await fetch('/api/v1/aia/protocol-agent/export/docx', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId,
|
||||
// 关键:发送实际生成的内容
|
||||
content: state.content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || '导出失败');
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `研究方案_${new Date().toISOString().slice(0, 10)}.docx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('导出 Word 失败:', error);
|
||||
alert(`导出失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
throw error;
|
||||
}
|
||||
}, [conversationId, state.content]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
generationStatus: state.status,
|
||||
generatedContent: state.content,
|
||||
currentSection: state.currentSection,
|
||||
generationError: state.error,
|
||||
viewMode,
|
||||
|
||||
// 计算属性
|
||||
isGenerating: state.status === 'generating',
|
||||
hasGeneratedContent: state.content.length > 0,
|
||||
|
||||
// 方法
|
||||
startGeneration,
|
||||
reset,
|
||||
exportWord,
|
||||
setViewMode: handleViewModeChange,
|
||||
};
|
||||
};
|
||||
|
||||
export default useProtocolGeneration;
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './components';
|
||||
export * from './hooks';
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,8 @@ export interface StudyDesignData {
|
||||
studyType: string;
|
||||
design: string[];
|
||||
features?: string[];
|
||||
/** 详细说明 */
|
||||
details?: string;
|
||||
confirmed?: boolean;
|
||||
confirmedAt?: string;
|
||||
}
|
||||
@@ -53,9 +55,18 @@ export interface SampleSizeData {
|
||||
calculation?: {
|
||||
alpha?: number;
|
||||
power?: number;
|
||||
effectSize?: number;
|
||||
effectSize?: string | number;
|
||||
dropoutRate?: number;
|
||||
};
|
||||
/** 计算依据/推导过程 */
|
||||
rationale?: string;
|
||||
/** 失访率及调整说明 */
|
||||
dropoutRate?: string;
|
||||
/** 分组信息 */
|
||||
groups?: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
}>;
|
||||
confirmed?: boolean;
|
||||
confirmedAt?: string;
|
||||
}
|
||||
@@ -70,6 +81,14 @@ export interface EndpointsData {
|
||||
laboratoryTests?: string[];
|
||||
};
|
||||
exposure?: {
|
||||
/** 暴露名称 */
|
||||
name?: string;
|
||||
/** 暴露定义 */
|
||||
definition?: string;
|
||||
/** 测量方法 */
|
||||
measurement?: string;
|
||||
/** 测量时点 */
|
||||
timing?: string;
|
||||
intervention?: string;
|
||||
control?: string;
|
||||
dosage?: string;
|
||||
@@ -81,6 +100,8 @@ export interface EndpointsData {
|
||||
safety?: string[];
|
||||
};
|
||||
confounders?: string[];
|
||||
/** 随访方案 */
|
||||
followUp?: string;
|
||||
confirmed?: boolean;
|
||||
confirmedAt?: string;
|
||||
}
|
||||
|
||||
@@ -580,5 +580,6 @@ export default FulltextDetailDrawer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -173,5 +173,6 @@ export const useAssets = (activeTab: AssetTabType) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -163,5 +163,6 @@ export const useRecentTasks = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -133,5 +133,6 @@ export function useSessionStatus({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -125,5 +125,6 @@ export interface DataStats {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -121,5 +121,6 @@ export type AssetTabType = 'all' | 'processed' | 'raw';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -308,5 +308,6 @@ export default KnowledgePage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -63,5 +63,6 @@ export interface BatchTemplate {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -141,5 +141,6 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -61,5 +61,6 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -84,5 +84,6 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -74,5 +74,6 @@ export default function Header({ onUpload }: HeaderProps) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -128,5 +128,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -56,5 +56,6 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -91,5 +91,6 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -175,16 +175,17 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
children.push(
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
columnWidths: [2000, 7000], // 固定列宽:第一列2000twips(约3.5cm),第二列7000twips(约12cm)
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '文件名', bold: true })] })],
|
||||
width: { size: 25, type: WidthType.PERCENTAGE },
|
||||
width: { size: 2000, type: WidthType.DXA },
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(report.fileName)],
|
||||
width: { size: 75, type: WidthType.PERCENTAGE },
|
||||
width: { size: 7000, type: WidthType.DXA },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -192,9 +193,11 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '综合评分', bold: true })] })],
|
||||
width: { size: 2000, type: WidthType.DXA },
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(`${report.overallScore || '-'} 分`)],
|
||||
width: { size: 7000, type: WidthType.DXA },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -202,6 +205,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '审查用时', bold: true })] })],
|
||||
width: { size: 2000, type: WidthType.DXA },
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(
|
||||
@@ -209,6 +213,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
? `${Math.floor(report.durationSeconds / 60)}分${report.durationSeconds % 60}秒`
|
||||
: '-'
|
||||
)],
|
||||
width: { size: 7000, type: WidthType.DXA },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -216,9 +221,11 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: '审查时间', bold: true })] })],
|
||||
width: { size: 2000, type: WidthType.DXA },
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(report.completedAt ? new Date(report.completedAt).toLocaleString('zh-CN') : '-')],
|
||||
width: { size: 7000, type: WidthType.DXA },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -74,59 +74,6 @@ export default function TaskTable({
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染结果摘要
|
||||
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) => {
|
||||
// 待审稿:[开始审稿] [删除]
|
||||
@@ -229,10 +176,10 @@ export default function TaskTable({
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<table className="w-full text-left text-sm table-fixed">
|
||||
<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">
|
||||
<th className="px-3 py-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
@@ -240,11 +187,10 @@ export default function TaskTable({
|
||||
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>
|
||||
<th className="px-3 py-4">文件名称 / 信息</th>
|
||||
<th className="px-3 py-4 w-16 text-center">上传<br/>时间</th>
|
||||
<th className="px-3 py-4 w-24 text-center">审稿维度</th>
|
||||
<th className="px-3 py-4 w-36 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
@@ -253,7 +199,7 @@ export default function TaskTable({
|
||||
key={task.id}
|
||||
className={`hover:bg-slate-50 group transition-colors ${selectedIds.includes(task.id) ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-3 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(task.id)}
|
||||
@@ -261,40 +207,55 @@ export default function TaskTable({
|
||||
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)}`}>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg border flex-shrink-0 ${getFileIconStyle(task.fileName)}`}>
|
||||
{getFileIcon(task.fileName)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
className={`font-bold text-slate-800 text-base mb-0.5 ${task.status === 'completed' ? 'cursor-pointer hover:text-indigo-600' : ''}`}
|
||||
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)}
|
||||
title={task.fileName}
|
||||
>
|
||||
{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>
|
||||
<div className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="bg-slate-100 px-1 rounded text-[10px]">{formatFileSize(task.fileSize)}</span>
|
||||
{task.wordCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{task.wordCount.toLocaleString()} 字</span>
|
||||
<span className="text-[10px]">{task.wordCount.toLocaleString()} 字</span>
|
||||
</>
|
||||
)}
|
||||
{/* 将结果摘要整合到这里 */}
|
||||
{task.status === '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'}`}>
|
||||
规范{task.editorialScore}分
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{task.status === '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'}`}>
|
||||
方法{task.methodologyScore}分
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-500 font-mono text-xs">
|
||||
<td className="px-3 py-4 text-slate-500 font-mono text-[10px] text-center whitespace-nowrap">
|
||||
{formatTime(task.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-3 py-4 text-center">
|
||||
{renderAgentTags(task)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{renderResultSummary(task)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<td className="px-3 py-4">
|
||||
{renderActions(task)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -33,5 +33,6 @@ export { default as TaskDetail } from './TaskDetail';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -302,5 +302,6 @@ export default function Dashboard() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -251,5 +251,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -353,4 +353,5 @@ export default TenantListPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -262,4 +262,5 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -482,3 +482,4 @@ export default AIStreamChat;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -182,3 +182,4 @@ export default ConversationList;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,3 +34,4 @@ export type {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -326,3 +326,4 @@ export default useAIStream;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -255,3 +255,4 @@ export default useConversations;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -290,3 +290,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -226,3 +226,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -163,3 +163,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -76,5 +76,6 @@ export { default as Placeholder } from './Placeholder';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
frontend-v2/src/vite-env.d.ts
vendored
1
frontend-v2/src/vite-env.d.ts
vendored
@@ -56,5 +56,6 @@ interface ImportMeta {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user