feat(ssa): Complete V11 UI development and frontend-backend integration - Pixel-perfect V11 UI, multi-task support, Word export, input overlay fix, code cleanup. MVP Phase 1 core 95% complete.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
442
frontend-v2/src/modules/ssa/components/SSAChatPane.tsx
Normal file
442
frontend-v2/src/modules/ssa/components/SSAChatPane.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* SSAChatPane - V11 对话区
|
||||
*
|
||||
* 100% 还原 V11 原型图
|
||||
* - 顶部 Header(标题 + 返回按钮 + 状态指示)
|
||||
* - 居中对话列表
|
||||
* - 底部悬浮输入框
|
||||
*/
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
User,
|
||||
Paperclip,
|
||||
ArrowUp,
|
||||
FileSpreadsheet,
|
||||
X,
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import { useAnalysis } from '../hooks/useAnalysis';
|
||||
import type { SSAMessage } from '../types';
|
||||
import { TypeWriter } from './TypeWriter';
|
||||
|
||||
export const SSAChatPane: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
currentSession,
|
||||
messages,
|
||||
mountedFile,
|
||||
setMountedFile,
|
||||
setCurrentSession,
|
||||
setActivePane,
|
||||
setWorkspaceOpen,
|
||||
currentPlan,
|
||||
isLoading,
|
||||
isExecuting,
|
||||
error,
|
||||
setError,
|
||||
addToast,
|
||||
selectAnalysisRecord,
|
||||
} = useSSAStore();
|
||||
|
||||
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动到底部,确保最新内容可见
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTo({
|
||||
top: messagesContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 延迟滚动,确保 DOM 更新完成
|
||||
const timer = setTimeout(scrollToBottom, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, currentPlan, scrollToBottom]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploadStatus('uploading');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setUploadStatus('parsing');
|
||||
const result = await uploadData(file);
|
||||
|
||||
setCurrentSession({
|
||||
id: result.sessionId,
|
||||
title: file.name.replace(/\.(csv|xlsx|xls)$/i, ''),
|
||||
mode: 'analysis',
|
||||
status: 'active',
|
||||
dataSchema: {
|
||||
columns: result.schema.columns.map((c: any) => ({
|
||||
name: c.name,
|
||||
type: c.type as 'numeric' | 'categorical' | 'datetime' | 'text',
|
||||
uniqueValues: c.uniqueValues,
|
||||
nullCount: c.nullCount,
|
||||
})),
|
||||
rowCount: result.schema.rowCount,
|
||||
preview: [],
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
setMountedFile({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
rowCount: result.schema.rowCount,
|
||||
});
|
||||
setUploadStatus('success');
|
||||
addToast('数据读取成功,正在分析结构...', 'success');
|
||||
} catch (err: any) {
|
||||
setUploadStatus('error');
|
||||
const errorMsg = err?.message || '上传失败,请检查文件格式';
|
||||
setError(errorMsg);
|
||||
addToast(errorMsg, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setMountedFile(null);
|
||||
setWorkspaceOpen(false);
|
||||
setUploadStatus('idle');
|
||||
setError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
try {
|
||||
await generatePlan(inputValue);
|
||||
setInputValue('');
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || '生成计划失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// 打开工作区,可选择特定的分析记录
|
||||
const handleOpenWorkspace = useCallback((recordId?: string) => {
|
||||
if (recordId) {
|
||||
selectAnalysisRecord(recordId);
|
||||
} else {
|
||||
setWorkspaceOpen(true);
|
||||
setActivePane('sap');
|
||||
}
|
||||
}, [selectAnalysisRecord, setWorkspaceOpen, setActivePane]);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ssa-chat-pane">
|
||||
{/* Chat Header */}
|
||||
<header className="chat-header">
|
||||
<div className="chat-header-left">
|
||||
<button className="back-btn" onClick={handleBack}>
|
||||
<ArrowLeft size={16} />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
<span className="header-divider" />
|
||||
<span className="header-title">
|
||||
{currentSession?.title || '新的统计分析'}
|
||||
</span>
|
||||
</div>
|
||||
<EngineStatus
|
||||
isExecuting={isExecuting}
|
||||
isLoading={isLoading}
|
||||
isUploading={uploadStatus === 'uploading' || uploadStatus === 'parsing'}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<div className="chat-messages" ref={messagesContainerRef}>
|
||||
<div className="chat-messages-inner">
|
||||
{/* 欢迎语 */}
|
||||
<div className="message message-ai slide-up">
|
||||
<div className="message-avatar ai-avatar">
|
||||
<Bot size={12} />
|
||||
</div>
|
||||
<div className="message-bubble ai-bubble">
|
||||
你好!我是 SSA-Pro 智能统计助手。
|
||||
<br />
|
||||
你可以直接描述研究目标,我将为你生成分析方案;或者点击下方 📎 <b>上传数据文件</b>,我们将直接开始分析。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 动态消息 */}
|
||||
{messages.map((msg: SSAMessage, idx: number) => {
|
||||
const isLastAiMessage = msg.role === 'assistant' && idx === messages.length - 1;
|
||||
const showTypewriter = isLastAiMessage && !msg.artifactType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id || idx}
|
||||
className={`message ${msg.role === 'user' ? 'message-user' : 'message-ai'} slide-up`}
|
||||
style={{ animationDelay: `${idx * 0.1}s` }}
|
||||
>
|
||||
<div className={`message-avatar ${msg.role === 'user' ? 'user-avatar' : 'ai-avatar'}`}>
|
||||
{msg.role === 'user' ? <User size={12} /> : <Bot size={12} />}
|
||||
</div>
|
||||
<div className={`message-bubble ${msg.role === 'user' ? 'user-bubble' : 'ai-bubble'}`}>
|
||||
{showTypewriter ? (
|
||||
<TypeWriter content={msg.content} speed={15} />
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
|
||||
{/* SAP 卡片 */}
|
||||
{msg.artifactType === 'sap' && (
|
||||
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
|
||||
<div className="sap-card-left">
|
||||
<div className="sap-card-icon">
|
||||
<FileSignature size={16} />
|
||||
</div>
|
||||
<div className="sap-card-content">
|
||||
<div className="sap-card-title">查看分析计划 (SAP)</div>
|
||||
<div className="sap-card-hint">参数映射完成,等待执行</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="sap-card-arrow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* AI 正在思考指示器 */}
|
||||
{isLoading && (
|
||||
<div className="message message-ai slide-up">
|
||||
<div className="message-avatar ai-avatar">
|
||||
<Bot size={12} />
|
||||
</div>
|
||||
<div className="message-bubble ai-bubble thinking-bubble">
|
||||
<div className="thinking-dots">
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据挂载成功消息 */}
|
||||
{mountedFile && currentPlan && !messages.some((m: SSAMessage) => m.artifactType === 'sap') && (
|
||||
<div className="message message-ai slide-up">
|
||||
<div className="message-avatar ai-avatar">
|
||||
<Bot size={12} />
|
||||
</div>
|
||||
<div className="message-bubble ai-bubble">
|
||||
<div className="data-mounted-msg">
|
||||
<Zap size={14} className="text-amber-500" />
|
||||
<b>数据已挂载</b>。我已经为您规划好了统计分析计划书 (SAP)。
|
||||
</div>
|
||||
<button className="sap-card" onClick={() => handleOpenWorkspace()}>
|
||||
<div className="sap-card-left">
|
||||
<div className="sap-card-icon">
|
||||
<FileSignature size={16} />
|
||||
</div>
|
||||
<div className="sap-card-content">
|
||||
<div className="sap-card-title">查看分析计划 (SAP)</div>
|
||||
<div className="sap-card-hint">参数映射完成,等待执行</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="sap-card-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
|
||||
<div ref={chatEndRef} className="scroll-spacer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Input */}
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-container">
|
||||
<div className="input-box">
|
||||
{/* 上传进度条 */}
|
||||
{uploadStatus === 'uploading' && (
|
||||
<div className="upload-progress-zone pop-in">
|
||||
<div className="upload-progress-card">
|
||||
<Loader2 size={16} className="spin text-blue-500" />
|
||||
<div className="upload-progress-info">
|
||||
<span className="upload-progress-text">正在上传...</span>
|
||||
<div className="upload-progress-bar">
|
||||
<div
|
||||
className="upload-progress-fill"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 解析中状态 */}
|
||||
{uploadStatus === 'parsing' && (
|
||||
<div className="upload-progress-zone pop-in">
|
||||
<div className="upload-progress-card parsing">
|
||||
<Loader2 size={16} className="spin text-amber-500" />
|
||||
<span className="upload-progress-text">正在解析数据结构...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传错误 */}
|
||||
{uploadStatus === 'error' && error && (
|
||||
<div className="upload-error-zone pop-in">
|
||||
<div className="upload-error-card">
|
||||
<AlertCircle size={16} className="text-red-500" />
|
||||
<span className="upload-error-text">{error}</span>
|
||||
<button
|
||||
className="upload-retry-btn"
|
||||
onClick={() => {
|
||||
setUploadStatus('idle');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据挂载区 */}
|
||||
{mountedFile && uploadStatus !== 'error' && (
|
||||
<div className="data-mount-zone pop-in">
|
||||
<div className="mount-file-card">
|
||||
<div className="mount-file-icon">
|
||||
<FileSpreadsheet size={14} />
|
||||
</div>
|
||||
<div className="mount-file-info">
|
||||
<span className="mount-file-name">{mountedFile.name}</span>
|
||||
<span className="mount-file-meta">
|
||||
{mountedFile.rowCount} rows • {formatFileSize(mountedFile.size)}
|
||||
</span>
|
||||
</div>
|
||||
<CheckCircle size={14} className="text-green-500" />
|
||||
<div className="mount-divider" />
|
||||
<button className="mount-remove-btn" onClick={handleRemoveFile}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入行 */}
|
||||
<div className="input-row">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
className={`upload-btn ${mountedFile ? 'disabled' : ''}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!!mountedFile || isUploading}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
className="message-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="发送消息,或点击回形针 📎 上传数据触发分析..."
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="input-hint">
|
||||
AI 可能会犯错,请核实生成的统计结论。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface EngineStatusProps {
|
||||
isExecuting: boolean;
|
||||
isLoading: boolean;
|
||||
isUploading: boolean;
|
||||
}
|
||||
|
||||
const EngineStatus: React.FC<EngineStatusProps> = ({
|
||||
isExecuting,
|
||||
isLoading,
|
||||
isUploading
|
||||
}) => {
|
||||
const getStatus = () => {
|
||||
if (isExecuting) {
|
||||
return { text: 'R Engine Running...', className: 'status-running' };
|
||||
}
|
||||
if (isLoading) {
|
||||
return { text: 'AI Processing...', className: 'status-processing' };
|
||||
}
|
||||
if (isUploading) {
|
||||
return { text: 'Parsing Data...', className: 'status-uploading' };
|
||||
}
|
||||
return { text: 'R Engine Ready', className: 'status-ready' };
|
||||
};
|
||||
|
||||
const { text, className } = getStatus();
|
||||
|
||||
return (
|
||||
<div className={`engine-status ${className}`}>
|
||||
<span className="status-dot" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSAChatPane;
|
||||
Reference in New Issue
Block a user