os.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { getMainWindow } from "ee-core/electron";
  2. import { app, dialog, shell } from "electron";
  3. import { logger } from "ee-core/log";
  4. import path from "path";
  5. import fs from "fs";
  6. import http from "http";
  7. import https from "https";
  8. import FormData from "form-data";
  9. class Os {
  10. /**
  11. * 解析要操作的窗口:优先用调用方传入的窗口(智能工作台),否则回落到主窗口。
  12. * 无边框窗口的按钮都走这里,避免智能窗口的最小化/关闭去操作主窗口。
  13. */
  14. resolveWindow(win) {
  15. return win && !win.isDestroyed() ? win : getMainWindow();
  16. }
  17. async minimizeWindow(win) {
  18. this.resolveWindow(win).minimize();
  19. }
  20. async maximizeWindow(win) {
  21. this.resolveWindow(win).maximize();
  22. }
  23. async unmaximizeWindow(win) {
  24. this.resolveWindow(win).unmaximize();
  25. }
  26. async closeWindow(win) {
  27. this.resolveWindow(win).close();
  28. }
  29. async isMaximized(win) {
  30. return this.resolveWindow(win).isMaximized();
  31. }
  32. async toggleDevTools(win) {
  33. this.resolveWindow(win).webContents.toggleDevTools();
  34. }
  35. async openUserDataFolder() {
  36. const userDataPath = app.getPath('userData');
  37. await shell.openPath(userDataPath);
  38. }
  39. async openInstallFolder() {
  40. const exePath = app.getPath('exe');
  41. const installDir = path.dirname(exePath);
  42. await shell.openPath(installDir);
  43. }
  44. async selectDirectory(win) {
  45. const parent = this.resolveWindow(win);
  46. const result = await dialog.showOpenDialog(parent, {
  47. title: '选择目录',
  48. properties: ['openDirectory', 'createDirectory'],
  49. });
  50. if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
  51. return '';
  52. }
  53. return result.filePaths[0];
  54. }
  55. /**
  56. * 按偏移分块读本机文件,返回 base64(离线地图瓦片这类大文件只按需取块)。
  57. *
  58. * <p>渲染进程侧的实现见 core/utils/map/offlineMap.ts 的 ElectronPmtilesSource:
  59. * PMTiles 只要求 getBytes(offset, length),所以这里只做「绝对路径 + 真实文件 + 边界收敛」。
  60. *
  61. * @param params { filePath: string, offset?: number, length: number }
  62. * @returns { data: base64, size: 实际读到的字节数, fileSize: 文件总大小 }
  63. */
  64. async readBinaryFileRange(params = {}) {
  65. const filePath = typeof params?.filePath === 'string' ? params.filePath.trim() : '';
  66. if (!filePath) throw new Error('文件路径不能为空');
  67. if (!path.isAbsolute(filePath)) throw new Error('只支持绝对路径');
  68. if (!fs.existsSync(filePath)) throw new Error(`文件不存在:${filePath}`);
  69. if (!fs.statSync(filePath).isFile()) throw new Error('目标是目录,不是文件');
  70. const fileSize = fs.statSync(filePath).size;
  71. const offset = Math.max(0, Math.floor(Number(params?.offset) || 0));
  72. const requested = Math.floor(Number(params?.length) || 0);
  73. if (!requested || requested < 0) throw new Error('读取长度不合法');
  74. if (offset >= fileSize) return { data: '', size: 0, fileSize };
  75. const length = Math.min(requested, fileSize - offset);
  76. const fd = fs.openSync(filePath, 'r');
  77. try {
  78. const buffer = Buffer.alloc(length);
  79. const read = fs.readSync(fd, buffer, 0, length, offset);
  80. return { data: buffer.subarray(0, read).toString('base64'), size: read, fileSize };
  81. } finally {
  82. fs.closeSync(fd);
  83. }
  84. }
  85. /**
  86. * 读本机文本文件(离线地图样式 JSON 等)。
  87. * @param params { filePath: string, encoding?: string }
  88. */
  89. async readTextFile(params = {}) {
  90. const filePath = typeof params?.filePath === 'string' ? params.filePath.trim() : '';
  91. if (!filePath) throw new Error('文件路径不能为空');
  92. if (!path.isAbsolute(filePath)) throw new Error('只支持绝对路径');
  93. if (!fs.existsSync(filePath)) throw new Error(`文件不存在:${filePath}`);
  94. if (!fs.statSync(filePath).isFile()) throw new Error('目标是目录,不是文件');
  95. const encoding = typeof params?.encoding === 'string' && params.encoding ? params.encoding : 'utf8';
  96. return fs.readFileSync(filePath, encoding);
  97. }
  98. async getAppInfo() {
  99. return {
  100. version: app.getVersion(),
  101. name: app.getName(),
  102. electronVersion: process.versions.electron,
  103. chromeVersion: process.versions.chrome,
  104. nodeVersion: process.versions.node,
  105. platform: process.platform,
  106. arch: process.arch,
  107. userDataPath: app.getPath('userData'),
  108. exePath: app.getPath('exe'),
  109. };
  110. }
  111. async openExternal(url) {
  112. if (!url || typeof url !== 'string') return false;
  113. try {
  114. await shell.openExternal(url);
  115. return true;
  116. } catch (e) {
  117. logger.error('[os] openExternal failed:', e);
  118. return false;
  119. }
  120. }
  121. /**
  122. * 通过 Node.js 主进程流式上传清鉴数据源文件
  123. * - 校验选中目录最后一级必须为 QingJian
  124. * - 在 QingJian 目录下拼接 workspace 作为扫描根
  125. * - 扫描 workspace 下一层文件夹,再读取每个文件夹下的文件(不递归更深层)
  126. * - 用 fs.createReadStream 流式读取,避免大文件占内存
  127. * - 用 form-data 构建 multipart,http/https 模块发送
  128. * @param params.dirPath 目录完整磁盘路径(最后一级必须为 QingJian)
  129. * @param params.uploadUrl 后端接口完整 URL
  130. * @param params.token 鉴权 token(可选)
  131. */
  132. async uploadQingJianFiles(params) {
  133. logger.info('[os] uploadQingJianFiles params:', params);
  134. const { dirPath, uploadUrl, token } = params || {};
  135. if (!dirPath || !uploadUrl) {
  136. logger.error('[os] uploadQingJianFiles params missing:', params);
  137. throw new Error('请选择 QingJian 目录');
  138. }
  139. // 1. 校验选中目录最后一级必须为 QingJian
  140. const dirName = path.basename(dirPath);
  141. if (dirName !== 'QingJian') {
  142. throw new Error('请选择 QingJian 目录');
  143. }
  144. // 2. 在选中目录下拼接 workspace,作为扫描根目录
  145. const workspacePath = path.join(dirPath, 'workspace');
  146. const platPath = path.join(dirPath, 'db',"plat");
  147. // 3. 扫描 workspace 下一层文件夹,再读取每个文件夹下的文件(不递归更深层)
  148. // id = 文件直接所在的父级目录名(即子文件夹名)
  149. const subEntries = await fs.promises.readdir(workspacePath, { withFileTypes: true });
  150. const subDirs = subEntries.filter(e => e.isDirectory());
  151. if (subDirs.length === 0) {
  152. throw new Error('workspace 目录下没有子文件夹');
  153. }
  154. const files = [];
  155. for (const sub of subDirs) {
  156. const subDirPath = path.join(workspacePath, sub.name);
  157. const subFiles = await fs.promises.readdir(subDirPath, { withFileTypes: true });
  158. for (const f of subFiles) {
  159. if (f.isFile()) {
  160. files.push({ id: sub.name, name: f.name, filePath: path.join(subDirPath, f.name) });
  161. }
  162. }
  163. }
  164. files.push({ id: 'plat', name: 'plat', filePath: platPath });
  165. if (files.length === 0) {
  166. throw new Error('workspace 的子文件夹下没有可上传的文件');
  167. }
  168. // 4. 构建 multipart FormData
  169. const form = new FormData();
  170. form.append('path', dirPath);
  171. for (let i = 0; i < files.length; i++) {
  172. const { id, name: fileName, filePath } = files[i];
  173. const stream = fs.createReadStream(filePath);
  174. form.append(`items[${i}].file`, stream, { filename: fileName });
  175. form.append(`items[${i}].name`, fileName);
  176. form.append(`items[${i}].id`, id);
  177. }
  178. // 3. 发送 HTTP 请求(流式 pipe,不占内存)
  179. return await new Promise((resolve, reject) => {
  180. const urlObj = new URL(uploadUrl);
  181. const lib = urlObj.protocol === 'https:' ? https : http;
  182. const headers = form.getHeaders();
  183. if (token) {
  184. headers['Authorization'] = token;
  185. }
  186. const req = lib.request(uploadUrl, {
  187. method: 'POST',
  188. headers,
  189. }, (res) => {
  190. let data = '';
  191. res.setEncoding('utf8');
  192. res.on('data', (chunk) => { data += chunk; });
  193. res.on('end', () => {
  194. const status = res.statusCode || 0;
  195. if (status >= 200 && status < 300) {
  196. try {
  197. resolve(JSON.parse(data));
  198. } catch {
  199. resolve(data);
  200. }
  201. } else {
  202. reject(new Error(`HTTP ${status}: ${data}`));
  203. }
  204. });
  205. });
  206. req.on('error', (e) => {
  207. logger.error('[os] uploadQingJianFiles request error:', e);
  208. reject(e.message);
  209. });
  210. form.pipe(req);
  211. });
  212. }
  213. }
  214. Os.toString = () => 'Os';
  215. export const os = new Os();