prevewSlice.tsx.bak 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. // 查看切片
  2. import React, { useEffect, useMemo, useState, useRef, useLayoutEffect } from 'react';
  3. import { Drawer, Row, Col, Input, Button, List, Typography, Select, Spin, Image, message } from 'antd';
  4. import type { DrawerProps, RadioChangeEvent } from 'antd';
  5. import { SnippetsOutlined } from '@ant-design/icons';
  6. import MarkdownIt from 'markdown-it';
  7. import { fetchReviseToolAllList, fetchReviseToolSliceList, apis } from '@/apis';
  8. import '../../revisionTool/components/reviseDrawer.less'
  9. import { Document, Page, pdfjs } from 'react-pdf';
  10. interface ReviseDrawerProps {
  11. openDrawer: boolean;
  12. onClose: () => void;
  13. title: string;
  14. record: any;
  15. }
  16. // 设置 pdf.worker(使用 CDN 作为回退)
  17. try {
  18. pdfjs.GlobalWorkerOptions.workerSrc = (pdfjs.GlobalWorkerOptions.workerSrc || '') || '//cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
  19. } catch (e) {
  20. // ignore
  21. }
  22. // =================== PDF 子组件(文件级,最重要) ===================
  23. const LazyPage = React.memo(
  24. ({
  25. pageNumber,
  26. width,
  27. containerRef,
  28. }: {
  29. pageNumber: number;
  30. width: number;
  31. containerRef: React.RefObject<HTMLDivElement>;
  32. }) => {
  33. const elRef = React.useRef<HTMLDivElement | null>(null);
  34. const [visible, setVisible] = React.useState(false);
  35. React.useEffect(() => {
  36. const el = elRef.current;
  37. const root = containerRef.current;
  38. if (!el || !root) return;
  39. const io = new IntersectionObserver(
  40. entries => {
  41. entries.forEach(e => e.isIntersecting && setVisible(true));
  42. },
  43. { root, rootMargin: '400px' }
  44. );
  45. io.observe(el);
  46. return () => io.disconnect();
  47. }, [containerRef]);
  48. return (
  49. <div ref={elRef} style={{ background: '#fff' }}>
  50. {visible ? (
  51. <Page
  52. pageNumber={pageNumber}
  53. // width={width}
  54. renderTextLayer={false}
  55. renderAnnotationLayer={false}
  56. className='pdf-page'
  57. />
  58. ) : (
  59. <div
  60. style={{
  61. width: '100%',
  62. height: Math.floor(width * 1.3),
  63. background: '#fafafa',
  64. }}
  65. />
  66. )}
  67. </div>
  68. );
  69. }
  70. );
  71. const PdfViewer = React.memo(
  72. ({
  73. file,
  74. numPages,
  75. pageWidth,
  76. scale,
  77. onLoadSuccess,
  78. containerRef,
  79. }: {
  80. file: string;
  81. numPages: number;
  82. pageWidth: number;
  83. scale: number;
  84. onLoadSuccess: any;
  85. containerRef: React.RefObject<HTMLDivElement>;
  86. }) => {
  87. return (
  88. <Document
  89. // renderMode="canvas"
  90. file={file}
  91. onLoadSuccess={onLoadSuccess}
  92. >
  93. {Array.from({ length: numPages }).map((_, idx) => (
  94. <LazyPage
  95. key={idx}
  96. pageNumber={idx + 1}
  97. width={Math.floor(pageWidth * scale)}
  98. containerRef={containerRef}
  99. />
  100. ))}
  101. </Document>
  102. );
  103. },
  104. (prev, next) =>
  105. prev.file === next.file &&
  106. prev.numPages === next.numPages &&
  107. prev.pageWidth === next.pageWidth &&
  108. prev.scale === next.scale
  109. );
  110. const ReviseDrawer: React.FC<ReviseDrawerProps> = (props: ReviseDrawerProps) => {
  111. const { openDrawer, onClose, title, record } = props;
  112. const [placement, setPlacement] = useState<DrawerProps['placement']>('right');
  113. const onGetSliceListByDocumentId = async (documentId: string) => {
  114. try {
  115. const res: any = await apis.getSliceListByDocumentId(documentId);
  116. if (res && res.data && Array.isArray(res.data)) {
  117. const results = res.data;
  118. results.forEach((item: any) => {
  119. item.sliceText = customRender(item.sliceText, item.mediaList)
  120. })
  121. setRightSlices(results);
  122. } else {
  123. return [];
  124. }
  125. } catch (err) {
  126. return [];
  127. }
  128. };
  129. useEffect(() => {
  130. if (openDrawer) {
  131. console.log('预览切片record', record);
  132. onGetSliceListByDocumentId(record.documentId);
  133. }
  134. }, [openDrawer]);
  135. const [leftSearch, setLeftSearch] = useState('');// 左边搜索
  136. const [rightSearch, setRightSearch] = useState(''); // 右边搜索
  137. const [leftSpliceSearch, setLeftSpliceSearch] = useState('');// 左边切片搜索
  138. const [rightSpliceSearch, setRightSpliceSearch] = useState('');// 右边切片搜索
  139. const [revisionOptions, setRevisionOptions] = useState<any[]>([]);
  140. const [revisionLoading, setRevisionLoading] = useState(false);
  141. const [selectedRevision, setSelectedRevision] = useState<string | undefined>(undefined);
  142. const [leftDocumentId, setLeftDocumentId] = useState(''); // 左边选中的文档ID
  143. const [rightDocumentId, setRightDocumentId] = useState(''); // 右边选中的文档ID
  144. const [leftOptions, setLeftOptions] = useState<any[]>([]);// 左边数据
  145. const [rightOptions, setRightOptions] = useState<any[]>([]);// 右边数据
  146. const [leftLoading, setLeftLoading] = useState(false);// 左边加载状态
  147. const [rightLoading, setRightLoading] = useState(false);// 右边加载状态
  148. const [selectedStandard, setSelectedStandard] = useState<string | null>(null);
  149. const [leftSlices, setLeftSlices] = useState<string[]>([]);// 左边切片列表
  150. const [rightSlices, setRightSlices] = useState<string[]>([]);// 右边切片列表
  151. const [leftSlicesLoading, setLeftSlicesLoading] = useState(false);// 左边切片加载状态
  152. const [rightSlicesLoading, setRightSlicesLoading] = useState(false);// 右边切片加载状态
  153. const [pdfBlobUrl, setPdfBlobUrl] = useState<string | null>(null);
  154. const [pdfLoading, setPdfLoading] = useState(false);
  155. const [largeFile, setLargeFile] = useState(false);
  156. const [pageNumber, setPageNumber] = useState<number>(1);
  157. const [scale, setScale] = useState<number>(1.0);
  158. const containerRef = useRef<HTMLDivElement | null>(null);
  159. const [pageWidth, setPageWidth] = useState<number>(600);
  160. const marked = new MarkdownIt({ html: true, typographer: true });
  161. useEffect(() => {
  162. if (leftDocumentId) {
  163. onFetchReviseToolSliceList(leftDocumentId, 'left');
  164. }
  165. }, [leftDocumentId]);
  166. useEffect(() => {
  167. if (rightDocumentId) {
  168. onFetchReviseToolSliceList(rightDocumentId, 'right');
  169. }
  170. }, [rightDocumentId]);
  171. const onChange = (e: RadioChangeEvent) => {
  172. setPlacement(e.target.value);
  173. };
  174. const onFetchTakaiAppTypeListApi = async () => {
  175. setRevisionLoading(true);
  176. try {
  177. const res: any = await apis.fetchTakaiAppTypeList('revision_status');
  178. if (res && res.data && Array.isArray(res.data)) {
  179. const opts = res.data.map((it: any) => ({ label: it.dictLabel, value: it.dictValue }));
  180. setSelectedRevision(opts[0].value);
  181. setRevisionOptions(opts);
  182. } else {
  183. setRevisionOptions([]);
  184. }
  185. } catch (err) {
  186. setRevisionOptions([]);
  187. } finally {
  188. setRevisionLoading(false);
  189. }
  190. };
  191. useEffect(() => {
  192. onFetchTakaiAppTypeListApi();
  193. }, []);
  194. const customRender = (text: string, mdImgUrlList: any[]) => {
  195. // 比如:把 "我是图片" 替换成一张图片
  196. mdImgUrlList?.forEach(item => {
  197. text = text.replace(new RegExp(item.originText, 'g'), `<img src="${item.mediaUrl}" alt="${item.originText}" />`);
  198. })
  199. return text;
  200. }
  201. // 获取知识库修订工具切片列表
  202. const onFetchReviseToolSliceList = async (documentId: string, side?: 'left' | 'right', sliceText?: string) => {
  203. setLeftSlicesLoading(side === 'left' ? true : leftSlicesLoading);
  204. setRightSlicesLoading(side === 'right' ? true : rightSlicesLoading);
  205. // Implement the function to fetch revise tool slice list
  206. const res: any = await fetchReviseToolSliceList({ documentId: documentId || '', sliceText: sliceText || '' });
  207. // 假设接口返回结构为 { code: 200, data: [ { sliceContent }, ... ] }
  208. if (res && res.data && Array.isArray(res.data)) {
  209. const results = res.data;
  210. results.forEach((item: any) => {
  211. item.sliceText = customRender(item.sliceText, item.mediaList)
  212. })
  213. if (side === 'left') {
  214. setLeftSlicesLoading(false);
  215. setLeftSlices(results);
  216. }
  217. else if (side === 'right') {
  218. setRightSlicesLoading(false);
  219. setRightSlices(results);
  220. }
  221. } else {
  222. if (side === 'left')
  223. setLeftSlices([]);
  224. else if (side === 'right')
  225. setRightSlices([]);
  226. }
  227. }
  228. const [LeftActiveId, setLeftActiveId] = useState<string | null>(null);
  229. const [RightActiveId, setRightActiveId] = useState<string | null>(null);
  230. const [subLoading, setSubLoading] = useState(false);
  231. const onAllClose = () => {
  232. setLeftActiveId(null);
  233. setRightActiveId(null);
  234. setLeftSearch('');
  235. setRightSearch('');
  236. setLeftSpliceSearch('');
  237. setRightSpliceSearch('');
  238. setLeftDocumentId('');
  239. setRightDocumentId('');
  240. setLeftOptions([]);
  241. setRightOptions([]);
  242. setLeftSlices([]);
  243. setRightSlices([]);
  244. onClose();
  245. }
  246. const [numPages, setNumPages] = useState<number>(0);
  247. function onLoadSuccess({ numPages }: { numPages: number }) {
  248. setNumPages(numPages);
  249. setPageNumber(1);
  250. }
  251. // 测量容器宽度以计算 Page 渲染宽度
  252. useLayoutEffect(() => {
  253. const update = () => {
  254. const el = containerRef.current;
  255. if (el) {
  256. const w = el.clientWidth - 20; // 留白
  257. setPageWidth(Math.max(200, w));
  258. }
  259. };
  260. update();
  261. window.addEventListener('resize', update);
  262. return () => window.removeEventListener('resize', update);
  263. }, []);
  264. // Fetch PDF as blob to avoid browser auto-download from Content-Disposition
  265. useEffect(() => {
  266. let cancelled = false;
  267. let currentBlobUrl: string | null = null;
  268. const fetchBlob = async () => {
  269. if (!record?.pdfUrl) {
  270. setPdfBlobUrl(null);
  271. return;
  272. }
  273. try {
  274. // 尝试先用 HEAD 获取文件大小,若大文件则跳过下载 blob,改用原始 URL 由 pdf.js 处理流式加载
  275. const headResp = await fetch(record.pdfUrl, { method: 'HEAD', mode: 'cors' });
  276. if (headResp && headResp.ok) {
  277. const len = headResp.headers.get('Content-Length');
  278. if (len) {
  279. const size = parseInt(len, 10);
  280. const threshold = 10 * 1024 * 1024; // 10MB
  281. if (!Number.isNaN(size) && size > threshold) {
  282. setLargeFile(true);
  283. // 不下载 blob,直接让 Document 使用原始 URL(支持 range 请求的服务器会更快)
  284. setPdfBlobUrl(null);
  285. return;
  286. }
  287. }
  288. }
  289. setPdfLoading(true);
  290. const res = await fetch(record.pdfUrl, { method: 'GET', mode: 'cors' });
  291. if (!res.ok) throw new Error('fetch failed');
  292. const blob = await res.blob();
  293. currentBlobUrl = URL.createObjectURL(blob);
  294. if (!cancelled) {
  295. setPdfBlobUrl(prev => {
  296. if (prev) URL.revokeObjectURL(prev);
  297. return currentBlobUrl;
  298. });
  299. } else {
  300. if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
  301. }
  302. } catch (err) {
  303. console.error('fetch pdf blob error', err);
  304. setPdfBlobUrl(null);
  305. } finally {
  306. if (!cancelled) setPdfLoading(false);
  307. }
  308. };
  309. fetchBlob();
  310. return () => {
  311. cancelled = true;
  312. if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
  313. };
  314. }, [record?.pdfUrl]);
  315. return (
  316. <Drawer
  317. title={title}
  318. placement={placement}
  319. closable={true}
  320. onClose={onAllClose}
  321. open={openDrawer}
  322. width={'90%'}
  323. >
  324. <div className="flex gap-4 h-full">
  325. {/* Left column */}
  326. <div className="w-1/2 border-r pr-4">
  327. <Spin spinning={leftSlicesLoading || pdfLoading} className="h-full">
  328. <div className="flex flex-col h-full">
  329. <div className="mb-2 flex items-center justify-end">
  330. <div className="flex items-center gap-2">
  331. <Button size="small" onClick={() => setScale(s => Math.max(0.5, s - 0.1))}>-</Button>
  332. <span className="text-sm">{Math.round(scale * 100)}%</span>
  333. <Button size="small" onClick={() => setScale(s => Math.min(2, s + 0.1))}>+</Button>
  334. <Button size="small" onClick={() => { if (record?.pdfUrl) window.open(record.pdfUrl, '_blank'); }}>在新窗口打开</Button>
  335. </div>
  336. </div>
  337. <div
  338. ref={containerRef}
  339. className="overflow-y-auto"
  340. style={{ maxHeight: 'calc(100vh - 130px)' }}
  341. >
  342. {pdfLoading ? (
  343. <div className="text-center py-8">PDF 加载中...</div>
  344. ) : record?.pdfUrl ? (
  345. (pdfBlobUrl || record.pdfUrl) ? (
  346. <PdfViewer
  347. file={pdfBlobUrl || record.pdfUrl}
  348. numPages={numPages}
  349. pageWidth={pageWidth}
  350. scale={scale}
  351. onLoadSuccess={onLoadSuccess}
  352. containerRef={containerRef}
  353. />
  354. ) : (
  355. <div className="text-center py-8">
  356. 无法生成预览,
  357. <Button
  358. type="link"
  359. onClick={() => {
  360. if (record?.pdfUrl) window.open(record.pdfUrl, '_blank');
  361. }}
  362. >
  363. 在新窗口打开
  364. </Button>
  365. </div>
  366. )
  367. ) : (
  368. <div className="text-center text-gray-500 mt-10">未找到 PDF</div>
  369. )}
  370. </div>
  371. </div>
  372. </Spin>
  373. </div>
  374. {/* Right column (same as left) */}
  375. <div className="w-1/2">
  376. <Spin spinning={rightSlicesLoading} className='h-full'>
  377. {rightSlices.length > 0 ? <div className="overflow-y-auto" style={{ maxHeight: 'calc(100vh - 130px)' }}>
  378. {rightSlices.map((module: any, index) => (
  379. <div
  380. key={`r-${index}`}
  381. className={`relative px-3 py-2 text-sm leading-7 transition-all duration-200 cursor-pointer
  382. ${index !== rightSlices.length - 1 ? 'border-b border-gray-100' : ''
  383. }`}
  384. onClick={() => setRightActiveId(module.sliceId)}
  385. >
  386. {module.sliceText && (() => {
  387. const html = marked.render(module.sliceText || '');
  388. const parts: any[] = [];
  389. const imgRegex = /<img[^>]*src=["']([^"']+)["'][^>]*>/g;
  390. let lastIndex = 0;
  391. let match: RegExpExecArray | null;
  392. while ((match = imgRegex.exec(html)) !== null) {
  393. const idx = match.index;
  394. const textSeg = html.substring(lastIndex, idx);
  395. if (textSeg) parts.push(<div key={`rt-${index}-${lastIndex}`} dangerouslySetInnerHTML={{ __html: textSeg }} />);
  396. const url = match[1];
  397. parts.push(<Image key={`rimg-${index}-${idx}`} src={url} preview={{}} style={{ maxWidth: '100%' }} />);
  398. lastIndex = idx + match[0].length;
  399. }
  400. const rest = html.substring(lastIndex);
  401. if (rest) parts.push(<div key={`rt-last-${index}`} dangerouslySetInnerHTML={{ __html: rest }} />);
  402. return <div className="text-sm leading-7 text-gray-800 markdown-preview">{parts}</div>;
  403. })()}
  404. </div>
  405. ))}
  406. </div> : <div className="text-center text-gray-500 mt-10">暂无数据</div>}
  407. </Spin>
  408. </div>
  409. </div>
  410. </Drawer>
  411. )
  412. }
  413. export default observer(ReviseDrawer);