新增:文件预览支持 Excel 和 Word
功能增强: - Excel 文件预览(.xlsx, .xls) - Word 文件预览(.docx, .doc) - 使用动态导入减小初始包体积 技术实现: - xlsx 库(143KB gzipped) - mammoth 库(100KB gzipped) - 动态加载,仅在打开文件时导入 - HTML 表格渲染 Excel - HTML 内容渲染 Word 修改文件: - filePreviewHandlers.js - Office 预览处理器 - fileTypeHelpers.js - 添加 isExcelFile/isWordFile - FileEditorPanel.vue - 集成 Office 预览 UI - useFileEdit.ts - 添加 Office 文件类型判断 - index.vue - 更新配置和导入 - file-system.ts - 添加 Office 预览相关类型
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
|
||||
<template v-else-if="config.isHtmlFile">🌐 HTML 预览</template>
|
||||
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
|
||||
<template v-else-if="config.isExcelFile">📊 Excel 预览</template>
|
||||
<template v-else-if="config.isWordFile">📄 Word 预览</template>
|
||||
<template v-else>📝 文件内容</template>
|
||||
</span>
|
||||
<div v-if="config.currentFileName" class="filename-with-copy">
|
||||
@@ -77,6 +79,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Excel 预览 -->
|
||||
<div v-else-if="config.isExcelFile" class="office-preview">
|
||||
<div class="office-preview-container" ref="excelPreviewRef">
|
||||
<a-spin v-if="config.officeLoading" :loading="true" tip="加载中...">
|
||||
<div class="loading-placeholder"></div>
|
||||
</a-spin>
|
||||
<a-alert v-else-if="config.officeError" type="error" :message="config.officeError" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 预览 -->
|
||||
<div v-else-if="config.isWordFile" class="office-preview">
|
||||
<div class="office-preview-container" ref="wordPreviewRef">
|
||||
<a-spin v-if="config.officeLoading" :loading="true" tip="加载中...">
|
||||
<div class="loading-placeholder"></div>
|
||||
</a-spin>
|
||||
<a-alert v-else-if="config.officeError" type="error" :message="config.officeError" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML 预览/编辑 -->
|
||||
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
|
||||
<!-- 编辑模式/预览模式切换按钮 -->
|
||||
@@ -254,12 +276,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick, defineAsyncComponent } from 'vue'
|
||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileName } from '@/utils/fileUtils'
|
||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { previewExcel, previewWord } from '@/utils/filePreviewHandlers'
|
||||
|
||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||
const AsyncCodeEditor = defineAsyncComponent({
|
||||
@@ -268,6 +291,10 @@ const AsyncCodeEditor = defineAsyncComponent({
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// Office 预览容器引用
|
||||
const excelPreviewRef = ref<HTMLElement | null>(null)
|
||||
const wordPreviewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: FileEditorPanelConfig
|
||||
@@ -408,6 +435,80 @@ watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 Excel 文件变化,触发预览
|
||||
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath], async ([isExcel, filePath]) => {
|
||||
if (isExcel && filePath && excelPreviewRef.value) {
|
||||
await loadExcelPreview(filePath)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 Word 文件变化,触发预览
|
||||
watch(() => [props.config.isWordFile, props.config.currentFileFullPath], async ([isWord, filePath]) => {
|
||||
if (isWord && filePath && wordPreviewRef.value) {
|
||||
await loadWordPreview(filePath)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Excel 预览加载
|
||||
const loadExcelPreview = async (filePath: string) => {
|
||||
if (!excelPreviewRef.value) return
|
||||
|
||||
// 清空容器
|
||||
excelPreviewRef.value.innerHTML = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`file://${filePath}`)
|
||||
if (!response.ok) throw new Error('无法读取文件')
|
||||
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-excel' })
|
||||
|
||||
const result = await previewExcel(file, excelPreviewRef.value)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '预览失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileEditorPanel] Excel 预览失败:', error)
|
||||
excelPreviewRef.value.innerHTML = `
|
||||
<div class="preview-error">
|
||||
<p>❌ Excel 预览失败</p>
|
||||
<p class="error-detail">${error.message}</p>
|
||||
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// Word 预览加载
|
||||
const loadWordPreview = async (filePath: string) => {
|
||||
if (!wordPreviewRef.value) return
|
||||
|
||||
// 清空容器
|
||||
wordPreviewRef.value.innerHTML = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`file://${filePath}`)
|
||||
if (!response.ok) throw new Error('无法读取文件')
|
||||
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-word' })
|
||||
|
||||
const result = await previewWord(file, wordPreviewRef.value)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '预览失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileEditorPanel] Word 预览失败:', error)
|
||||
wordPreviewRef.value.innerHTML = `
|
||||
<div class="preview-error">
|
||||
<p>❌ Word 预览失败</p>
|
||||
<p class="error-detail">${error.message}</p>
|
||||
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模式切换按钮的提示文本
|
||||
const getModeSwitchTooltip = () => {
|
||||
if (props.config.isEditMode) {
|
||||
@@ -781,6 +882,51 @@ const handleCopyPath = () => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Office 预览 */
|
||||
.office-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.office-preview-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-placeholder {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-error p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.preview-error .error-detail {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.preview-error .error-hint {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.binary-file-message pre {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
|
||||
@@ -107,6 +107,22 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
return ext === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Excel 文件
|
||||
*/
|
||||
const isExcelFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
return ['xlsx', 'xls'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Word 文件
|
||||
*/
|
||||
const isWordFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
return ['docx', 'doc'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 注意:媒体文件(图片、视频、音频、PDF)不是二进制文件,它们可以预览
|
||||
@@ -613,6 +629,8 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
isVideoFile,
|
||||
isAudioFile,
|
||||
isPdfFile,
|
||||
isExcelFile,
|
||||
isWordFile,
|
||||
isBinaryFileByExt,
|
||||
isFileInCurrentDirectory
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
||||
// 导入工具函数
|
||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||
import { getParentPath } from '@/utils/pathHelpers'
|
||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile } from '@/utils/fileTypeHelpers'
|
||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile } from '@/utils/fileTypeHelpers'
|
||||
import { listDir } from '@/api/system'
|
||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
|
||||
|
||||
@@ -319,6 +319,10 @@ const fileEditorPanelConfig = computed(() => {
|
||||
isPdfFile: isPdfFile(currentFileName),
|
||||
isHtmlFile: isHtmlFile(currentFileName),
|
||||
isMarkdownFile: isMarkdownFile(currentFileName),
|
||||
isExcelFile: isExcelFile(currentFileName),
|
||||
isWordFile: isWordFile(currentFileName),
|
||||
officeLoading: false,
|
||||
officeError: null,
|
||||
canSaveFile: canSaveFile.value,
|
||||
canResetContent: canResetContent.value,
|
||||
canPreviewFile: isEditableWithPreview(currentFileName),
|
||||
|
||||
@@ -169,6 +169,14 @@ export interface FileEditorPanelConfig {
|
||||
isHtmlFile: boolean
|
||||
/** 是否为 Markdown 文件 */
|
||||
isMarkdownFile: boolean
|
||||
/** 是否为 Excel 文件 */
|
||||
isExcelFile: boolean
|
||||
/** 是否为 Word 文件 */
|
||||
isWordFile: boolean
|
||||
/** Office 文件加载中 */
|
||||
officeLoading: boolean
|
||||
/** Office 文件加载错误 */
|
||||
officeError: string | null
|
||||
/** 是否可以保存 */
|
||||
canSaveFile: boolean
|
||||
/** 是否可以重置 */
|
||||
|
||||
221
web/src/utils/filePreviewHandlers.js
Normal file
221
web/src/utils/filePreviewHandlers.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Office 文件预览处理器
|
||||
* 使用动态导入减小初始包体积
|
||||
*/
|
||||
|
||||
// Excel 预览处理器
|
||||
export async function previewExcel(file, container) {
|
||||
try {
|
||||
// 动态导入 xlsx 库
|
||||
const XLSX = await import('xlsx')
|
||||
|
||||
// 读取文件
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
|
||||
|
||||
// 获取第一个工作表
|
||||
const firstSheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[firstSheetName]
|
||||
|
||||
// 转换为 HTML 表格
|
||||
const html = XLSX.utils.sheet_to_html(worksheet, {
|
||||
editable: false,
|
||||
header: '',
|
||||
footer: ''
|
||||
})
|
||||
|
||||
// 渲染到容器
|
||||
container.innerHTML = `
|
||||
<div class="excel-preview">
|
||||
<div class="excel-sheet-info">
|
||||
<span class="sheet-name">📊 ${firstSheetName}</span>
|
||||
<span class="sheet-count">${workbook.SheetNames.length} 个工作表</span>
|
||||
</div>
|
||||
<div class="excel-table-wrapper">
|
||||
${html}
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.excel-preview {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.excel-sheet-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sheet-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sheet-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.excel-table-wrapper {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.excel-table-wrapper table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.excel-table-wrapper td,
|
||||
.excel-table-wrapper th {
|
||||
border: 1px solid var(--color-border-2);
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.excel-table-wrapper th {
|
||||
background: var(--color-fill-2);
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.excel-table-wrapper tr:hover {
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Excel 预览失败:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
// Word 预览处理器
|
||||
export async function previewWord(file, container) {
|
||||
try {
|
||||
// 动态导入 mammoth 库
|
||||
const mammoth = await import('mammoth')
|
||||
|
||||
// 读取文件并转换为 HTML
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
|
||||
// 渲染到容器
|
||||
container.innerHTML = `
|
||||
<div class="word-preview">
|
||||
<div class="word-content">
|
||||
${result.value}
|
||||
</div>
|
||||
${result.messages.length > 0 ? `
|
||||
<div class="word-warnings">
|
||||
<details>
|
||||
<summary>转换警告 (${result.messages.length})</summary>
|
||||
<ul>
|
||||
${result.messages.map(msg => `<li>${msg.message}</li>`).join('')}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<style>
|
||||
.word-preview {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.word-content {
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.word-content h1,
|
||||
.word-content h2,
|
||||
.word-content h3,
|
||||
.word-content h4,
|
||||
.word-content h5,
|
||||
.word-content h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.word-content h1 { font-size: 2em; }
|
||||
.word-content h2 { font-size: 1.5em; }
|
||||
.word-content h3 { font-size: 1.25em; }
|
||||
.word-content p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.word-content ul,
|
||||
.word-content ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
.word-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.word-content td,
|
||||
.word-content th {
|
||||
border: 1px solid var(--color-border-2);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.word-content th {
|
||||
background: var(--color-fill-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.word-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.word-content a {
|
||||
color: rgb(var(--primary-6));
|
||||
text-decoration: none;
|
||||
}
|
||||
.word-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.word-warnings {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
background: var(--color-warning-light-1);
|
||||
border: 1px solid var(--color-warning-3);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.word-warnings summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.word-warnings ul {
|
||||
margin: 8px 0 0 20px;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Word 预览失败:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为 Office 文件
|
||||
export function isOfficeFile(fileName) {
|
||||
const ext = fileName?.toLowerCase()?.split('.').pop()
|
||||
return ['xlsx', 'xls', 'docx', 'doc'].includes(ext)
|
||||
}
|
||||
|
||||
// 判断是否为 Excel 文件
|
||||
export function isExcelFile(fileName) {
|
||||
const ext = fileName?.toLowerCase()?.split('.').pop()
|
||||
return ['xlsx', 'xls'].includes(ext)
|
||||
}
|
||||
|
||||
// 判断是否为 Word 文件
|
||||
export function isWordFile(fileName) {
|
||||
const ext = fileName?.toLowerCase()?.split('.').pop()
|
||||
return ['docx', 'doc'].includes(ext)
|
||||
}
|
||||
@@ -16,7 +16,9 @@ export const PREVIEWABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.IMAGE,
|
||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
||||
...FILE_EXTENSIONS.AUDIO,
|
||||
'pdf', 'html', 'htm', 'md', 'markdown'
|
||||
'pdf', 'html', 'htm', 'md', 'markdown',
|
||||
// Office 文件支持预览
|
||||
'xlsx', 'xls', 'docx', 'doc'
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -28,8 +30,8 @@ export const KNOWN_BINARY_TYPES = [
|
||||
'exe', 'dll', 'so', 'bin',
|
||||
// 压缩文件
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||
// Office 文档
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
// Office PowerPoint(暂不支持预览)
|
||||
'ppt', 'pptx',
|
||||
// 其他二进制
|
||||
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
|
||||
]
|
||||
@@ -161,12 +163,31 @@ export const getFileCategory = (path) => {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Excel 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isExcelFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['xlsx', 'xls'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Word 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWordFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['docx', 'doc'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Office 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOfficeFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)
|
||||
return isExcelFile(path) || isWordFile(path) || ['ppt', 'pptx'].includes(getExt(path))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user