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:
2026-01-25 19:16:36 +08:00
parent 4d7d97ca19
commit 303dd78c54
332 changed files with 6204 additions and 617 deletions

View File

@@ -60,5 +60,6 @@ export default apiClient;

View File

@@ -259,5 +259,6 @@ export async function logout(): Promise<void> {

View File

@@ -25,5 +25,6 @@ export * from './api';

View File

@@ -50,4 +50,5 @@ export async function fetchUserModules(): Promise<string[]> {

View File

@@ -131,3 +131,4 @@ export default ModulePermissionModal;

View File

@@ -42,3 +42,4 @@ export default AdminModule;

View File

@@ -207,3 +207,4 @@ export const TENANT_TYPE_NAMES: Record<TenantType, string> = {

View File

@@ -21,3 +21,4 @@ export { ChatWorkspace } from './ChatWorkspace';

View File

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

View File

@@ -64,3 +64,4 @@ export const ActionCardComponent: React.FC<ActionCardProps> = ({ card }) => {
};

View File

@@ -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' && (

View File

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

View File

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

View File

@@ -50,3 +50,4 @@ function formatTime(date: Date): string {
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,3 +48,4 @@ export const SyncButton: React.FC<SyncButtonProps> = ({ syncData, onSync }) => {
};

View File

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

View File

@@ -10,3 +10,4 @@ export { ActionCardComponent } from './ActionCard';
export { ReflexionMessage } from './ReflexionMessage';

View File

@@ -6,3 +6,4 @@ export { useProtocolContext } from './useProtocolContext';
export { useProtocolConversations } from './useProtocolConversations';

View File

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

View File

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

View File

@@ -8,3 +8,4 @@ export * from './components';
export * from './hooks';

View File

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

View File

@@ -580,5 +580,6 @@ export default FulltextDetailDrawer;

View File

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

View File

@@ -163,5 +163,6 @@ export const useRecentTasks = () => {

View File

@@ -133,5 +133,6 @@ export function useSessionStatus({

View File

@@ -125,5 +125,6 @@ export interface DataStats {

View File

@@ -121,5 +121,6 @@ export type AssetTabType = 'all' | 'processed' | 'raw';

View File

@@ -308,5 +308,6 @@ export default KnowledgePage;

View File

@@ -63,5 +63,6 @@ export interface BatchTemplate {

View File

@@ -141,5 +141,6 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A

View File

@@ -61,5 +61,6 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti

View File

@@ -84,5 +84,6 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC

View File

@@ -74,5 +74,6 @@ export default function Header({ onUpload }: HeaderProps) {

View File

@@ -128,5 +128,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {

View File

@@ -56,5 +56,6 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }:

View File

@@ -91,5 +91,6 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }:

View File

@@ -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 },
}),
],
}),

View File

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

View File

@@ -33,5 +33,6 @@ export { default as TaskDetail } from './TaskDetail';

View File

@@ -302,5 +302,6 @@ export default function Dashboard() {

View File

@@ -251,5 +251,6 @@

View File

@@ -353,4 +353,5 @@ export default TenantListPage;

View File

@@ -262,4 +262,5 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {

View File

@@ -482,3 +482,4 @@ export default AIStreamChat;

View File

@@ -182,3 +182,4 @@ export default ConversationList;

View File

@@ -34,3 +34,4 @@ export type {

View File

@@ -326,3 +326,4 @@ export default useAIStream;

View File

@@ -255,3 +255,4 @@ export default useConversations;

View File

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

View File

@@ -56,5 +56,6 @@ interface ImportMeta {