Files
u-desk/frontend/src/utils/filePreviewHandlers.js
T
lxy e7a2c5148e 新增: 文件操作增强与预览扩展
- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
2026-09-15 22:26:12 +08:00

353 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Office 文件预览处理器
*
* Excel 解析已迁移到 Web Worker(见 excel.worker.js + excelWorkerClient.js),
* 主线程不再出现 XLSX.read() 同步调用,避免大表解析冻结整个界面。
*/
import { escapeHtml } from './fileUtils'
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
import { parseWorkbook, getSheet, isCurrent } from './excelWorkerClient'
// 每批加载行数
const BATCH_ROWS = 200
const LOAD_MORE_THRESHOLD = 100
// Excel 预览处理器(解析在 Web Worker 中进行,不阻塞主线程)
export async function previewExcel(file, container) {
// 1. 取二进制并交给 worker 解析(XLSX.read / sheet_to_json 全在 worker 线程)
const arrayBuffer = await file.arrayBuffer()
const { wid, sheetNames } = await parseWorkbook(arrayBuffer)
// 切到别的文件时 wid 会推进,丢弃本次结果
if (!isCurrent(wid)) return { success: true }
// 2. 渲染骨架(标签页 + 信息栏 + 内容区)
const tabs = sheetNames.map((name, i) =>
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${escapeHtml(name)}</button>`
).join('')
container.innerHTML = `
<div class="excel-preview">
<div class="excel-tabs">${tabs}</div>
<div class="excel-info"></div>
<div class="excel-content"><div class="loading-hint">解析中...</div></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;color:var(--color-text-1)}
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top: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;top:0;left:0}
.excel-content tr:hover td{background:var(--color-fill-1)}
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
.loading-hint{padding:24px;text-align:center;color:var(--color-text-3);font-size:13px}
</style>
`
const contentEl = container.querySelector('.excel-content')
const infoEl = container.querySelector('.excel-info')
const tabsEl = container.querySelector('.excel-tabs')
// 当前 sheet 状态
let currentSheet = { idx: -1, data: null, renderedRows: 0, totalRows: 0 }
// 渲染令牌:切 sheet 自增,丢弃乱序回包
let renderToken = 0
// 渲染表格(带行号)
const renderTable = (data, startRow = 0) => {
let html = '<table><thead><tr><th class="row-num">#</th>'
if (data[0]) {
data[0].forEach((cell) => {
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
}
// 追加行(从内存中的 data 追加,不再过 worker)
const appendRows = (data, fromRow, toRow) => {
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(worker 异步解析单个 sheet)
const loadSheet = async (idx) => {
const token = ++renderToken
// 标签高亮
tabsEl.querySelectorAll('.excel-tab').forEach(t => {
t.classList.toggle('active', parseInt(t.dataset.idx, 10) === idx)
})
// 该 sheet 尚未解析过 → 显示解析中
if (currentSheet.idx !== idx) {
contentEl.innerHTML = '<div class="loading-hint">解析中...</div>'
}
let data
try {
data = await getSheet(wid, idx)
} catch (e) {
if (isCurrent(wid) && token === renderToken) {
contentEl.innerHTML = `<div class="preview-error"><p>❌ Excel 解析失败</p><p class="error-detail">${escapeHtml(e && e.message ? e.message : String(e))}</p></div>`
}
return
}
// 丢弃过期 / 乱序回包
if (!isCurrent(wid) || token !== renderToken) return
currentSheet = { idx, data, renderedRows: BATCH_ROWS, totalRows: data.length }
const displayData = 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 (data && renderedRows < totalRows - 1) {
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
appendRows(data, renderedRows + 1, newEnd)
currentSheet.renderedRows = newEnd
updateInfo()
}
}
}
// 标签页切换
tabsEl.onclick = (e) => {
const tab = e.target.closest('.excel-tab')
if (!tab) return
const idx = parseInt(tab.dataset.idx, 10)
if (idx === currentSheet.idx) return
loadSheet(idx)
}
await loadSheet(0)
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 }
}
// 文件类型判断(从 fileTypeHelpers 导入)
export { isOfficeFile, isExcelFile, isWordFile, isCsvFile }
// CSV/TSV 预览处理器(原生实现,支持滚动加载)
export async function previewCsv(file, container) {
try {
if (!container) {
return { success: false, error: '容器不存在' }
}
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))
container.innerHTML = `
<div class="csv-preview">
<div class="csv-info">📋 ${escapeHtml(file.name)}</div>
<div class="csv-content"></div>
</div>
<style>
.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;color:var(--color-text-1)}
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top: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;top:0;left:0}
.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 (err) {
console.error('[previewCsv] 错误:', err)
return { success: false, error: err?.message || String(err) }
}
}