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:
2026-02-20 14:46:45 +08:00
parent 49b5c37cb1
commit 8d496d1515
38 changed files with 7255 additions and 1074 deletions

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