| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780 |
- <template>
- <div class="file-preview-panel">
- <div class="panel-header">
- <span class="panel-title">{{ file.name }}</span>
- <div class="panel-header-actions">
- <button v-if="editable && !isEditing" class="header-action-btn edit-btn" @click="enterEdit">
- <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
- <span>编辑</span>
- </button>
- <button v-if="isEditing" class="header-action-btn cancel-btn" @click="cancelEdit">
- <img :src="getCssUrl('--img-chat-close-icon-btn')" width="14" height="14" />
- <span>取消</span>
- </button>
- <button v-if="isEditing" class="header-action-btn save-btn" :class="{ saving }" :disabled="saving" @click="saveEdit">
- <img v-if="!saving" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
- <img v-else class="spin-icon" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
- <span>{{ saving ? '保存中...' : '保存' }}</span>
- </button>
- </div>
- <div class="close-btn" @click="emit('close')">
- <div class="btn-close-icon"></div>
- </div>
- </div>
- <div class="panel-content file-preview-content">
- <!-- 编辑模式 -->
- <div v-if="isEditing" class="edit-mode">
- <textarea v-model="editContent" class="edit-textarea" spellcheck="false"></textarea>
- </div>
- <!-- 预览模式 -->
- <template v-else>
- <div v-if="isImageFile(file.name)" class="image-preview">
- <img :src="file.preview" alt="预览图片" />
- </div>
- <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
- <iframe v-if="file.preview" :src="pdfSrc" frameborder="0"></iframe>
- <div v-else class="no-preview-text">无法加载PDF预览</div>
- </div>
- <div v-else-if="isOldDocFile(file.name)" class="no-preview">
- <div class="no-preview-icon"></div>
- <div class="no-preview-text">暂不支持 .doc 格式预览</div>
- <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
- </div>
- <div v-else-if="isWordFile(file.name)" class="word-preview">
- <VueOfficeDocx v-if="file.arrayBuffer" :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
- <div v-else class="no-preview-text">无法加载Word文档</div>
- </div>
- <div v-else-if="isExcelFile(file.name)" class="excel-preview">
- <div v-if="excelSheets.length > 1" class="excel-tabs">
- <div v-for="(sheet, i) in excelSheets" :key="i" :class="['excel-tab', { active: activeSheet === i }]" @click="activeSheet = i">
- {{ sheet.name }}
- </div>
- </div>
- <div v-if="excelLoading" class="no-preview-text">加载中...</div>
- <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
- <table class="excel-table">
- <thead>
- <tr>
- <th v-for="(cell, i) in currentSheetData[0]" :key="i">{{ cell }}</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
- <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
- </tr>
- </tbody>
- </table>
- </div>
- <div v-else class="no-preview-text">Excel文件内容为空</div>
- </div>
- <div v-else-if="isCsvFile(file.name)" class="csv-preview">
- <table v-if="csvRows.length > 0" class="csv-table">
- <thead>
- <tr>
- <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="(row, ri) in csvRows.slice(1)" :key="ri">
- <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
- </tr>
- </tbody>
- </table>
- <div v-else class="no-preview-text">CSV文件内容为空</div>
- </div>
- <div v-else-if="isMdFile(file.name)" class="markdown-preview">
- <div class="markdown-body" v-html="renderedMarkdown"></div>
- </div>
- <div v-else-if="isTextFile(file.name)" class="text-preview">
- <pre>{{ file.content || '文件内容加载中...' }}</pre>
- </div>
- <div v-else class="no-preview">
- <div class="no-preview-icon"></div>
- <div class="no-preview-text">不支持预览此文件格式</div>
- <div class="no-preview-hint">{{ getFileExtension(file.name) }} 格式文件暂不支持在线预览</div>
- </div>
- </template>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, computed, watch } from 'vue';
- import { message } from 'ant-design-vue';
- import { saveReportText } from '../../api';
- import VueOfficeDocx from '@vue-office/docx/lib/v3/vue-office-docx.mjs';
- import * as XLSX from 'xlsx';
- import {
- isImageFile,
- isPdfFile,
- isWordFile,
- isOldDocFile,
- isExcelFile,
- isCsvFile,
- isMdFile,
- isTextFile,
- getFileExtension,
- renderMarkdown,
- } from './utils';
- import type { AttachedFile } from './types';
- const getCssUrl = (varName: string) => {
- const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
- return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
- };
- const props = withDefaults(
- defineProps<{
- file: AttachedFile;
- editable?: boolean;
- saveFilename?: string;
- }>(),
- { editable: false, saveFilename: '' }
- );
- const emit = defineEmits<{
- (e: 'close'): void;
- (e: 'save-success', content: string): void;
- }>();
- // 编辑模式
- const isEditing = ref(false);
- const editContent = ref('');
- const saving = ref(false);
- const enterEdit = () => {
- editContent.value = props.file.content || '';
- isEditing.value = true;
- };
- const cancelEdit = () => {
- isEditing.value = false;
- editContent.value = '';
- };
- const saveEdit = async () => {
- if (!props.saveFilename) return;
- saving.value = true;
- try {
- await saveReportText(props.saveFilename, editContent.value);
- message.success('保存成功');
- emit('save-success', editContent.value);
- } catch (err) {
- console.error('保存文件失败:', err);
- message.error('保存文件失败');
- } finally {
- saving.value = false;
- }
- };
- // 文件内容更新后(保存成功),自动退出编辑模式
- watch(
- () => props.file.content,
- () => {
- if (isEditing.value) {
- isEditing.value = false;
- editContent.value = '';
- saving.value = false;
- }
- }
- );
- // Excel 解析
- const excelSheets = ref<{ name: string; data: string[][] }[]>([]);
- const activeSheet = ref(0);
- const excelLoading = ref(false);
- const currentSheetData = computed(() => {
- return excelSheets.value[activeSheet.value]?.data || [];
- });
- const parseExcel = async (file: AttachedFile) => {
- excelLoading.value = true;
- try {
- let buffer = file.arrayBuffer;
- if (!buffer && file.originalFile) {
- buffer = await file.originalFile.arrayBuffer();
- }
- if (!buffer) return;
- const workbook = XLSX.read(buffer, { type: 'array' });
- excelSheets.value = workbook.SheetNames.map((name) => {
- const sheet = workbook.Sheets[name];
- const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, defval: '' });
- return { name, data: rows };
- });
- activeSheet.value = 0;
- } catch (err) {
- console.error('Excel解析失败:', err);
- excelSheets.value = [];
- } finally {
- excelLoading.value = false;
- }
- };
- watch(
- () => props.file,
- (file) => {
- if (isExcelFile(file.name)) {
- parseExcel(file);
- }
- },
- { immediate: true }
- );
- const pdfSrc = computed(() => {
- const base = props.file.preview || '';
- if (!base) return '';
- return `${base}#toolbar=0&navpanes=0&scrollbar=0`;
- });
- const renderedMarkdown = computed(() => {
- return renderMarkdown(props.file.content || '');
- });
- const csvRows = computed(() => {
- const content = props.file.content || '';
- if (!content.trim()) return [];
- return content
- .split(/\r?\n/)
- .filter((line) => line.trim())
- .map((line) => {
- const cells: string[] = [];
- let current = '';
- let inQuotes = false;
- for (let i = 0; i < line.length; i++) {
- const ch = line[i];
- if (ch === '"') {
- if (inQuotes && line[i + 1] === '"') {
- current += '"';
- i++;
- } else {
- inQuotes = !inQuotes;
- }
- } else if (ch === ',' && !inQuotes) {
- cells.push(current);
- current = '';
- } else {
- current += ch;
- }
- }
- cells.push(current);
- return cells;
- });
- });
- const handleRendered = () => {
- console.log('文件渲染完成');
- };
- const handleError = (err: any) => {
- console.error('文件预览失败:', err);
- };
- </script>
- <style scoped lang="less">
- .file-preview-panel {
- display: flex;
- flex-direction: column;
- height: 100%;
- }
- .panel-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 10px 5px;
- flex-shrink: 0;
- border-bottom: 1px solid rgb(63 80 106 / 25%);
- .panel-title {
- flex: 1;
- color: #e0e6ed;
- overflow: hidden;
- font-size: 15px;
- font-weight: 600;
- line-height: 1.4;
- text-overflow: ellipsis;
- white-space: nowrap;
- margin-right: 10px;
- }
- .panel-header-actions {
- display: flex;
- gap: 6px;
- flex-shrink: 0;
- margin-right: 6px;
- .header-action-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 4px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
- border: none;
- transition: all 0.2s ease;
- white-space: nowrap;
- &.edit-btn {
- background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
- color: #fff;
- border: 1px solid rgba(10, 132, 255, 0.5);
- box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
- &:hover {
- background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
- box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
- }
- }
- &.cancel-btn {
- background: rgba(255, 255, 255, 0.06);
- color: #8b949e;
- border: 1px solid rgba(63, 80, 106, 0.4);
- &:hover {
- background: rgba(255, 255, 255, 0.1);
- color: #c9d1d9;
- border-color: rgba(63, 80, 106, 0.6);
- }
- }
- &.save-btn {
- background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
- color: #fff;
- border: 1px solid rgba(10, 132, 255, 0.5);
- box-shadow: 0 2px 6px rgba(10, 132, 255, 0.25);
- &:hover:not(:disabled) {
- background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
- box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
- }
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
- &.saving {
- background: linear-gradient(135deg, #3a8fd4 0%, #2a6fa0 100%);
- }
- }
- .spin-icon {
- animation: spin 1s linear infinite;
- }
- @keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
- }
- }
- }
- .close-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 36px;
- height: 36px;
- cursor: pointer;
- transition: all 0.3s;
- flex-shrink: 0;
- margin-bottom: auto;
- .btn-close-icon {
- width: 20px;
- height: 20px;
- background-image: var(--img-chat-close-icon-btn);
- background-repeat: no-repeat;
- background-size: 100% 100%;
- }
- }
- }
- .panel-content {
- flex: 1;
- overflow-y: auto;
- padding: 16px;
- }
- .file-preview-content {
- height: 100%;
- .image-preview {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- img {
- width: 100%;
- height: 100%;
- object-fit: contain;
- border-radius: 4px;
- }
- }
- .pdf-preview {
- width: 100%;
- height: 100%;
- iframe {
- width: 100%;
- height: 100%;
- border: none;
- }
- }
- .word-preview {
- width: calc(100% + 32px);
- height: calc(100% + 32px);
- margin: -16px;
- overflow: auto auto;
- :deep(.docx-wrapper) {
- display: inline-block;
- padding: 0;
- background: transparent;
- min-width: 100%;
- }
- }
- .excel-preview {
- display: flex;
- flex-direction: column;
- width: calc(100% + 32px);
- height: calc(100% + 32px);
- margin: -16px;
- overflow: hidden;
- .excel-tabs {
- display: flex;
- gap: 0;
- border-bottom: 1px solid rgb(255 255 255 / 10%);
- flex-shrink: 0;
- padding: 0 16px;
- .excel-tab {
- padding: 8px 16px;
- font-size: 13px;
- color: #8b949e;
- cursor: pointer;
- border-bottom: 2px solid transparent;
- transition: all 0.2s;
- &:hover {
- color: #e0e6ed;
- }
- &.active {
- color: #e0e6ed;
- border-bottom-color: #3a8fd4;
- }
- }
- }
- .excel-table-wrapper {
- flex: 1;
- padding: 0 16px;
- overflow: auto;
- .excel-table {
- border-collapse: collapse;
- width: max-content;
- min-width: 100%;
- font-size: 13px;
- th,
- td {
- padding: 6px 10px;
- border: 1px solid rgb(255 255 255 / 15%);
- text-align: left;
- white-space: nowrap;
- }
- th {
- position: sticky;
- top: 0;
- background: rgb(10 132 255 / 15%);
- color: #e0e6ed;
- font-weight: 600;
- }
- td {
- color: #c9d1d9;
- }
- tr:hover td {
- background: rgb(10 132 255 / 8%);
- }
- }
- }
- }
- .csv-preview {
- width: 100%;
- height: 100%;
- overflow: auto;
- .csv-table {
- border-collapse: collapse;
- width: 100%;
- font-size: 13px;
- th,
- td {
- padding: 6px 10px;
- border: 1px solid rgb(255 255 255 / 15%);
- text-align: left;
- white-space: nowrap;
- }
- th {
- position: sticky;
- top: 0;
- background: rgb(10 132 255 / 15%);
- color: #e0e6ed;
- font-weight: 600;
- }
- td {
- color: #c9d1d9;
- }
- tr:hover td {
- background: rgb(10 132 255 / 8%);
- }
- }
- }
- .doc-preview {
- width: 100%;
- height: 100%;
- overflow: auto;
- .doc-html-body {
- color: #e0e6ed;
- font-size: 14px;
- line-height: 1.8;
- :deep(table) {
- border-collapse: collapse;
- width: 100%;
- margin-bottom: 12px;
- th,
- td {
- padding: 8px 12px;
- border: 1px solid rgb(255 255 255 / 15%);
- text-align: left;
- }
- th {
- background: rgb(10 132 255 / 10%);
- font-weight: 600;
- }
- }
- :deep(img) {
- max-width: 100%;
- }
- :deep(p) {
- margin-bottom: 8px;
- }
- }
- }
- .markdown-preview {
- .markdown-body {
- color: #e0e6ed;
- font-size: 14px;
- line-height: 1.8;
- :deep(h1),
- :deep(h2),
- :deep(h3),
- :deep(h4),
- :deep(h5),
- :deep(h6) {
- color: #fff;
- margin-top: 16px;
- margin-bottom: 8px;
- font-weight: 600;
- }
- :deep(h1) {
- font-size: 24px;
- padding-bottom: 8px;
- border-bottom: 1px solid rgb(255 255 255 / 10%);
- }
- :deep(h2) {
- font-size: 20px;
- padding-bottom: 6px;
- border-bottom: 1px solid rgb(255 255 255 / 10%);
- }
- :deep(h3) {
- font-size: 16px;
- }
- :deep(p) {
- margin-bottom: 12px;
- }
- :deep(code) {
- padding: 2px 6px;
- background: rgb(10 132 255 / 15%);
- color: #79c0ff;
- border-radius: 4px;
- font-family: monospace;
- font-size: 13px;
- }
- :deep(pre) {
- padding: 12px 16px;
- background: rgb(0 0 0 / 30%);
- border-radius: 6px;
- overflow-x: auto;
- margin-bottom: 12px;
- code {
- padding: 0;
- background: none;
- color: #e0e6ed;
- }
- }
- :deep(blockquote) {
- border-left: 3px solid #3a8fd4;
- padding-left: 12px;
- margin: 12px 0;
- color: #8b949e;
- }
- :deep(ul),
- :deep(ol) {
- padding-left: 24px;
- margin-bottom: 12px;
- li {
- margin-bottom: 4px;
- }
- }
- :deep(table) {
- border-collapse: collapse;
- width: 100%;
- margin-bottom: 12px;
- th,
- td {
- padding: 8px 12px;
- border: 1px solid rgb(255 255 255 / 15%);
- text-align: left;
- }
- th {
- background: rgb(10 132 255 / 10%);
- font-weight: 600;
- }
- tr:hover td {
- background: rgb(10 132 255 / 5%);
- }
- }
- :deep(a) {
- color: #3a8fd4;
- text-decoration: none;
- &:hover {
- text-decoration: underline;
- }
- }
- :deep(img) {
- max-width: 100%;
- border-radius: 4px;
- }
- :deep(hr) {
- margin: 16px 0;
- border: none;
- border-top: 1px solid rgb(255 255 255 / 10%);
- }
- }
- }
- .text-preview {
- pre {
- white-space: pre-wrap;
- word-wrap: break-word;
- font-family: monospace;
- font-size: 13px;
- color: #e0e6ed;
- }
- }
- .edit-mode {
- height: 100%;
- display: flex;
- flex-direction: column;
- .edit-textarea {
- flex: 1;
- width: 100%;
- min-height: 100%;
- padding: 12px;
- font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
- font-size: 13px;
- line-height: 1.6;
- color: #e0e6ed;
- background: rgba(0, 0, 0, 0.2);
- border: 1px solid rgba(63, 80, 106, 0.4);
- border-radius: 6px;
- outline: none;
- resize: none;
- tab-size: 4;
- &:focus {
- border-color: #0a84ff;
- background: rgba(0, 0, 0, 0.3);
- }
- }
- }
- .no-preview {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- gap: 12px;
- .no-preview-icon {
- width: 64px;
- height: 64px;
- background-image: var(--img-chat-attach-icon);
- background-repeat: no-repeat;
- background-size: 100% 100%;
- opacity: 0.4;
- }
- }
- .no-preview-text {
- color: #8b949e;
- font-size: 14px;
- }
- .no-preview-hint {
- color: #6e7681;
- font-size: 12px;
- }
- }
- </style>
|