FilePreviewPanel.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. <template>
  2. <div class="file-preview-panel">
  3. <div class="panel-header">
  4. <span class="panel-title">{{ file.name }}</span>
  5. <div class="panel-header-actions">
  6. <button v-if="editable && !isEditing" class="header-action-btn edit-btn" @click="enterEdit">
  7. <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
  8. <span>编辑</span>
  9. </button>
  10. <button v-if="isEditing" class="header-action-btn cancel-btn" @click="cancelEdit">
  11. <img :src="getCssUrl('--img-chat-close-icon-btn')" width="14" height="14" />
  12. <span>取消</span>
  13. </button>
  14. <button v-if="isEditing" class="header-action-btn save-btn" :class="{ saving }" :disabled="saving" @click="saveEdit">
  15. <img v-if="!saving" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
  16. <img v-else class="spin-icon" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
  17. <span>{{ saving ? '保存中...' : '保存' }}</span>
  18. </button>
  19. </div>
  20. <div class="close-btn" @click="emit('close')">
  21. <div class="btn-close-icon"></div>
  22. </div>
  23. </div>
  24. <div class="panel-content file-preview-content">
  25. <!-- 编辑模式 -->
  26. <div v-if="isEditing" class="edit-mode">
  27. <textarea v-model="editContent" class="edit-textarea" spellcheck="false"></textarea>
  28. </div>
  29. <!-- 预览模式 -->
  30. <template v-else>
  31. <div v-if="isImageFile(file.name)" class="image-preview">
  32. <img :src="file.preview" alt="预览图片" />
  33. </div>
  34. <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
  35. <iframe v-if="file.preview" :src="pdfSrc" frameborder="0"></iframe>
  36. <div v-else class="no-preview-text">无法加载PDF预览</div>
  37. </div>
  38. <div v-else-if="isOldDocFile(file.name)" class="no-preview">
  39. <div class="no-preview-icon"></div>
  40. <div class="no-preview-text">暂不支持 .doc 格式预览</div>
  41. <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
  42. </div>
  43. <div v-else-if="isWordFile(file.name)" class="word-preview">
  44. <VueOfficeDocx v-if="file.arrayBuffer" :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
  45. <div v-else class="no-preview-text">无法加载Word文档</div>
  46. </div>
  47. <div v-else-if="isExcelFile(file.name)" class="excel-preview">
  48. <div v-if="excelSheets.length > 1" class="excel-tabs">
  49. <div v-for="(sheet, i) in excelSheets" :key="i" :class="['excel-tab', { active: activeSheet === i }]" @click="activeSheet = i">
  50. {{ sheet.name }}
  51. </div>
  52. </div>
  53. <div v-if="excelLoading" class="no-preview-text">加载中...</div>
  54. <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
  55. <table class="excel-table">
  56. <thead>
  57. <tr>
  58. <th v-for="(cell, i) in currentSheetData[0]" :key="i">{{ cell }}</th>
  59. </tr>
  60. </thead>
  61. <tbody>
  62. <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
  63. <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
  64. </tr>
  65. </tbody>
  66. </table>
  67. </div>
  68. <div v-else class="no-preview-text">Excel文件内容为空</div>
  69. </div>
  70. <div v-else-if="isCsvFile(file.name)" class="csv-preview">
  71. <table v-if="csvRows.length > 0" class="csv-table">
  72. <thead>
  73. <tr>
  74. <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
  75. </tr>
  76. </thead>
  77. <tbody>
  78. <tr v-for="(row, ri) in csvRows.slice(1)" :key="ri">
  79. <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
  80. </tr>
  81. </tbody>
  82. </table>
  83. <div v-else class="no-preview-text">CSV文件内容为空</div>
  84. </div>
  85. <div v-else-if="isMdFile(file.name)" class="markdown-preview">
  86. <div class="markdown-body" v-html="renderedMarkdown"></div>
  87. </div>
  88. <div v-else-if="isTextFile(file.name)" class="text-preview">
  89. <pre>{{ file.content || '文件内容加载中...' }}</pre>
  90. </div>
  91. <div v-else class="no-preview">
  92. <div class="no-preview-icon"></div>
  93. <div class="no-preview-text">不支持预览此文件格式</div>
  94. <div class="no-preview-hint">{{ getFileExtension(file.name) }} 格式文件暂不支持在线预览</div>
  95. </div>
  96. </template>
  97. </div>
  98. </div>
  99. </template>
  100. <script setup lang="ts">
  101. import { ref, computed, watch } from 'vue';
  102. import { message } from 'ant-design-vue';
  103. import { saveReportText } from '../../api';
  104. import VueOfficeDocx from '@vue-office/docx/lib/v3/vue-office-docx.mjs';
  105. import * as XLSX from 'xlsx';
  106. import {
  107. isImageFile,
  108. isPdfFile,
  109. isWordFile,
  110. isOldDocFile,
  111. isExcelFile,
  112. isCsvFile,
  113. isMdFile,
  114. isTextFile,
  115. getFileExtension,
  116. renderMarkdown,
  117. } from './utils';
  118. import type { AttachedFile } from './types';
  119. const getCssUrl = (varName: string) => {
  120. const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
  121. return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
  122. };
  123. const props = withDefaults(
  124. defineProps<{
  125. file: AttachedFile;
  126. editable?: boolean;
  127. saveFilename?: string;
  128. }>(),
  129. { editable: false, saveFilename: '' }
  130. );
  131. const emit = defineEmits<{
  132. (e: 'close'): void;
  133. (e: 'save-success', content: string): void;
  134. }>();
  135. // 编辑模式
  136. const isEditing = ref(false);
  137. const editContent = ref('');
  138. const saving = ref(false);
  139. const enterEdit = () => {
  140. editContent.value = props.file.content || '';
  141. isEditing.value = true;
  142. };
  143. const cancelEdit = () => {
  144. isEditing.value = false;
  145. editContent.value = '';
  146. };
  147. const saveEdit = async () => {
  148. if (!props.saveFilename) return;
  149. saving.value = true;
  150. try {
  151. await saveReportText(props.saveFilename, editContent.value);
  152. message.success('保存成功');
  153. emit('save-success', editContent.value);
  154. } catch (err) {
  155. console.error('保存文件失败:', err);
  156. message.error('保存文件失败');
  157. } finally {
  158. saving.value = false;
  159. }
  160. };
  161. // 文件内容更新后(保存成功),自动退出编辑模式
  162. watch(
  163. () => props.file.content,
  164. () => {
  165. if (isEditing.value) {
  166. isEditing.value = false;
  167. editContent.value = '';
  168. saving.value = false;
  169. }
  170. }
  171. );
  172. // Excel 解析
  173. const excelSheets = ref<{ name: string; data: string[][] }[]>([]);
  174. const activeSheet = ref(0);
  175. const excelLoading = ref(false);
  176. const currentSheetData = computed(() => {
  177. return excelSheets.value[activeSheet.value]?.data || [];
  178. });
  179. const parseExcel = async (file: AttachedFile) => {
  180. excelLoading.value = true;
  181. try {
  182. let buffer = file.arrayBuffer;
  183. if (!buffer && file.originalFile) {
  184. buffer = await file.originalFile.arrayBuffer();
  185. }
  186. if (!buffer) return;
  187. const workbook = XLSX.read(buffer, { type: 'array' });
  188. excelSheets.value = workbook.SheetNames.map((name) => {
  189. const sheet = workbook.Sheets[name];
  190. const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, defval: '' });
  191. return { name, data: rows };
  192. });
  193. activeSheet.value = 0;
  194. } catch (err) {
  195. console.error('Excel解析失败:', err);
  196. excelSheets.value = [];
  197. } finally {
  198. excelLoading.value = false;
  199. }
  200. };
  201. watch(
  202. () => props.file,
  203. (file) => {
  204. if (isExcelFile(file.name)) {
  205. parseExcel(file);
  206. }
  207. },
  208. { immediate: true }
  209. );
  210. const pdfSrc = computed(() => {
  211. const base = props.file.preview || '';
  212. if (!base) return '';
  213. return `${base}#toolbar=0&navpanes=0&scrollbar=0`;
  214. });
  215. const renderedMarkdown = computed(() => {
  216. return renderMarkdown(props.file.content || '');
  217. });
  218. const csvRows = computed(() => {
  219. const content = props.file.content || '';
  220. if (!content.trim()) return [];
  221. return content
  222. .split(/\r?\n/)
  223. .filter((line) => line.trim())
  224. .map((line) => {
  225. const cells: string[] = [];
  226. let current = '';
  227. let inQuotes = false;
  228. for (let i = 0; i < line.length; i++) {
  229. const ch = line[i];
  230. if (ch === '"') {
  231. if (inQuotes && line[i + 1] === '"') {
  232. current += '"';
  233. i++;
  234. } else {
  235. inQuotes = !inQuotes;
  236. }
  237. } else if (ch === ',' && !inQuotes) {
  238. cells.push(current);
  239. current = '';
  240. } else {
  241. current += ch;
  242. }
  243. }
  244. cells.push(current);
  245. return cells;
  246. });
  247. });
  248. const handleRendered = () => {
  249. console.log('文件渲染完成');
  250. };
  251. const handleError = (err: any) => {
  252. console.error('文件预览失败:', err);
  253. };
  254. </script>
  255. <style scoped lang="less">
  256. .file-preview-panel {
  257. display: flex;
  258. flex-direction: column;
  259. height: 100%;
  260. }
  261. .panel-header {
  262. display: flex;
  263. align-items: center;
  264. justify-content: space-between;
  265. padding: 10px 10px 5px;
  266. flex-shrink: 0;
  267. border-bottom: 1px solid rgb(63 80 106 / 25%);
  268. .panel-title {
  269. flex: 1;
  270. color: #e0e6ed;
  271. overflow: hidden;
  272. font-size: 15px;
  273. font-weight: 600;
  274. line-height: 1.4;
  275. text-overflow: ellipsis;
  276. white-space: nowrap;
  277. margin-right: 10px;
  278. }
  279. .panel-header-actions {
  280. display: flex;
  281. gap: 6px;
  282. flex-shrink: 0;
  283. margin-right: 6px;
  284. .header-action-btn {
  285. display: inline-flex;
  286. align-items: center;
  287. gap: 5px;
  288. padding: 4px 12px;
  289. border-radius: 6px;
  290. font-size: 12px;
  291. font-weight: 500;
  292. cursor: pointer;
  293. border: none;
  294. transition: all 0.2s ease;
  295. white-space: nowrap;
  296. &.edit-btn {
  297. background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
  298. color: #fff;
  299. border: 1px solid rgba(10, 132, 255, 0.5);
  300. box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
  301. &:hover {
  302. background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
  303. box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
  304. }
  305. }
  306. &.cancel-btn {
  307. background: rgba(255, 255, 255, 0.06);
  308. color: #8b949e;
  309. border: 1px solid rgba(63, 80, 106, 0.4);
  310. &:hover {
  311. background: rgba(255, 255, 255, 0.1);
  312. color: #c9d1d9;
  313. border-color: rgba(63, 80, 106, 0.6);
  314. }
  315. }
  316. &.save-btn {
  317. background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
  318. color: #fff;
  319. border: 1px solid rgba(10, 132, 255, 0.5);
  320. box-shadow: 0 2px 6px rgba(10, 132, 255, 0.25);
  321. &:hover:not(:disabled) {
  322. background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
  323. box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
  324. }
  325. &:disabled {
  326. opacity: 0.6;
  327. cursor: not-allowed;
  328. }
  329. &.saving {
  330. background: linear-gradient(135deg, #3a8fd4 0%, #2a6fa0 100%);
  331. }
  332. }
  333. .spin-icon {
  334. animation: spin 1s linear infinite;
  335. }
  336. @keyframes spin {
  337. from { transform: rotate(0deg); }
  338. to { transform: rotate(360deg); }
  339. }
  340. }
  341. }
  342. .close-btn {
  343. display: flex;
  344. align-items: center;
  345. justify-content: center;
  346. width: 36px;
  347. height: 36px;
  348. cursor: pointer;
  349. transition: all 0.3s;
  350. flex-shrink: 0;
  351. margin-bottom: auto;
  352. .btn-close-icon {
  353. width: 20px;
  354. height: 20px;
  355. background-image: var(--img-chat-close-icon-btn);
  356. background-repeat: no-repeat;
  357. background-size: 100% 100%;
  358. }
  359. }
  360. }
  361. .panel-content {
  362. flex: 1;
  363. overflow-y: auto;
  364. padding: 16px;
  365. }
  366. .file-preview-content {
  367. height: 100%;
  368. .image-preview {
  369. display: flex;
  370. align-items: center;
  371. justify-content: center;
  372. width: 100%;
  373. height: 100%;
  374. img {
  375. width: 100%;
  376. height: 100%;
  377. object-fit: contain;
  378. border-radius: 4px;
  379. }
  380. }
  381. .pdf-preview {
  382. width: 100%;
  383. height: 100%;
  384. iframe {
  385. width: 100%;
  386. height: 100%;
  387. border: none;
  388. }
  389. }
  390. .word-preview {
  391. width: calc(100% + 32px);
  392. height: calc(100% + 32px);
  393. margin: -16px;
  394. overflow: auto auto;
  395. :deep(.docx-wrapper) {
  396. display: inline-block;
  397. padding: 0;
  398. background: transparent;
  399. min-width: 100%;
  400. }
  401. }
  402. .excel-preview {
  403. display: flex;
  404. flex-direction: column;
  405. width: calc(100% + 32px);
  406. height: calc(100% + 32px);
  407. margin: -16px;
  408. overflow: hidden;
  409. .excel-tabs {
  410. display: flex;
  411. gap: 0;
  412. border-bottom: 1px solid rgb(255 255 255 / 10%);
  413. flex-shrink: 0;
  414. padding: 0 16px;
  415. .excel-tab {
  416. padding: 8px 16px;
  417. font-size: 13px;
  418. color: #8b949e;
  419. cursor: pointer;
  420. border-bottom: 2px solid transparent;
  421. transition: all 0.2s;
  422. &:hover {
  423. color: #e0e6ed;
  424. }
  425. &.active {
  426. color: #e0e6ed;
  427. border-bottom-color: #3a8fd4;
  428. }
  429. }
  430. }
  431. .excel-table-wrapper {
  432. flex: 1;
  433. padding: 0 16px;
  434. overflow: auto;
  435. .excel-table {
  436. border-collapse: collapse;
  437. width: max-content;
  438. min-width: 100%;
  439. font-size: 13px;
  440. th,
  441. td {
  442. padding: 6px 10px;
  443. border: 1px solid rgb(255 255 255 / 15%);
  444. text-align: left;
  445. white-space: nowrap;
  446. }
  447. th {
  448. position: sticky;
  449. top: 0;
  450. background: rgb(10 132 255 / 15%);
  451. color: #e0e6ed;
  452. font-weight: 600;
  453. }
  454. td {
  455. color: #c9d1d9;
  456. }
  457. tr:hover td {
  458. background: rgb(10 132 255 / 8%);
  459. }
  460. }
  461. }
  462. }
  463. .csv-preview {
  464. width: 100%;
  465. height: 100%;
  466. overflow: auto;
  467. .csv-table {
  468. border-collapse: collapse;
  469. width: 100%;
  470. font-size: 13px;
  471. th,
  472. td {
  473. padding: 6px 10px;
  474. border: 1px solid rgb(255 255 255 / 15%);
  475. text-align: left;
  476. white-space: nowrap;
  477. }
  478. th {
  479. position: sticky;
  480. top: 0;
  481. background: rgb(10 132 255 / 15%);
  482. color: #e0e6ed;
  483. font-weight: 600;
  484. }
  485. td {
  486. color: #c9d1d9;
  487. }
  488. tr:hover td {
  489. background: rgb(10 132 255 / 8%);
  490. }
  491. }
  492. }
  493. .doc-preview {
  494. width: 100%;
  495. height: 100%;
  496. overflow: auto;
  497. .doc-html-body {
  498. color: #e0e6ed;
  499. font-size: 14px;
  500. line-height: 1.8;
  501. :deep(table) {
  502. border-collapse: collapse;
  503. width: 100%;
  504. margin-bottom: 12px;
  505. th,
  506. td {
  507. padding: 8px 12px;
  508. border: 1px solid rgb(255 255 255 / 15%);
  509. text-align: left;
  510. }
  511. th {
  512. background: rgb(10 132 255 / 10%);
  513. font-weight: 600;
  514. }
  515. }
  516. :deep(img) {
  517. max-width: 100%;
  518. }
  519. :deep(p) {
  520. margin-bottom: 8px;
  521. }
  522. }
  523. }
  524. .markdown-preview {
  525. .markdown-body {
  526. color: #e0e6ed;
  527. font-size: 14px;
  528. line-height: 1.8;
  529. :deep(h1),
  530. :deep(h2),
  531. :deep(h3),
  532. :deep(h4),
  533. :deep(h5),
  534. :deep(h6) {
  535. color: #fff;
  536. margin-top: 16px;
  537. margin-bottom: 8px;
  538. font-weight: 600;
  539. }
  540. :deep(h1) {
  541. font-size: 24px;
  542. padding-bottom: 8px;
  543. border-bottom: 1px solid rgb(255 255 255 / 10%);
  544. }
  545. :deep(h2) {
  546. font-size: 20px;
  547. padding-bottom: 6px;
  548. border-bottom: 1px solid rgb(255 255 255 / 10%);
  549. }
  550. :deep(h3) {
  551. font-size: 16px;
  552. }
  553. :deep(p) {
  554. margin-bottom: 12px;
  555. }
  556. :deep(code) {
  557. padding: 2px 6px;
  558. background: rgb(10 132 255 / 15%);
  559. color: #79c0ff;
  560. border-radius: 4px;
  561. font-family: monospace;
  562. font-size: 13px;
  563. }
  564. :deep(pre) {
  565. padding: 12px 16px;
  566. background: rgb(0 0 0 / 30%);
  567. border-radius: 6px;
  568. overflow-x: auto;
  569. margin-bottom: 12px;
  570. code {
  571. padding: 0;
  572. background: none;
  573. color: #e0e6ed;
  574. }
  575. }
  576. :deep(blockquote) {
  577. border-left: 3px solid #3a8fd4;
  578. padding-left: 12px;
  579. margin: 12px 0;
  580. color: #8b949e;
  581. }
  582. :deep(ul),
  583. :deep(ol) {
  584. padding-left: 24px;
  585. margin-bottom: 12px;
  586. li {
  587. margin-bottom: 4px;
  588. }
  589. }
  590. :deep(table) {
  591. border-collapse: collapse;
  592. width: 100%;
  593. margin-bottom: 12px;
  594. th,
  595. td {
  596. padding: 8px 12px;
  597. border: 1px solid rgb(255 255 255 / 15%);
  598. text-align: left;
  599. }
  600. th {
  601. background: rgb(10 132 255 / 10%);
  602. font-weight: 600;
  603. }
  604. tr:hover td {
  605. background: rgb(10 132 255 / 5%);
  606. }
  607. }
  608. :deep(a) {
  609. color: #3a8fd4;
  610. text-decoration: none;
  611. &:hover {
  612. text-decoration: underline;
  613. }
  614. }
  615. :deep(img) {
  616. max-width: 100%;
  617. border-radius: 4px;
  618. }
  619. :deep(hr) {
  620. margin: 16px 0;
  621. border: none;
  622. border-top: 1px solid rgb(255 255 255 / 10%);
  623. }
  624. }
  625. }
  626. .text-preview {
  627. pre {
  628. white-space: pre-wrap;
  629. word-wrap: break-word;
  630. font-family: monospace;
  631. font-size: 13px;
  632. color: #e0e6ed;
  633. }
  634. }
  635. .edit-mode {
  636. height: 100%;
  637. display: flex;
  638. flex-direction: column;
  639. .edit-textarea {
  640. flex: 1;
  641. width: 100%;
  642. min-height: 100%;
  643. padding: 12px;
  644. font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
  645. font-size: 13px;
  646. line-height: 1.6;
  647. color: #e0e6ed;
  648. background: rgba(0, 0, 0, 0.2);
  649. border: 1px solid rgba(63, 80, 106, 0.4);
  650. border-radius: 6px;
  651. outline: none;
  652. resize: none;
  653. tab-size: 4;
  654. &:focus {
  655. border-color: #0a84ff;
  656. background: rgba(0, 0, 0, 0.3);
  657. }
  658. }
  659. }
  660. .no-preview {
  661. display: flex;
  662. flex-direction: column;
  663. align-items: center;
  664. justify-content: center;
  665. height: 100%;
  666. gap: 12px;
  667. .no-preview-icon {
  668. width: 64px;
  669. height: 64px;
  670. background-image: var(--img-chat-attach-icon);
  671. background-repeat: no-repeat;
  672. background-size: 100% 100%;
  673. opacity: 0.4;
  674. }
  675. }
  676. .no-preview-text {
  677. color: #8b949e;
  678. font-size: 14px;
  679. }
  680. .no-preview-hint {
  681. color: #6e7681;
  682. font-size: 12px;
  683. }
  684. }
  685. </style>