MessageItem.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. /**
  2. * MessageItem Component
  3. *
  4. * Renders a single chat message with role-appropriate styling.
  5. * Displays a formatted timestamp and optional export record links.
  6. * When an export record is present, shows a clickable card to preview the document.
  7. *
  8. * Requirements: 5.4, 5.5
  9. *
  10. * @module components/ChatPanel/MessageItem
  11. */
  12. import React, { memo, useCallback, useState } from 'react';
  13. import { Typography, Card, message as antdMessage, Spin } from 'antd';
  14. import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
  15. import { formatDate } from '../../utils/formatDate';
  16. import type { ChatMessage } from '../../types/chat';
  17. import { useDocumentStore } from '../../stores/documentStore';
  18. import { useChatStore } from '../../stores/chatStore';
  19. const { Text } = Typography;
  20. // ── Styles ──────────────────────────────────────────────────────────────────
  21. const messageRowStyle = (role: ChatMessage['role']): React.CSSProperties => ({
  22. display: 'flex',
  23. flexDirection: 'column',
  24. alignItems: role === 'user' ? 'flex-end' : 'flex-start',
  25. gap: '4px',
  26. contentVisibility: 'auto',
  27. containIntrinsicSize: '0 96px',
  28. });
  29. const bubbleStyle = (role: ChatMessage['role']): React.CSSProperties => ({
  30. maxWidth: '80%',
  31. padding: '10px 14px',
  32. borderRadius: role === 'user' ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
  33. backgroundColor: role === 'user' ? '#1677ff' : '#f0f0f0',
  34. color: role === 'user' ? '#ffffff' : 'rgba(0, 0, 0, 0.85)',
  35. wordBreak: 'break-word',
  36. whiteSpace: 'pre-wrap',
  37. lineHeight: '1.6',
  38. fontSize: '14px',
  39. });
  40. const metaRowStyle = (role: ChatMessage['role']): React.CSSProperties => ({
  41. display: 'flex',
  42. flexDirection: role === 'user' ? 'row-reverse' : 'row',
  43. alignItems: 'center',
  44. gap: '8px',
  45. });
  46. const timestampStyle: React.CSSProperties = {
  47. fontSize: '11px',
  48. color: 'rgba(0, 0, 0, 0.45)',
  49. };
  50. const exportCardStyle: React.CSSProperties = {
  51. maxWidth: '80%',
  52. marginTop: '8px',
  53. cursor: 'pointer',
  54. transition: 'all 0.2s',
  55. position: 'relative',
  56. };
  57. const exportCardBodyStyle: React.CSSProperties = {
  58. padding: '12px 16px',
  59. };
  60. const exportCardHeaderStyle: React.CSSProperties = {
  61. display: 'flex',
  62. alignItems: 'center',
  63. gap: '8px',
  64. marginBottom: '4px',
  65. };
  66. const exportCardIconStyle: React.CSSProperties = {
  67. fontSize: '16px',
  68. color: '#1677ff',
  69. };
  70. const exportCardTitleStyle: React.CSSProperties = {
  71. fontSize: '14px',
  72. fontWeight: 500,
  73. color: '#262626',
  74. flex: 1,
  75. };
  76. const exportCardActionsStyle: React.CSSProperties = {
  77. display: 'flex',
  78. alignItems: 'center',
  79. gap: '12px',
  80. marginTop: '8px',
  81. fontSize: '12px',
  82. color: '#8c8c8c',
  83. };
  84. const exportCardActionStyle: React.CSSProperties = {
  85. display: 'flex',
  86. alignItems: 'center',
  87. gap: '4px',
  88. cursor: 'pointer',
  89. transition: 'color 0.2s',
  90. };
  91. // ── Component ────────────────────────────────────────────────────────────────
  92. /**
  93. * Props for the MessageItem component
  94. */
  95. export interface MessageItemProps {
  96. /** The chat message to display */
  97. message: ChatMessage;
  98. /** Callback when export record card is clicked to preview document */
  99. onPreviewDocument?: (documentId: string, documentName?: string) => void;
  100. className?: string;
  101. }
  102. /**
  103. * MessageItem
  104. *
  105. * Renders a single chat message bubble with its timestamp.
  106. * If the message includes an export record, displays a clickable card
  107. * that allows the user to preview or download the generated document.
  108. *
  109. * The component is wrapped in `React.memo` to avoid unnecessary re-renders
  110. * when the parent (MessageList) re-renders due to new messages arriving.
  111. *
  112. * @example
  113. * ```tsx
  114. * <MessageItem
  115. * message={msg}
  116. * onPreviewDocument={(docId) => { Handle preview }}
  117. * />
  118. * ```
  119. */
  120. const MessageItem: React.FC<MessageItemProps> = memo(
  121. ({ message, onPreviewDocument, className }) => {
  122. const { role, content, timestamp, exportRecord } = message;
  123. const [isCreatingDocument, setIsCreatingDocument] = useState(false);
  124. const createDocument = useDocumentStore((state) => state.createDocument);
  125. const currentSessionId = useChatStore((state) => state.currentSessionId);
  126. /**
  127. * Handle preview document click
  128. *
  129. * 点击预览文档时的处理逻辑:
  130. * 1. 检查本地缓存是否已有该导出记录对应的 documentId
  131. * 2. 如果有缓存且文档仍然存在,直接使用
  132. * 3. 如果没有缓存或文档已被删除,调用 POST /api/v1/documents 创建新文档
  133. * 4. 创建文档时传递 sessionId,确保同一会话中的多个文档共用相同的 sessionId
  134. * 5. 缓存 documentId 并打开编辑器
  135. */
  136. const handlePreviewClick = useCallback(async () => {
  137. if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
  138. try {
  139. setIsCreatingDocument(true);
  140. // Step 1: Check localStorage cache
  141. const { default: apiClient, isApiError } = await import('../../services/api');
  142. const cacheKey = `doc_cache_${currentSessionId}_${exportRecord.recordId}`;
  143. const cachedDocId = localStorage.getItem(cacheKey);
  144. let documentId: string;
  145. if (cachedDocId) {
  146. // Step 2: Verify cached document still exists
  147. try {
  148. await apiClient.get(`/api/v1/documents/${cachedDocId}`);
  149. // Document exists, reuse it
  150. documentId = cachedDocId;
  151. } catch (error) {
  152. if (!isApiError(error) || error.status !== 404) {
  153. throw error;
  154. }
  155. // Document no longer exists, clear cache and create new
  156. localStorage.removeItem(cacheKey);
  157. // Create new document with sessionId
  158. // 重要: 传递 sessionId 确保文档关联到当前会话
  159. const response = await createDocument({
  160. userId: 'default-user', // TODO: Get from auth context
  161. fileUrl: exportRecord.downloadUrl,
  162. sessionId: currentSessionId || 'default-session',
  163. });
  164. documentId = response.documentId;
  165. localStorage.setItem(cacheKey, documentId);
  166. }
  167. } else {
  168. // Step 3: No cache, create new document with sessionId
  169. // 重要: 每次生成新文档都会调用此API,传递相同的sessionId
  170. // 这样在数据库中就会有多条记录,id不同但session_id相同
  171. const response = await createDocument({
  172. userId: 'default-user', // TODO: Get from auth context
  173. fileUrl: exportRecord.downloadUrl,
  174. sessionId: currentSessionId || 'default-session',
  175. });
  176. documentId = response.documentId;
  177. // Step 4: Cache the mapping
  178. localStorage.setItem(cacheKey, documentId);
  179. }
  180. // Step 5: Open the preview with the document ID
  181. onPreviewDocument(documentId, exportRecord.fileName);
  182. } catch (error) {
  183. antdMessage.error('打开文档失败: ' + (error instanceof Error ? error.message : '未知错误'));
  184. } finally {
  185. setIsCreatingDocument(false);
  186. }
  187. }, [exportRecord, onPreviewDocument, isCreatingDocument, createDocument, currentSessionId]);
  188. /**
  189. * Handle download document click
  190. */
  191. const handleDownloadClick = useCallback(
  192. async (e: React.MouseEvent) => {
  193. e.stopPropagation(); // Prevent card click
  194. if (!exportRecord?.downloadUrl) return;
  195. try {
  196. // Extract recordId from downloadUrl
  197. // URL format: http://xxx/api/v1/export/records/{recordId}/download?userId=xxx
  198. const urlMatch = exportRecord.downloadUrl.match(/\/export\/records\/([^/]+)\/download/);
  199. if (!urlMatch) {
  200. return;
  201. }
  202. const recordId = urlMatch[1];
  203. const urlParams = new URL(exportRecord.downloadUrl).searchParams;
  204. const userId = urlParams.get('userId') || 'default-user';
  205. // Use apiClient to download (no mixed content warning)
  206. const { default: apiClient } = await import('../../services/api');
  207. const response = await apiClient.get(
  208. `/api/v1/export/records/${recordId}/download`,
  209. {
  210. params: { userId },
  211. responseType: 'blob',
  212. }
  213. );
  214. // Get filename from export record or Content-Disposition header
  215. let fileName = exportRecord.fileName || 'document.doc';
  216. const contentDisposition = response.headers['content-disposition'];
  217. if (contentDisposition) {
  218. const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
  219. if (match && match[1]) {
  220. fileName = match[1].replace(/['"]/g, '');
  221. }
  222. }
  223. // Create blob and download
  224. const blob = new Blob([response.data], { type: 'application/msword' });
  225. const blobUrl = URL.createObjectURL(blob);
  226. const link = document.createElement('a');
  227. link.href = blobUrl;
  228. link.download = fileName;
  229. link.style.display = 'none';
  230. document.body.appendChild(link);
  231. link.click();
  232. // Clean up
  233. setTimeout(() => {
  234. document.body.removeChild(link);
  235. URL.revokeObjectURL(blobUrl);
  236. }, 100);
  237. } catch (error) {
  238. antdMessage.error(
  239. `下载文档失败: ${error instanceof Error ? error.message : '未知错误'}`
  240. );
  241. }
  242. },
  243. [exportRecord]
  244. );
  245. return (
  246. <div
  247. style={messageRowStyle(role)}
  248. className={className}
  249. data-testid={`message-item-${message.id}`}
  250. data-role={role}
  251. >
  252. {/* Message bubble */}
  253. <div style={bubbleStyle(role)} data-testid="message-bubble">
  254. {content}
  255. </div>
  256. {/* Export record card (if present) */}
  257. {exportRecord && (
  258. <Card
  259. style={exportCardStyle}
  260. styles={{ body: exportCardBodyStyle }}
  261. hoverable
  262. onClick={handlePreviewClick}
  263. data-testid="export-record-card"
  264. >
  265. {isCreatingDocument && (
  266. <div
  267. style={{
  268. position: 'absolute',
  269. top: 0,
  270. left: 0,
  271. right: 0,
  272. bottom: 0,
  273. display: 'flex',
  274. alignItems: 'center',
  275. justifyContent: 'center',
  276. backgroundColor: 'rgba(255, 255, 255, 0.8)',
  277. zIndex: 1,
  278. }}
  279. >
  280. <Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
  281. </div>
  282. )}
  283. <div style={exportCardHeaderStyle}>
  284. <FileTextOutlined style={exportCardIconStyle} />
  285. <div style={exportCardTitleStyle}>{exportRecord.fileName}</div>
  286. </div>
  287. <div style={exportCardActionsStyle}>
  288. <div
  289. style={exportCardActionStyle}
  290. onClick={handleDownloadClick}
  291. onMouseEnter={(e) => {
  292. (e.currentTarget as HTMLElement).style.color = '#1677ff';
  293. }}
  294. onMouseLeave={(e) => {
  295. (e.currentTarget as HTMLElement).style.color = '#8c8c8c';
  296. }}
  297. data-testid="export-download-action"
  298. >
  299. <DownloadOutlined />
  300. <span>下载</span>
  301. </div>
  302. <div>
  303. <span>点击预览和编辑</span>
  304. </div>
  305. </div>
  306. </Card>
  307. )}
  308. {/* Meta row: timestamp */}
  309. <div style={metaRowStyle(role)} data-testid="message-meta">
  310. <Text style={timestampStyle} data-testid="message-timestamp">
  311. {formatDate(timestamp)}
  312. </Text>
  313. </div>
  314. </div>
  315. );
  316. }
  317. );
  318. MessageItem.displayName = 'MessageItem';
  319. export default MessageItem;