import { getMainWindow } from "ee-core/electron"; import { app, dialog, shell } from "electron"; import { logger } from "ee-core/log"; import path from "path"; import fs from "fs"; import http from "http"; import https from "https"; import FormData from "form-data"; class Os { async minimizeWindow() { getMainWindow().minimize(); } async maximizeWindow() { getMainWindow().maximize(); } async unmaximizeWindow() { getMainWindow().unmaximize(); } async closeWindow() { getMainWindow().close(); } async isMaximized() { return getMainWindow().isMaximized(); } async toggleDevTools() { getMainWindow().webContents.toggleDevTools(); } async openUserDataFolder() { const userDataPath = app.getPath('userData'); await shell.openPath(userDataPath); } async openInstallFolder() { const exePath = app.getPath('exe'); const installDir = path.dirname(exePath); await shell.openPath(installDir); } async selectDirectory() { const win = getMainWindow(); const result = await dialog.showOpenDialog(win, { title: '选择目录', properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || !result.filePaths || result.filePaths.length === 0) { return ''; } return result.filePaths[0]; } /** * 按偏移分块读本机文件,返回 base64(离线地图瓦片这类大文件只按需取块)。 * *
渲染进程侧的实现见 core/utils/map/offlineMap.ts 的 ElectronPmtilesSource: * PMTiles 只要求 getBytes(offset, length),所以这里只做「绝对路径 + 真实文件 + 边界收敛」。 * * @param params { filePath: string, offset?: number, length: number } * @returns { data: base64, size: 实际读到的字节数, fileSize: 文件总大小 } */ async readBinaryFileRange(params = {}) { const filePath = typeof params?.filePath === 'string' ? params.filePath.trim() : ''; if (!filePath) throw new Error('文件路径不能为空'); if (!path.isAbsolute(filePath)) throw new Error('只支持绝对路径'); if (!fs.existsSync(filePath)) throw new Error(`文件不存在:${filePath}`); if (!fs.statSync(filePath).isFile()) throw new Error('目标是目录,不是文件'); const fileSize = fs.statSync(filePath).size; const offset = Math.max(0, Math.floor(Number(params?.offset) || 0)); const requested = Math.floor(Number(params?.length) || 0); if (!requested || requested < 0) throw new Error('读取长度不合法'); if (offset >= fileSize) return { data: '', size: 0, fileSize }; const length = Math.min(requested, fileSize - offset); const fd = fs.openSync(filePath, 'r'); try { const buffer = Buffer.alloc(length); const read = fs.readSync(fd, buffer, 0, length, offset); return { data: buffer.subarray(0, read).toString('base64'), size: read, fileSize }; } finally { fs.closeSync(fd); } } /** * 读本机文本文件(离线地图样式 JSON 等)。 * @param params { filePath: string, encoding?: string } */ async readTextFile(params = {}) { const filePath = typeof params?.filePath === 'string' ? params.filePath.trim() : ''; if (!filePath) throw new Error('文件路径不能为空'); if (!path.isAbsolute(filePath)) throw new Error('只支持绝对路径'); if (!fs.existsSync(filePath)) throw new Error(`文件不存在:${filePath}`); if (!fs.statSync(filePath).isFile()) throw new Error('目标是目录,不是文件'); const encoding = typeof params?.encoding === 'string' && params.encoding ? params.encoding : 'utf8'; return fs.readFileSync(filePath, encoding); } async getAppInfo() { return { version: app.getVersion(), name: app.getName(), electronVersion: process.versions.electron, chromeVersion: process.versions.chrome, nodeVersion: process.versions.node, platform: process.platform, arch: process.arch, userDataPath: app.getPath('userData'), exePath: app.getPath('exe'), }; } async openExternal(url) { if (!url || typeof url !== 'string') return false; try { await shell.openExternal(url); return true; } catch (e) { logger.error('[os] openExternal failed:', e); return false; } } /** * 通过 Node.js 主进程流式上传清鉴数据源文件 * - 校验选中目录最后一级必须为 QingJian * - 在 QingJian 目录下拼接 workspace 作为扫描根 * - 扫描 workspace 下一层文件夹,再读取每个文件夹下的文件(不递归更深层) * - 用 fs.createReadStream 流式读取,避免大文件占内存 * - 用 form-data 构建 multipart,http/https 模块发送 * @param params.dirPath 目录完整磁盘路径(最后一级必须为 QingJian) * @param params.uploadUrl 后端接口完整 URL * @param params.token 鉴权 token(可选) */ async uploadQingJianFiles(params) { logger.info('[os] uploadQingJianFiles params:', params); const { dirPath, uploadUrl, token } = params || {}; if (!dirPath || !uploadUrl) { logger.error('[os] uploadQingJianFiles params missing:', params); throw new Error('请选择 QingJian 目录'); } // 1. 校验选中目录最后一级必须为 QingJian const dirName = path.basename(dirPath); if (dirName !== 'QingJian') { throw new Error('请选择 QingJian 目录'); } // 2. 在选中目录下拼接 workspace,作为扫描根目录 const workspacePath = path.join(dirPath, 'workspace'); const platPath = path.join(dirPath, 'db',"plat"); // 3. 扫描 workspace 下一层文件夹,再读取每个文件夹下的文件(不递归更深层) // id = 文件直接所在的父级目录名(即子文件夹名) const subEntries = await fs.promises.readdir(workspacePath, { withFileTypes: true }); const subDirs = subEntries.filter(e => e.isDirectory()); if (subDirs.length === 0) { throw new Error('workspace 目录下没有子文件夹'); } const files = []; for (const sub of subDirs) { const subDirPath = path.join(workspacePath, sub.name); const subFiles = await fs.promises.readdir(subDirPath, { withFileTypes: true }); for (const f of subFiles) { if (f.isFile()) { files.push({ id: sub.name, name: f.name, filePath: path.join(subDirPath, f.name) }); } } } files.push({ id: 'plat', name: 'plat', filePath: platPath }); if (files.length === 0) { throw new Error('workspace 的子文件夹下没有可上传的文件'); } // 4. 构建 multipart FormData const form = new FormData(); form.append('path', dirPath); for (let i = 0; i < files.length; i++) { const { id, name: fileName, filePath } = files[i]; const stream = fs.createReadStream(filePath); form.append(`items[${i}].file`, stream, { filename: fileName }); form.append(`items[${i}].name`, fileName); form.append(`items[${i}].id`, id); } // 3. 发送 HTTP 请求(流式 pipe,不占内存) return await new Promise((resolve, reject) => { const urlObj = new URL(uploadUrl); const lib = urlObj.protocol === 'https:' ? https : http; const headers = form.getHeaders(); if (token) { headers['Authorization'] = token; } const req = lib.request(uploadUrl, { method: 'POST', headers, }, (res) => { let data = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { const status = res.statusCode || 0; if (status >= 200 && status < 300) { try { resolve(JSON.parse(data)); } catch { resolve(data); } } else { reject(new Error(`HTTP ${status}: ${data}`)); } }); }); req.on('error', (e) => { logger.error('[os] uploadQingJianFiles request error:', e); reject(e.message); }); form.pipe(req); }); } } Os.toString = () => 'Os'; export const os = new Os();