| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- /**
- * 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
- * <MessageItem
- * message={msg}
- * onPreviewDocument={(docId) => { Handle preview }}
- * />
- * ```
- */
- const MessageItem: React.FC<MessageItemProps> = 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 (
- <div
- style={messageRowStyle(role)}
- className={className}
- data-testid={`message-item-${message.id}`}
- data-role={role}
- >
- {/* Message bubble */}
- <div style={bubbleStyle(role)} data-testid="message-bubble">
- {content}
- </div>
- {/* Export record card (if present) */}
- {exportRecord && (
- <Card
- style={exportCardStyle}
- styles={{ body: exportCardBodyStyle }}
- hoverable
- onClick={handlePreviewClick}
- data-testid="export-record-card"
- >
- {isCreatingDocument && (
- <div
- style={{
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(255, 255, 255, 0.8)',
- zIndex: 1,
- }}
- >
- <Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
- </div>
- )}
- <div style={exportCardHeaderStyle}>
- <FileTextOutlined style={exportCardIconStyle} />
- <div style={exportCardTitleStyle}>{exportRecord.fileName}</div>
- </div>
- <div style={exportCardActionsStyle}>
- <div
- style={exportCardActionStyle}
- onClick={handleDownloadClick}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLElement).style.color = '#1677ff';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = '#8c8c8c';
- }}
- data-testid="export-download-action"
- >
- <DownloadOutlined />
- <span>下载</span>
- </div>
- <div>
- <span>点击预览和编辑</span>
- </div>
- </div>
- </Card>
- )}
- {/* Meta row: timestamp */}
- <div style={metaRowStyle(role)} data-testid="message-meta">
- <Text style={timestampStyle} data-testid="message-timestamp">
- {formatDate(timestamp)}
- </Text>
- </div>
- </div>
- );
- }
- );
- MessageItem.displayName = 'MessageItem';
- export default MessageItem;
|