MessageItem.tsx 14 KB

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