From d6444c2798f001e61a6f06db8435dfebd8aa12f7 Mon Sep 17 00:00:00 2001 From: hanshiyang Date: Thu, 20 Nov 2025 14:34:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=AF=BB=E5=8F=96=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加从本地文件URL提取路径和通过Electron API读取本地文件的功能,支持多种数据格式转换。同时新增构建脚本快捷方式bs和bt,优化开发工作流程。 --- package.json | 2 + src/main.ts | 300 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 263 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index e9293b2..f401434 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "release": "node scripts/release.js", "ball": "vite build && pnpm ct && pnpm cs && pnpm cw", "bc": "vite build && pnpm cw", + "bs": "vite build && pnpm cs", + "bt": "vite build && pnpm ct", "ct": "rm -rf 'E:/XuZhou/xz-teacher/public/vocd/' && scp -r 'E:/XuZhou/by-onlineeditor/dist/.' 'E:/XuZhou/xz-teacher/public/vocd/'", "cs": "rm -rf 'E:/XuZhou/xz-student/src/static/vocd/' && scp -r 'E:/XuZhou/by-onlineeditor/dist/.' 'E:/XuZhou/xz-student/src/static/vocd/'", "cw": "rm -rf 'F:/SVN/工程装备数智化教学软件平台/07 程序代码/gczbjx-ui/public/vocd/' && scp -r 'E:/XuZhou/by-onlineeditor/dist/.' 'F:/SVN/工程装备数智化教学软件平台/07 程序代码/gczbjx-ui/public/vocd/'" diff --git a/src/main.ts b/src/main.ts index 278f7b1..d09d27f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,179 @@ import { formatPrismToken } from './utils/prism' import { Signature } from './components/signature/Signature' import { debounce, nextTick, scrollIntoView } from './utils' +/** + * 从本地文件URL中提取文件路径 + */ +function extractLocalFilePathFromUrl(url: string): string | null { + console.log('[special] extractLocalFilePathFromUrl: input url =', url) + const localPrefix = 'http://localfile/' + const beginToken = '/begin-path/' + const endToken = '/path-end/' + + // 1. 检查前缀 + if (!url.startsWith(localPrefix)) { + console.warn('[special] prefix not matched', { localPrefix, url }) + return null + } + + // 2. 查找路径标记 + const beginIndex: number = url.indexOf(beginToken) + const endIndex: number = url.indexOf(endToken) + console.log('[special] token indexes', { beginIndex, endIndex }) + + if (beginIndex === -1 || endIndex === -1 || endIndex <= beginIndex) { + console.warn('[special] token invalid', { beginIndex, endIndex }) + return null // 标记不全或顺序错误 + } + + // 3. 提取 filePath(不含 /begin-path/ 和 /path-end/) + const filePathStart: number = beginIndex + beginToken.length + const filePath: string = url.substring(filePathStart, endIndex) + console.log('[special] extracted localFilePath =', filePath) + + return filePath +} + +/** + * 请求本地文件并返回ArrayBuffer + */ +function requestLocalFile( + localFilePath: string, + onProgress?: (loaded: number, total: number) => void +): Promise { + return new Promise((resolve, reject) => { + console.log('[special] requestLocalFile: input path =', localFilePath) + + type ElectronAPI = { + readFile?: (path: string) => Promise<{ + success: boolean + content?: unknown + error?: string + }> + } + + const electronAPI = (window as any).electronAPI as ElectronAPI | undefined + if (!electronAPI?.readFile) { + console.error('[special] Electron API 不可用或未暴露 readFile') + return reject( + new Error('Electron API 不可用,请确保在 Electron 环境中运行') + ) + } + + // 解码文件路径,处理 URL 编码 + let decodedPath: string + try { + decodedPath = decodeURIComponent(localFilePath) + // 移除可能的 file:// 前缀 + if (decodedPath.startsWith('file://')) { + decodedPath = decodedPath.substring(7) + } + // 在 Windows 上处理路径格式 + const isWindows = + typeof navigator !== 'undefined' && + navigator.platform && + (navigator.platform.indexOf('Win') !== -1 || + navigator.platform.indexOf('win') !== -1) + console.log('[special] platform isWindows =', !!isWindows) + if (isWindows && decodedPath.startsWith('/')) { + decodedPath = decodedPath.substring(1) + } + } catch (error) { + console.error('[special] 路径解码失败:', error) + decodedPath = localFilePath + } + + console.log('[special] requestLocalFile 读取文件 decodedPath =', decodedPath) + console.log('[special] electronAPI.readFile 调用') + + electronAPI + .readFile!(decodedPath) + .then(result => { + console.log('[special] Electron API 返回结果:', result) + + if (!result || !result.success) { + const errorMsg = (result && result.error) || '读取文件失败' + console.error('[special] 文件读取失败:', errorMsg) + return reject(new Error(errorMsg)) + } + + const content: any = result.content + if (!content) { + console.error('[special] 文件内容为空') + return reject(new Error('文件内容为空')) + } + + // 处理不同的数据格式,确保返回 ArrayBuffer + let arrayBuffer: ArrayBuffer + try { + if (content instanceof ArrayBuffer) { + arrayBuffer = content + } else if (content instanceof Uint8Array) { + arrayBuffer = content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength + ) + } else if ( + typeof Buffer !== 'undefined' && + (content as any) instanceof Buffer + ) { + // Node.js Buffer 转换为 ArrayBuffer + arrayBuffer = (content as any).buffer.slice( + (content as any).byteOffset, + (content as any).byteOffset + (content as any).byteLength + ) + } else if (Array.isArray(content)) { + // 数组转换为 ArrayBuffer + arrayBuffer = new Uint8Array(content as number[]).buffer + } else if ((content as any).data && Array.isArray((content as any).data)) { + // 包装的数组数据 + arrayBuffer = new Uint8Array((content as any).data).buffer + } else if ( + typeof content === 'object' && + (content as any).type === 'Buffer' && + Array.isArray((content as any).data) + ) { + // Node.js Buffer 的 JSON 序列化格式 + arrayBuffer = new Uint8Array((content as any).data).buffer + } else if ( + typeof content === 'object' && + (content as any).constructor === Object + ) { + // 处理普通对象格式的 Buffer + const values = Object.values(content as Record) + if (values.length > 0 && typeof values[0] === 'number') { + arrayBuffer = new Uint8Array(values as number[]).buffer + } else { + console.error('[special] 无效的本地文件数据格式对象:', content) + throw new Error('无效的本地文件数据格式') + } + } else { + console.error('[special] 未知的数据格式:', typeof content, content) + throw new Error(`未知的数据格式: ${typeof content}`) + } + + console.log('[special] 文件读取成功,大小:', arrayBuffer.byteLength, '字节') + + // 调用进度回调 + if (onProgress) { + console.log('[special] onProgress 回调上报最终大小') + onProgress(arrayBuffer.byteLength, arrayBuffer.byteLength) + } + + // 直接返回 ArrayBuffer,不需要创建 blob URL + resolve(arrayBuffer) + } catch (conversionError: any) { + console.error('[special] 数据格式转换失败:', conversionError) + reject(new Error(`数据格式转换失败: ${conversionError.message}`)) + } + }) + .catch(err => { + console.error('[special] requestLocalFile 调用失败:', err) + reject(new Error('读取本地文件失败: ' + (err && (err.message || err)))) + }) + }) +} + window.onload = function () { const isApple = typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent) @@ -50,28 +223,68 @@ window.onload = function () { const params = new URLSearchParams(window.location.search) const filePath = params.get('filePath') if (filePath) { - const url = new URL(filePath, window.location.href).toString() - fetch(url) - .then(res => res.text()) - .then(text => { - try { - let editorData + // 特殊路径识别和特殊获取逻辑 + const localFilePath = extractLocalFilePathFromUrl(filePath) + if (localFilePath) { + // 走特殊情况:通过 Electron API 读取本地文件 + requestLocalFile(localFilePath, (loaded, total) => { + console.log(`[iframe] 本地文件读取进度: ${loaded}/${total}`) + }) + .then(arrayBuffer => { + let text = '' try { - editorData = JSON.parse(text) - } catch { - editorData = { main: [{ value: text }] } + const decoder = new TextDecoder('utf-8') + text = decoder.decode(arrayBuffer) + } catch (e) { + const uint8 = new Uint8Array(arrayBuffer) + text = Array.from(uint8) + .map(code => String.fromCharCode(code)) + .join('') } - instance.command.executeSetValue(editorData) - console.log('[iframe] 根据 filePath 加载完成:', url) - } catch (err) { - console.error('[iframe] 加载失败:', err) - alert('通过参数加载文件失败,请检查格式与同源权限') - } - }) - .catch(err => { - console.error('[iframe] 获取文件失败:', err) - alert('无法获取 filePath 指定的文件') - }) + + try { + let editorData + try { + editorData = JSON.parse(text) + } catch { + editorData = { main: [{ value: text }] } + } + instance.command.executeSetValue(editorData) + console.log('[iframe] 通过 Electron 加载本地文件完成:', localFilePath) + } catch (err) { + console.error('[iframe] 本地文件解析失败:', err) + alert('本地文件解析失败,请检查格式') + } + }) + .catch(err => { + console.error('[iframe] 本地文件读取失败:', err) + alert('读取本地文件失败,请检查路径格式或环境') + }) + } else { + // 非特殊情况:保持原有同源路径 fetch 行为 + const url = new URL(filePath, window.location.href).toString() + fetch(url) + .then(res => res.text()) + .then(text => { + try { + let editorData + try { + editorData = JSON.parse(text) + } catch { + editorData = { main: [{ value: text }] } + } + instance.command.executeSetValue(editorData) + console.log('[iframe] 根据 filePath 加载完成:', url) + } catch (err) { + console.error('[iframe] 加载失败:', err) + alert('通过参数加载文件失败,请检查格式与同源权限') + } + }) + .catch(err => { + console.error('[iframe] 获取文件失败:', err) + alert('无法获取 filePath 指定的文件') + }) + } } // 菜单弹窗销毁 @@ -88,28 +301,28 @@ window.onload = function () { ) // 2. | 打开 | 保存 | 撤销 | 重做 | 格式刷 | 清除格式 | - + // 打开文档功能 const openDom = document.querySelector('.menu-item__open')! openDom.onclick = function () { console.log('open document') - + // 创建文件输入元素 const fileInput = document.createElement('input') fileInput.type = 'file' fileInput.accept = '.json,.txt' fileInput.style.display = 'none' - + fileInput.onchange = function (event) { const file = (event.target as HTMLInputElement).files?.[0] if (!file) return - + const reader = new FileReader() reader.onload = function (e) { try { const content = e.target?.result as string let editorData - + // 尝试解析JSON格式 try { editorData = JSON.parse(content) @@ -119,7 +332,7 @@ window.onload = function () { main: [{ value: content }] } } - + // 设置编辑器内容 instance.command.executeSetValue(editorData) console.log('文档已打开') @@ -128,11 +341,11 @@ window.onload = function () { alert('打开文档失败,请检查文件格式') } } - + reader.readAsText(file) document.body.removeChild(fileInput) } - + document.body.appendChild(fileInput) fileInput.click() } @@ -141,25 +354,28 @@ window.onload = function () { const saveDom = document.querySelector('.menu-item__export')! saveDom.onclick = function () { console.log('export document') - + try { // 获取编辑器内容 const editorValue = instance.command.getValue() const content = JSON.stringify(editorValue.data, null, 2) - + // 创建下载链接 const blob = new Blob([content], { type: 'application/json' }) const url = URL.createObjectURL(blob) - + const downloadLink = document.createElement('a') downloadLink.href = url - downloadLink.download = `document_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json` + downloadLink.download = `document_${new Date() + .toISOString() + .slice(0, 19) + .replace(/:/g, '-')}.json` downloadLink.style.display = 'none' - + document.body.appendChild(downloadLink) downloadLink.click() document.body.removeChild(downloadLink) - + // 清理URL对象 URL.revokeObjectURL(url) console.log('文档已保存') @@ -170,14 +386,16 @@ window.onload = function () { } // 保存(调用父窗口回调) - const saveCallbackDom = document.querySelector('.menu-item__save')! + const saveCallbackDom = + document.querySelector('.menu-item__save')! saveCallbackDom.onclick = function () { try { const editorValue = instance.command.getValue() const content = JSON.stringify(editorValue.data, null, 2) const parentWin: any = window.parent || window - const viewer = (parentWin && parentWin.OCDViewer) || (window as any).OCDViewer + const viewer = + (parentWin && parentWin.OCDViewer) || (window as any).OCDViewer if (viewer && typeof viewer.onSave === 'function') { viewer.onSave(content) @@ -194,12 +412,14 @@ window.onload = function () { // 全局快捷键:Ctrl+S / ⌘+S 触发保存 window.addEventListener('keydown', function (e) { - const isSaveKey = (e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S') + const isSaveKey = + (e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S') if (!isSaveKey) return e.preventDefault() const params = new URLSearchParams(window.location.search) - const isEditMode = params.get('mode') === 'edit' || params.get('model') === 'edit' + const isEditMode = + params.get('mode') === 'edit' || params.get('model') === 'edit' if (!isEditMode) { console.warn('当前为只读模式,Ctrl+S 不可用') alert('当前为只读模式,不能保存') @@ -214,7 +434,9 @@ window.onload = function () { const winAny = window as any winAny.showSaveStatus = (text: string, color?: string, delay?: number) => { try { - const indicator = document.getElementById('save-status-indicator') as HTMLDivElement | null + const indicator = document.getElementById( + 'save-status-indicator' + ) as HTMLDivElement | null if (!indicator) return const duration = typeof delay === 'number' ? delay : 2000 indicator.textContent = text || '已保存'