|
|
@@ -2,82 +2,106 @@
|
|
|
<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="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 v-if="isEditing" class="edit-mode">
|
|
|
+ <textarea v-model="editContent" class="edit-textarea" spellcheck="false"></textarea>
|
|
|
</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 }}
|
|
|
+ <!-- 预览模式 -->
|
|
|
+ <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-if="excelLoading" class="no-preview-text">加载中...</div>
|
|
|
- <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
|
|
|
- <table class="excel-table">
|
|
|
+ <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 currentSheetData[0]" :key="i">{{ cell }}</th>
|
|
|
+ <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
- <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
|
|
|
+ <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 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>
|
|
|
+ <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 {
|
|
|
@@ -94,14 +118,67 @@
|
|
|
} from './utils';
|
|
|
import type { AttachedFile } from './types';
|
|
|
|
|
|
- const props = defineProps<{
|
|
|
- file: AttachedFile;
|
|
|
- }>();
|
|
|
+ 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);
|
|
|
@@ -221,6 +298,81 @@
|
|
|
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 {
|
|
|
cursor: pointer;
|
|
|
width: 36px;
|
|
|
@@ -570,6 +722,34 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ .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;
|