优化:Office/CSV 预览增强 + 清理冗余代码
Office 预览优化: - 重构 Excel/Word 预览,使用本地文件服务器直接加载 - 添加 CSV 文件预览支持(表格形式展示) - 优化加载状态和错误提示 UI CSV 文件支持: - 后端添加 CSV/TSV 文件类型和 MIME 映射 - 前端添加 isCsvFile 类型判断 代码清理: - 移除未使用的 ReadFileAsBase64 API
This commit is contained in:
@@ -1 +1 @@
|
||||
0b56c4ddab241d0ca843efcc544c131c
|
||||
11e4d92d4ca3da6546d1516713da71a8
|
||||
@@ -156,8 +156,7 @@
|
||||
<iframe
|
||||
v-if="!config.isEditMode"
|
||||
class="html-preview-content"
|
||||
:srcdoc="htmlContentWithTheme"
|
||||
:key="getCurrentTheme()"
|
||||
:src="htmlPreviewUrl"
|
||||
></iframe>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
@@ -287,7 +286,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onUnmounted } from 'vue'
|
||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } 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'
|
||||
@@ -335,50 +334,13 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 获取当前主题
|
||||
const getCurrentTheme = () => {
|
||||
return document.body.getAttribute('arco-theme') || 'light'
|
||||
}
|
||||
|
||||
// 生成带主题样式的 HTML 内容
|
||||
const htmlContentWithTheme = computed(() => {
|
||||
if (!props.config.rendered || props.config.isEditMode) return ''
|
||||
|
||||
const theme = getCurrentTheme()
|
||||
const bgColor = theme === 'dark' ? '#1a1a1a' : '#ffffff'
|
||||
const textColor = theme === 'dark' ? '#e8e8e8' : '#333333'
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
padding: 20px;
|
||||
background-color: ${bgColor};
|
||||
color: ${textColor};
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
a { color: ${theme === 'dark' ? '#4e9af1' : '#0066cc'}; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid ${theme === 'dark' ? '#444' : '#ddd'}; padding: 8px; }
|
||||
th { background-color: ${theme === 'dark' ? '#333' : '#f2f2f2'}; }
|
||||
code { background-color: ${theme === 'dark' ? '#333' : '#f4f4f4'}; padding: 2px 6px; border-radius: 3px; }
|
||||
pre { background-color: ${theme === 'dark' ? '#2a2a2a' : '#f4f4f4'}; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
||||
pre code { background-color: transparent; padding: 0; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${props.config.rendered}</body>
|
||||
</html>
|
||||
`
|
||||
// HTML 预览 URL(使用后端接口)
|
||||
const htmlPreviewUrl = computed(() => {
|
||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
|
||||
return ''
|
||||
}
|
||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||
return `http://localhost:18765/localfs/html-preview?path=${encodedPath}`
|
||||
})
|
||||
|
||||
// 计算属性:判断文件是否在当前目录
|
||||
@@ -626,10 +588,35 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 处理 HTML iframe 发送的消息(链接点击)
|
||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null', // about:blank 或 data: URL
|
||||
'http://localhost:18765', // 本地文件服务器
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
}
|
||||
const data = event.data
|
||||
if (data && data.type === 'openLocalFile' && data.path) {
|
||||
// 直接传递路径,由父组件处理相对路径解析
|
||||
emit('openLocalFile', data.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 iframe 的 postMessage
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleHtmlIframeMessage)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (markdownPreviewRef.value) {
|
||||
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
|
||||
}
|
||||
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -123,6 +123,14 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
return ['docx', 'doc'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CSV/TSV 文件
|
||||
*/
|
||||
const isCsvFile = (filepath: any): boolean => {
|
||||
const ext = getFileExtension(filepath)
|
||||
return ['csv', 'tsv'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 注意:媒体文件(图片、视频、音频、PDF)不是二进制文件,它们可以预览
|
||||
@@ -138,8 +146,8 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
|
||||
['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext)
|
||||
|
||||
// Office 文件(可预览)
|
||||
const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc'].includes(ext)
|
||||
// Office 文件和 CSV(可预览)
|
||||
const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc', 'csv', 'tsv'].includes(ext)
|
||||
|
||||
// 文本或代码文件(可编辑)
|
||||
const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) ||
|
||||
@@ -231,15 +239,14 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 记录加载时间戳,用于过滤过期更新
|
||||
lastLoadTime.value = Date.now()
|
||||
|
||||
// 先清空内容,避免显示之前文件的内容
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
||||
// 新内容加载完成后会直接替换旧内容
|
||||
|
||||
const filename = getFilePath(path)
|
||||
const ext = getFileExtension(filename)
|
||||
|
||||
// Office 文件直接读取内容进行预览,跳过二进制检测
|
||||
if (isExcelFile(filename) || isWordFile(filename)) {
|
||||
// Office 文件和 CSV 文件直接读取内容进行预览,跳过二进制检测
|
||||
if (isExcelFile(filename) || isWordFile(filename) || isCsvFile(filename)) {
|
||||
const content = await readFile(path)
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
@@ -426,9 +433,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
const saveDraft = () => {
|
||||
if (!currentFilePath.value) return
|
||||
|
||||
// Office 文件不支持草稿功能
|
||||
// Office 文件和 CSV 不支持草稿功能
|
||||
const path = getFilePath(currentFilePath.value)
|
||||
if (isExcelFile(path) || isWordFile(path)) {
|
||||
if (isExcelFile(path) || isWordFile(path) || isCsvFile(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -450,8 +457,8 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
* 加载草稿
|
||||
*/
|
||||
const loadDraft = (path: string) => {
|
||||
// Office 文件不支持草稿功能,并清除已有的草稿
|
||||
if (isExcelFile(path) || isWordFile(path)) {
|
||||
// Office 文件和 CSV 不支持草稿功能,并清除已有的草稿
|
||||
if (isExcelFile(path) || isWordFile(path) || isCsvFile(path)) {
|
||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
@@ -657,6 +664,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
isPdfFile,
|
||||
isExcelFile,
|
||||
isWordFile,
|
||||
isCsvFile,
|
||||
isBinaryFileByExt,
|
||||
isFileInCurrentDirectory
|
||||
}
|
||||
|
||||
@@ -368,9 +368,10 @@ const handleOpenFile = async (path: string) => {
|
||||
// 是目录,导航进入
|
||||
await navigate(path)
|
||||
} else {
|
||||
// 是文件,选中并加载
|
||||
selectedFileItem.value = targetFile
|
||||
// 是文件,先加载内容,再更新选中状态(避免闪烁)
|
||||
await loadFileContent(path)
|
||||
// 内容加载完成后再更新选中状态,确保 fileContent 和 selectedFileItem 同步
|
||||
selectedFileItem.value = targetFile
|
||||
}
|
||||
} else {
|
||||
// 未找到,尝试直接导航(可能是目录)
|
||||
|
||||
@@ -173,6 +173,8 @@ export interface FileEditorPanelConfig {
|
||||
isExcelFile: boolean
|
||||
/** 是否为 Word 文件 */
|
||||
isWordFile: boolean
|
||||
/** 是否为 CSV/TSV 文件 */
|
||||
isCsvFile: boolean
|
||||
/** Office 文件加载中 */
|
||||
officeLoading: boolean
|
||||
/** Office 文件加载错误 */
|
||||
|
||||
@@ -1,221 +1,345 @@
|
||||
/**
|
||||
* Office 文件预览处理器
|
||||
* 使用动态导入减小初始包体积
|
||||
*/
|
||||
|
||||
// 获取文件扩展名(统一方法)
|
||||
function getExt(fileName) {
|
||||
return fileName?.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
// 每批加载行数
|
||||
const BATCH_ROWS = 200
|
||||
const LOAD_MORE_THRESHOLD = 100
|
||||
|
||||
// Excel 预览处理器
|
||||
export async function previewExcel(file, container) {
|
||||
try {
|
||||
// 动态导入 xlsx 库
|
||||
const XLSX = await import('xlsx')
|
||||
const XLSX = await import('xlsx')
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
|
||||
|
||||
// 读取文件
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
|
||||
// 渲染标签页
|
||||
const tabs = workbook.SheetNames.map((name, i) =>
|
||||
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
|
||||
).join('')
|
||||
|
||||
// 获取第一个工作表
|
||||
const firstSheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[firstSheetName]
|
||||
container.innerHTML = `
|
||||
<div class="excel-preview">
|
||||
<div class="excel-tabs">${tabs}</div>
|
||||
<div class="excel-info"></div>
|
||||
<div class="excel-content"></div>
|
||||
</div>
|
||||
<style>
|
||||
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
||||
.excel-tabs{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;background:var(--color-fill-1);border-bottom:1px solid var(--color-border-2)}
|
||||
.excel-tab{padding:6px 14px;border:none;background:transparent;color:var(--color-text-2);font-size:13px;border-radius:4px;cursor:pointer;transition:all .2s}
|
||||
.excel-tab:hover{background:var(--color-fill-3)}
|
||||
.excel-tab.active{background:rgb(var(--primary-6));color:#fff;font-weight:500}
|
||||
.excel-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.excel-content{flex:1;overflow:auto;padding:12px}
|
||||
.excel-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
|
||||
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
|
||||
.excel-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.excel-content th.row-num{z-index:3}
|
||||
.excel-content tr:hover td{background:var(--color-fill-1)}
|
||||
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
`
|
||||
|
||||
// 转换为 HTML 表格
|
||||
const html = XLSX.utils.sheet_to_html(worksheet, {
|
||||
editable: false,
|
||||
header: '',
|
||||
footer: ''
|
||||
})
|
||||
const contentEl = container.querySelector('.excel-content')
|
||||
const infoEl = container.querySelector('.excel-info')
|
||||
const tabsEl = container.querySelector('.excel-tabs')
|
||||
|
||||
// 渲染到容器
|
||||
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>
|
||||
`
|
||||
// 当前 sheet 状态
|
||||
let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Excel 预览失败:', error)
|
||||
return { success: false, error: error.message }
|
||||
// 获取 sheet 数据
|
||||
const getSheetData = (idx) => {
|
||||
const ws = workbook.Sheets[workbook.SheetNames[idx]]
|
||||
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
||||
}
|
||||
|
||||
// 渲染表格(带行号)
|
||||
const renderTable = (data, startRow = 0) => {
|
||||
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
let html = '<table><thead><tr><th class="row-num">#</th>'
|
||||
if (data[0]) {
|
||||
data[0].forEach((cell, i) => {
|
||||
html += `<th>${escapeHtml(cell)}</th>`
|
||||
})
|
||||
}
|
||||
html += '</tr></thead><tbody>'
|
||||
|
||||
for (let i = 1; i < data.length && i <= startRow + BATCH_ROWS; i++) {
|
||||
html += `<tr><td class="row-num">${i}</td>`
|
||||
if (data[i]) {
|
||||
data[i].forEach(cell => {
|
||||
html += `<td>${escapeHtml(cell)}</td>`
|
||||
})
|
||||
}
|
||||
html += '</tr>'
|
||||
}
|
||||
html += '</tbody></table>'
|
||||
return html
|
||||
}
|
||||
|
||||
// 追加行
|
||||
const appendRows = (data, fromRow, toRow) => {
|
||||
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
const tbody = contentEl.querySelector('tbody')
|
||||
if (!tbody) return
|
||||
|
||||
for (let i = fromRow; i <= toRow && i < data.length; i++) {
|
||||
let html = `<tr><td class="row-num">${i}</td>`
|
||||
if (data[i]) {
|
||||
data[i].forEach(cell => {
|
||||
html += `<td>${escapeHtml(cell)}</td>`
|
||||
})
|
||||
}
|
||||
html += '</tr>'
|
||||
tbody.insertAdjacentHTML('beforeend', html)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新信息栏
|
||||
const updateInfo = () => {
|
||||
const { renderedRows, totalRows } = currentSheet
|
||||
if (renderedRows >= totalRows) {
|
||||
infoEl.textContent = `共 ${totalRows} 行`
|
||||
} else {
|
||||
infoEl.textContent = `已加载 ${renderedRows}/${totalRows} 行(滚动加载更多)`
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 sheet
|
||||
const loadSheet = (idx) => {
|
||||
currentSheet = {
|
||||
idx,
|
||||
data: getSheetData(idx),
|
||||
renderedRows: BATCH_ROWS,
|
||||
totalRows: 0
|
||||
}
|
||||
currentSheet.totalRows = currentSheet.data.length
|
||||
|
||||
const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1)
|
||||
contentEl.innerHTML = renderTable(displayData)
|
||||
updateInfo()
|
||||
}
|
||||
|
||||
// 滚动加载更多
|
||||
contentEl.onscroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
||||
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
||||
const { data, renderedRows, totalRows } = currentSheet
|
||||
if (renderedRows < totalRows - 1) {
|
||||
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
|
||||
appendRows(data, renderedRows + 1, newEnd)
|
||||
currentSheet.renderedRows = newEnd
|
||||
updateInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSheet(0)
|
||||
|
||||
// 标签页切换
|
||||
tabsEl.onclick = (e) => {
|
||||
const tab = e.target.closest('.excel-tab')
|
||||
if (!tab) return
|
||||
tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
loadSheet(parseInt(tab.dataset.idx, 10))
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Word 预览处理器
|
||||
export async function previewWord(file, container) {
|
||||
const mammoth = await import('mammoth')
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
|
||||
const warnings = result.messages.length > 0
|
||||
? `<details class="word-warnings"><summary>转换警告 (${result.messages.length})</summary><ul>${result.messages.map(m => `<li>${m.message}</li>`).join('')}</ul></details>`
|
||||
: ''
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="word-preview">
|
||||
<div class="word-content">${result.value}</div>
|
||||
${warnings}
|
||||
</div>
|
||||
<style>
|
||||
.word-preview{padding:20px;height:100%;overflow:auto;line-height:1.6;color:var(--color-text-1)}
|
||||
.word-content h1,.word-content h2,.word-content h3{margin:1em 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:.5em 0}
|
||||
.word-content ul,.word-content ol{margin:.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 }
|
||||
}
|
||||
|
||||
// 文件类型判断
|
||||
const OFFICE_EXTS = { xlsx: 1, xls: 1, docx: 1, doc: 1 }
|
||||
const EXCEL_EXTS = { xlsx: 1, xls: 1 }
|
||||
const WORD_EXTS = { docx: 1, doc: 1 }
|
||||
|
||||
export const isOfficeFile = (name) => OFFICE_EXTS[getExt(name)] || false
|
||||
export const isExcelFile = (name) => EXCEL_EXTS[getExt(name)] || false
|
||||
export const isWordFile = (name) => WORD_EXTS[getExt(name)] || false
|
||||
export const isCsvFile = (name) => ['csv', 'tsv'].includes(getExt(name))
|
||||
|
||||
// CSV/TSV 预览处理器(原生实现,支持滚动加载)
|
||||
export async function previewCsv(file, container) {
|
||||
try {
|
||||
// 动态导入 mammoth 库
|
||||
const mammoth = await import('mammoth')
|
||||
if (!container) {
|
||||
return { success: false, error: '容器不存在' }
|
||||
}
|
||||
|
||||
// 读取文件并转换为 HTML
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
const text = await file.text()
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) {
|
||||
throw new Error('文件为空')
|
||||
}
|
||||
|
||||
// 解析 CSV 行
|
||||
const parseLine = (line, delimiter) => {
|
||||
const cells = []
|
||||
let cell = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
cell += '"'
|
||||
i++
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (char === delimiter && !inQuotes) {
|
||||
cells.push(cell)
|
||||
cell = ''
|
||||
} else {
|
||||
cell += char
|
||||
}
|
||||
}
|
||||
cells.push(cell)
|
||||
return cells
|
||||
}
|
||||
|
||||
const delimiter = file.name.endsWith('.tsv') ? '\t' : ','
|
||||
const rows = lines.map(line => parseLine(line, delimiter))
|
||||
|
||||
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
// 渲染到容器
|
||||
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 class="csv-preview">
|
||||
<div class="csv-info">📋 ${file.name}</div>
|
||||
<div class="csv-content"></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;
|
||||
}
|
||||
.csv-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
||||
.csv-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.csv-content{flex:1;overflow:auto;padding:12px}
|
||||
.csv-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
|
||||
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
|
||||
.csv-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.csv-content th.row-num{z-index:3}
|
||||
.csv-content tr:hover td{background:var(--color-fill-1)}
|
||||
.csv-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
`
|
||||
|
||||
const contentEl = container.querySelector('.csv-content')
|
||||
const infoEl = container.querySelector('.csv-info')
|
||||
|
||||
// 状态
|
||||
let renderedRows = 0
|
||||
const totalRows = rows.length
|
||||
|
||||
// 渲染表格
|
||||
const renderTable = (startRow = 0) => {
|
||||
const endRow = Math.min(startRow + BATCH_ROWS, totalRows)
|
||||
let html = '<table>'
|
||||
|
||||
// 表头(第一行)
|
||||
if (startRow === 0 && rows[0]) {
|
||||
html += '<thead><tr><th class="row-num">#</th>'
|
||||
rows[0].forEach(cell => { html += `<th>${escapeHtml(cell)}</th>` })
|
||||
html += '</tr></thead><tbody>'
|
||||
}
|
||||
|
||||
// 数据行
|
||||
for (let i = Math.max(1, startRow); i < endRow; i++) {
|
||||
html += `<tr><td class="row-num">${i}</td>`
|
||||
if (rows[i]) {
|
||||
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
|
||||
}
|
||||
html += '</tr>'
|
||||
}
|
||||
html += '</tbody></table>'
|
||||
return html
|
||||
}
|
||||
|
||||
// 追加行
|
||||
const appendRows = (fromRow) => {
|
||||
const endRow = Math.min(fromRow + BATCH_ROWS, totalRows)
|
||||
const tbody = contentEl.querySelector('tbody')
|
||||
if (!tbody) return
|
||||
|
||||
for (let i = fromRow; i < endRow; i++) {
|
||||
let html = `<tr><td class="row-num">${i}</td>`
|
||||
if (rows[i]) {
|
||||
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
|
||||
}
|
||||
html += '</tr>'
|
||||
tbody.insertAdjacentHTML('beforeend', html)
|
||||
}
|
||||
return endRow
|
||||
}
|
||||
|
||||
// 更新信息
|
||||
const updateInfo = () => {
|
||||
if (renderedRows >= totalRows) {
|
||||
infoEl.textContent = `📋 ${file.name}(共 ${totalRows - 1} 行)`
|
||||
} else {
|
||||
infoEl.textContent = `📋 ${file.name}(已加载 ${renderedRows}/${totalRows - 1} 行)`
|
||||
}
|
||||
}
|
||||
|
||||
// 初始渲染
|
||||
contentEl.innerHTML = renderTable(0)
|
||||
renderedRows = Math.min(BATCH_ROWS, totalRows)
|
||||
updateInfo()
|
||||
|
||||
// 滚动加载
|
||||
contentEl.onscroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
||||
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
||||
if (renderedRows < totalRows) {
|
||||
renderedRows = appendRows(renderedRows)
|
||||
updateInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Word 预览失败:', error)
|
||||
return { success: false, error: error.message }
|
||||
} catch (err) {
|
||||
console.error('[previewCsv] 错误:', err)
|
||||
return { success: false, error: err?.message || String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为 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)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ export const PREVIEWABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.AUDIO,
|
||||
'pdf', 'html', 'htm', 'md', 'markdown',
|
||||
// Office 文件支持预览
|
||||
'xlsx', 'xls', 'docx', 'doc'
|
||||
'xlsx', 'xls', 'docx', 'doc',
|
||||
// CSV/TSV 表格文件
|
||||
'csv', 'tsv'
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -191,3 +193,13 @@ export const isWordFile = (path) => {
|
||||
export const isOfficeFile = (path) => {
|
||||
return isExcelFile(path) || isWordFile(path) || ['ppt', 'pptx'].includes(getExt(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CSV/TSV 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isCsvFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['csv', 'tsv'].includes(ext)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user