/** * MessageItem Component * * Renders a single chat message with role-appropriate styling. * Displays a formatted timestamp and optional export record links. * When an export record is present, shows a clickable card to preview the document. * * Requirements: 5.4, 5.5 * * @module components/ChatPanel/MessageItem */ import React, { memo, useCallback, useState } from 'react'; import { Typography, Card, message as antdMessage, Spin } from 'antd'; import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons'; import { formatDate } from '../../utils/formatDate'; import type { ChatMessage } from '../../types/chat'; import { useDocumentStore } from '../../stores/documentStore'; import { useChatStore } from '../../stores/chatStore'; const { Text } = Typography; // ── Styles ────────────────────────────────────────────────────────────────── const messageRowStyle = (role: ChatMessage['role']): React.CSSProperties => ({ display: 'flex', flexDirection: 'column', alignItems: role === 'user' ? 'flex-end' : 'flex-start', gap: '4px', contentVisibility: 'auto', containIntrinsicSize: '0 96px', }); const bubbleStyle = (role: ChatMessage['role']): React.CSSProperties => ({ maxWidth: '80%', padding: '10px 14px', borderRadius: role === 'user' ? '16px 16px 4px 16px' : '16px 16px 16px 4px', backgroundColor: role === 'user' ? '#1677ff' : '#f0f0f0', color: role === 'user' ? '#ffffff' : 'rgba(0, 0, 0, 0.85)', wordBreak: 'break-word', whiteSpace: 'pre-wrap', lineHeight: '1.6', fontSize: '14px', }); const metaRowStyle = (role: ChatMessage['role']): React.CSSProperties => ({ display: 'flex', flexDirection: role === 'user' ? 'row-reverse' : 'row', alignItems: 'center', gap: '8px', }); const timestampStyle: React.CSSProperties = { fontSize: '11px', color: 'rgba(0, 0, 0, 0.45)', }; const exportCardStyle: React.CSSProperties = { maxWidth: '80%', marginTop: '8px', cursor: 'pointer', transition: 'all 0.2s', position: 'relative', }; const exportCardBodyStyle: React.CSSProperties = { padding: '12px 16px', }; const exportCardHeaderStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px', }; const exportCardIconStyle: React.CSSProperties = { fontSize: '16px', color: '#1677ff', }; const exportCardTitleStyle: React.CSSProperties = { fontSize: '14px', fontWeight: 500, color: '#262626', flex: 1, }; const exportCardActionsStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '12px', marginTop: '8px', fontSize: '12px', color: '#8c8c8c', }; const exportCardActionStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', transition: 'color 0.2s', }; // ── Component ──────────────────────────────────────────────────────────────── /** * Props for the MessageItem component */ export interface MessageItemProps { /** The chat message to display */ message: ChatMessage; /** Callback when export record card is clicked to preview document */ onPreviewDocument?: (documentId: string, documentName?: string) => void; className?: string; } /** * MessageItem * * Renders a single chat message bubble with its timestamp. * If the message includes an export record, displays a clickable card * that allows the user to preview or download the generated document. * * The component is wrapped in `React.memo` to avoid unnecessary re-renders * when the parent (MessageList) re-renders due to new messages arriving. * * @example * ```tsx * { Handle preview }} * /> * ``` */ const MessageItem: React.FC = memo( ({ message, onPreviewDocument, className }) => { const { role, content, timestamp, exportRecord } = message; const [isCreatingDocument, setIsCreatingDocument] = useState(false); const createDocument = useDocumentStore((state) => state.createDocument); const currentSessionId = useChatStore((state) => state.currentSessionId); /** * Handle preview document click * * 点击预览文档时的处理逻辑: * 1. 检查本地缓存是否已有该导出记录对应的 documentId * 2. 如果有缓存且文档仍然存在,直接使用 * 3. 如果没有缓存或文档已被删除,调用 POST /api/v1/documents 创建新文档 * 4. 创建文档时传递 sessionId,确保同一会话中的多个文档共用相同的 sessionId * 5. 缓存 documentId 并打开编辑器 */ const handlePreviewClick = useCallback(async () => { if (!exportRecord || !onPreviewDocument || isCreatingDocument) return; try { setIsCreatingDocument(true); // Step 1: Check localStorage cache const { default: apiClient, isApiError } = await import('../../services/api'); const cacheKey = `doc_cache_${currentSessionId}_${exportRecord.recordId}`; const cachedDocId = localStorage.getItem(cacheKey); let documentId: string; if (cachedDocId) { // Step 2: Verify cached document still exists try { await apiClient.get(`/api/v1/documents/${cachedDocId}`); // Document exists, reuse it documentId = cachedDocId; } catch (error) { if (!isApiError(error) || error.status !== 404) { throw error; } // Document no longer exists, clear cache and create new localStorage.removeItem(cacheKey); // Create new document with sessionId // 重要: 传递 sessionId 确保文档关联到当前会话 const response = await createDocument({ userId: 'default-user', // TODO: Get from auth context fileUrl: exportRecord.downloadUrl, sessionId: currentSessionId || 'default-session', }); documentId = response.documentId; localStorage.setItem(cacheKey, documentId); } } else { // Step 3: No cache, create new document with sessionId // 重要: 每次生成新文档都会调用此API,传递相同的sessionId // 这样在数据库中就会有多条记录,id不同但session_id相同 const response = await createDocument({ userId: 'default-user', // TODO: Get from auth context fileUrl: exportRecord.downloadUrl, sessionId: currentSessionId || 'default-session', }); documentId = response.documentId; // Step 4: Cache the mapping localStorage.setItem(cacheKey, documentId); } // Step 5: Open the preview with the document ID onPreviewDocument(documentId, exportRecord.fileName); } catch (error) { antdMessage.error('打开文档失败: ' + (error instanceof Error ? error.message : '未知错误')); } finally { setIsCreatingDocument(false); } }, [exportRecord, onPreviewDocument, isCreatingDocument, createDocument, currentSessionId]); /** * Handle download document click */ const handleDownloadClick = useCallback( async (e: React.MouseEvent) => { e.stopPropagation(); // Prevent card click if (!exportRecord?.downloadUrl) return; try { // Extract recordId from downloadUrl // URL format: http://xxx/api/v1/export/records/{recordId}/download?userId=xxx const urlMatch = exportRecord.downloadUrl.match(/\/export\/records\/([^/]+)\/download/); if (!urlMatch) { return; } const recordId = urlMatch[1]; const urlParams = new URL(exportRecord.downloadUrl).searchParams; const userId = urlParams.get('userId') || 'default-user'; // Use apiClient to download (no mixed content warning) const { default: apiClient } = await import('../../services/api'); const response = await apiClient.get( `/api/v1/export/records/${recordId}/download`, { params: { userId }, responseType: 'blob', } ); // Get filename from export record or Content-Disposition header let fileName = exportRecord.fileName || 'document.doc'; const contentDisposition = response.headers['content-disposition']; if (contentDisposition) { const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (match && match[1]) { fileName = match[1].replace(/['"]/g, ''); } } // Create blob and download const blob = new Blob([response.data], { type: 'application/msword' }); const blobUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = fileName; link.style.display = 'none'; document.body.appendChild(link); link.click(); // Clean up setTimeout(() => { document.body.removeChild(link); URL.revokeObjectURL(blobUrl); }, 100); } catch (error) { antdMessage.error( `下载文档失败: ${error instanceof Error ? error.message : '未知错误'}` ); } }, [exportRecord] ); return (
{/* Message bubble */}
{content}
{/* Export record card (if present) */} {exportRecord && ( {isCreatingDocument && (
} />
)}
{exportRecord.fileName}
{ (e.currentTarget as HTMLElement).style.color = '#1677ff'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = '#8c8c8c'; }} data-testid="export-download-action" > 下载
点击预览和编辑
)} {/* Meta row: timestamp */}
{formatDate(timestamp)}
); } ); MessageItem.displayName = 'MessageItem'; export default MessageItem;