/** * 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'; import { downloadBlob, getFileNameFromContentDisposition } from '../../utils/download'; 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', }; const URL_PATTERN = /(https?:\/\/[^\s]+)/g; const documentLinkCardStyle: React.CSSProperties = { ...exportCardStyle, marginTop: 0, marginBottom: 8, }; const getDocumentIdFromUrl = (value: string): string | undefined => { try { return new URL(value).searchParams.get('documentId') || undefined; } catch { return undefined; } }; const renderMessageContent = ( content: string, onPreviewDocument?: (documentId: string, documentName?: string) => void ): React.ReactNode => { const lines = content.split('\n'); const nodes: React.ReactNode[] = []; for (let index = 0; index < lines.length; index += 1) { const documentLine = lines[index].match(/^\s*\d+\.\s+(.+?)(?:\s+\(更新于[^)]*\))?\s*$/); const documentUrl = lines[index + 1]?.trim(); const documentId = documentUrl && /^https?:\/\//i.test(documentUrl) ? getDocumentIdFromUrl(documentUrl) : undefined; if (documentLine && documentId) { nodes.push( onPreviewDocument?.(documentId, documentLine[1])} data-testid="document-link-card" >
{documentLine[1]}
点击打开并编辑
); index += 1; continue; } const parts = lines[index].split(URL_PATTERN); nodes.push( {parts.map((part, partIndex) => { if (!/^https?:\/\//i.test(part)) return part; return ( event.stopPropagation()} > {part} ); })} {index < lines.length - 1 ? '\n' : null} ); } return nodes; }; // ── 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 const fileName = getFileNameFromContentDisposition( response.headers['content-disposition'], exportRecord.fileName || 'document.doc' ); // Create blob and download const blob = new Blob([response.data], { type: 'application/msword' }); downloadBlob(blob, fileName); } catch (error) { antdMessage.error(`下载文档失败: ${error instanceof Error ? error.message : '未知错误'}`); } }, [exportRecord] ); return (
{/* Message bubble */}
{renderMessageContent(content, onPreviewDocument)}
{/* 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;