- 文件剪贴板复制剪切粘贴,重名自动副本 - SFTP下载到本机与外部文件拖入,目录递归 - 传输进度浮动面板,transfer-progress事件链 - Drawio预览,Excel工作线程,冻结看门狗 - cmd与bat自写batch语法高亮,psm1等映射补齐
353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
/**
|
||
* 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) }
|
||
}
|
||
}
|