Sfoglia il codice sorgente

feat (1.24.1 -> 1.25.0): 添加诊断报告PDF下载功能和跨平台文件保存服务

- 在 main.js 中添加 save-blob IPC handler,支持通用的文件保存功能
- 在 preload.js 中暴露 saveFile API,便于前端调用
- 新增 src/services/download/ 模块,实现跨平台下载服务
- 在 PdfPreviewModal.tsx 中集成 PDF 下载功能,支持诊断报告下载
- 更新 types/global.d.ts,添加相关类型定义

改动文件:
- main.js
- preload.js
- src/services/download/DownloadManager.ts
- src/services/download/IDownloadAdapter.ts
- src/services/download/adapters/BrowserDownloadAdapter.ts
- src/services/download/adapters/CordovaDownloadAdapter.ts
- src/services/download/adapters/ElectronDownloadAdapter.ts
- src/services/download/index.ts
- src/services/download/types.ts
- src/states/patient/DiagnosticReport/PdfPreviewModal.tsx
- types/global.d.ts
dengdx 2 settimane fa
parent
commit
e106219e2b

+ 42 - 0
CHANGELOG.md

@@ -2,6 +2,48 @@
 
 本项目的所有重要变更都将记录在此文件中。
 
+## [1.25.0] - 2025-12-24 12:42
+
+### 新增 (Added)
+- **跨平台文件下载和保存功能** - 实现完整的文件下载服务,支持浏览器、Electron和Cordova平台
+  - 新增 download 服务模块,包含 DownloadManager 和多平台适配器
+  - 在 main.js 中添加 save-blob IPC handler,支持通用的文件保存功能
+  - 在 preload.js 中暴露 saveFile API,便于前端调用
+  - 在 PdfPreviewModal 中集成 PDF 下载功能,支持诊断报告下载
+  - 更新全局类型定义,支持文件下载相关的类型安全
+  - 实现跨平台兼容性,确保在不同环境中都能正常工作
+
+**核心功能实现:**
+- 下载服务架构:抽象出 IDownloadAdapter 接口,支持多平台适配
+- 文件保存功能:通过 IPC 实现安全的文件保存对话框
+- PDF 下载集成:诊断报告预览模态框中添加下载按钮
+- 类型安全:完善的 TypeScript 类型定义
+- 跨平台支持:支持浏览器、Electron和Cordova环境的下载功能
+
+**技术实现:**
+- 新增 src/services/download/ 模块,包含核心服务类和适配器
+- 使用适配器模式实现不同平台的下载逻辑
+- 集成 Ant Design 组件实现用户界面
+- 添加详细的错误处理和日志记录
+- 确保与现有代码风格和架构的一致性
+
+**改动文件:**
+- main.js
+- preload.js
+- src/services/download/DownloadManager.ts (新增)
+- src/services/download/IDownloadAdapter.ts (新增)
+- src/services/download/adapters/BrowserDownloadAdapter.ts (新增)
+- src/services/download/adapters/CordovaDownloadAdapter.ts (新增)
+- src/services/download/adapters/ElectronDownloadAdapter.ts (新增)
+- src/services/download/index.ts (新增)
+- src/services/download/types.ts (新增)
+- src/states/patient/DiagnosticReport/PdfPreviewModal.tsx
+- types/global.d.ts
+- CHANGELOG.md
+- package.json (版本更新: 1.24.1 -> 1.25.0)
+
+---
+
 ## [1.24.1] - 2025-12-23 20:05
 
 ### 修复 (Fixed)

+ 2 - 2
config/dev.ts

@@ -21,14 +21,14 @@ export default {
     devServer: {
       proxy: {
         '/dr': {
-          target: 'http://192.168.110.245:6001', // 你的后端服务地址
+          target: 'http://192.168.110.13:6001', // 你的后端服务地址
           changeOrigin: true, // 允许跨域
           // pathRewrite: {
           //   '^/dr/api': '' // 可选,用于重写路径
           // }
         },
         '/mqtt': {
-          target: 'ws://192.168.110.245:8083', // MQTT WebSocket 服务地址
+          target: 'ws://192.168.110.13:8083', // MQTT WebSocket 服务地址
           changeOrigin: true,
           ws: true, // 启用 WebSocket 代理
           // pathRewrite: {

+ 39 - 0
main.js

@@ -559,6 +559,45 @@ await printWindow.loadURL(`file://${tmpHtml.replace(/\\/g, '/')}`);
   }
 });
 
+/**
+ * 保存 Blob 数据到文件
+ * 通用的文件保存功能,支持任意二进制数据
+ */
+ipcMain.handle('save-blob', async (event, options) => {
+  try {
+    const { data, defaultFilename, filters, title } = options;
+    
+    writeLog('info', `开始保存文件,默认文件名: ${defaultFilename}`);
+
+    // 显示保存对话框
+    const { filePath, canceled } = await dialog.showSaveDialog(win, {
+      title: title || '保存文件',
+      defaultPath: defaultFilename,
+      filters: filters || [{ name: '所有文件', extensions: ['*'] }]
+    });
+
+    if (canceled || !filePath) {
+      writeLog('info', '用户取消保存');
+      return { success: false, canceled: true };
+    }
+
+    // 将 ArrayBuffer 转换为 Buffer
+    const buffer = Buffer.from(data);
+    
+    // 保存文件
+    const fs = await import('fs/promises');
+    await fs.writeFile(filePath, buffer);
+    
+    writeLog('info', `文件已保存到: ${filePath}`);
+
+    return { success: true, filePath };
+
+  } catch (error) {
+    writeLog('error', `保存文件出错: ${error.message}`);
+    return { success: false, error: error.message };
+  }
+});
+
 /**
  * 导出为PDF
  */

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "zsis",
-  "version": "1.24.1",
+  "version": "1.25.0",
   "private": true,
   "description": "医学成像系统",
   "main": "main.js",

+ 13 - 1
preload.js

@@ -8,7 +8,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
   shutdownSystem: () => ipcRenderer.invoke('exit-shutdown'),
   
   // 日志功能(保留原有功能)
-  writeLog: (level, msg) => ipcRenderer.invoke('write-log', level, msg)
+  writeLog: (level, msg) => ipcRenderer.invoke('write-log', level, msg),
+
+  // 文件保存功能
+  /**
+   * 保存 Blob 数据到文件
+   * @param {Object} options - 保存选项
+   * @param {ArrayBuffer} options.data - 文件数据(ArrayBuffer)
+   * @param {string} options.defaultFilename - 默认文件名
+   * @param {Array} options.filters - 文件类型过滤器
+   * @param {string} options.title - 对话框标题
+   * @returns {Promise<{success: boolean, filePath?: string, canceled?: boolean, error?: string}>}
+   */
+  saveFile: (options) => ipcRenderer.invoke('save-blob', options)
 });
 
 // 暴露存储API

+ 98 - 0
src/services/download/DownloadManager.ts

@@ -0,0 +1,98 @@
+/**
+ * 下载管理器
+ * 根据当前平台自动选择合适的下载适配器
+ */
+
+import { platform } from '@/utils/platform';
+import { IDownloadAdapter } from './IDownloadAdapter';
+import { BrowserDownloadAdapter } from './adapters/BrowserDownloadAdapter';
+import { ElectronDownloadAdapter } from './adapters/ElectronDownloadAdapter';
+import { CordovaDownloadAdapter } from './adapters/CordovaDownloadAdapter';
+import { DownloadOptions, DownloadResult, DownloadFeatures, PlatformType } from './types';
+
+/**
+ * 下载管理器类
+ */
+export class DownloadManager {
+  private adapter: IDownloadAdapter;
+  private platformType: string;
+
+  constructor() {
+    // 检测当前平台
+    this.platformType = this.detectPlatform();
+
+    // 创建对应的适配器
+    this.adapter = this.createAdapter();
+
+    console.log(`[下载管理器] 当前平台: ${this.platformType}, 使用适配器: ${this.adapter.getName()}`);
+  }
+
+  /**
+   * 检测当前平台
+   */
+  private detectPlatform(): string {
+    if (platform.isElectron) {
+      return PlatformType.Electron;
+    } else if (platform.isCordova) {
+      return PlatformType.Cordova;
+    } else if (platform.isBrowser) {
+      return PlatformType.Browser;
+    }
+    return PlatformType.Browser; // 默认使用浏览器适配器
+  }
+
+  /**
+   * 创建对应的适配器
+   */
+  private createAdapter(): IDownloadAdapter {
+    switch (this.platformType) {
+      case PlatformType.Electron:
+        return new ElectronDownloadAdapter();
+      case PlatformType.Cordova:
+        return new CordovaDownloadAdapter();
+      case PlatformType.Browser:
+      default:
+        return new BrowserDownloadAdapter();
+    }
+  }
+
+  /**
+   * 执行下载
+   */
+  async download(options: DownloadOptions): Promise<DownloadResult> {
+    console.log('[下载管理器] 开始下载:', options.filename);
+    const result = await this.adapter.download(options);
+    
+    if (result.success) {
+      console.log('[下载管理器] 下载成功');
+    } else {
+      console.error('[下载管理器] 下载失败:', result.error);
+    }
+    
+    return result;
+  }
+
+  /**
+   * 获取平台类型
+   */
+  getPlatform(): string {
+    return this.platformType;
+  }
+
+  /**
+   * 获取支持的功能特性
+   */
+  getSupportedFeatures(): DownloadFeatures {
+    return this.adapter.getSupportedFeatures();
+  }
+
+  /**
+   * 获取适配器名称
+   */
+  getAdapterName(): string {
+    return this.adapter.getName();
+  }
+}
+
+// 导出单例实例
+export const downloadManager = new DownloadManager();

+ 30 - 0
src/services/download/IDownloadAdapter.ts

@@ -0,0 +1,30 @@
+/**
+ * 下载适配器接口
+ * 定义所有下载适配器必须实现的方法
+ */
+
+import { DownloadOptions, DownloadResult, DownloadFeatures } from './types';
+
+/**
+ * 下载适配器接口
+ */
+export  interface IDownloadAdapter {
+  /**
+   * 执行下载操作
+   * @param options 下载选项
+   * @returns Promise,下载完成时resolve
+   */
+  download(options: DownloadOptions): Promise<DownloadResult>;
+
+  /**
+   * 获取当前适配器支持的功能特性
+   * @returns 支持的功能特性
+   */
+  getSupportedFeatures(): DownloadFeatures;
+
+  /**
+   * 获取适配器名称
+   * @returns 适配器名称
+   */
+  getName(): string;
+}

+ 66 - 0
src/services/download/adapters/BrowserDownloadAdapter.ts

@@ -0,0 +1,66 @@
+/**
+ * 浏览器下载适配器
+ * 使用浏览器原生的 <a> 标签 download 属性实现下载功能
+ */
+
+import { IDownloadAdapter } from '../IDownloadAdapter';
+import { DownloadOptions, DownloadResult, DownloadFeatures } from '../types';
+
+export class BrowserDownloadAdapter implements IDownloadAdapter {
+  /**
+   * 执行下载操作
+   */
+  async download(options: DownloadOptions): Promise<DownloadResult> {
+    try {
+      const { data, filename, mimeType } = options;
+
+      // 将数据转换为 Blob
+      const blob =
+        data instanceof Blob
+          ? data
+          : new Blob([data], { type: mimeType || 'application/octet-stream' });
+
+      // 创建下载链接
+      const url = URL.createObjectURL(blob);
+      const link = document.createElement('a');
+      link.href = url;
+      link.download = filename;
+
+      // 触发下载
+      document.body.appendChild(link);
+      link.click();
+
+      // 清理
+      document.body.removeChild(link);
+      URL.revokeObjectURL(url);
+
+      return {
+        success: true,
+      };
+    } catch (error) {
+      console.error('[浏览器下载] 下载失败:', error);
+      return {
+        success: false,
+        error: error instanceof Error ? error.message : '下载失败',
+      };
+    }
+  }
+
+  /**
+   * 获取支持的功能特性
+   */
+  getSupportedFeatures(): DownloadFeatures {
+    return {
+      supportsFileDialog: false, // 浏览器直接下载到默认位置
+      supportsCustomFilename: true, // 支持自定义文件名
+      supportsFileFilters: false, // 不支持文件类型过滤
+    };
+  }
+
+  /**
+   * 获取适配器名称
+   */
+  getName(): string {
+    return 'BrowserDownloadAdapter';
+  }
+}

+ 134 - 0
src/services/download/adapters/CordovaDownloadAdapter.ts

@@ -0,0 +1,134 @@
+/**
+ * Cordova 下载适配器
+ * 使用 cordova-plugin-file 保存文件到本地文件系统
+ */
+
+import { IDownloadAdapter } from '../IDownloadAdapter';
+import { DownloadOptions, DownloadResult, DownloadFeatures } from '../types';
+
+export class CordovaDownloadAdapter implements IDownloadAdapter {
+  /**
+   * 执行下载操作
+   */
+  async download(options: DownloadOptions): Promise<DownloadResult> {
+    try {
+      const { data, filename } = options;
+
+      // 检查 Cordova 环境
+      if (!window.cordova) {
+        throw new Error('Cordova 环境不可用');
+      }
+
+      // 将数据转换为 Blob
+      const blob =
+        data instanceof Blob
+          ? data
+          : new Blob([data], { type: options.mimeType || 'application/octet-stream' });
+
+      // 使用 FileReader 读取 Blob 数据
+      const arrayBuffer = await this.blobToArrayBuffer(blob);
+
+      // 保存文件
+      const filePath = await this.saveFile(filename, arrayBuffer);
+
+      return {
+        success: true,
+        filePath: filePath,
+      };
+    } catch (error) {
+      console.error('[Cordova下载] 下载失败:', error);
+      return {
+        success: false,
+        error: error instanceof Error ? error.message : '下载失败',
+      };
+    }
+  }
+
+  /**
+   * 将 Blob 转换为 ArrayBuffer
+   */
+  private blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
+    return new Promise((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => resolve(reader.result as ArrayBuffer);
+      reader.onerror = reject;
+      reader.readAsArrayBuffer(blob);
+    });
+  }
+
+  /**
+   * 保存文件到 Cordova 文件系统
+   */
+  private saveFile(filename: string, data: ArrayBuffer): Promise<string> {
+    return new Promise((resolve, reject) => {
+      // 检查文件系统 API 是否可用
+      if (!window.resolveLocalFileSystemURL) {
+        reject(new Error('Cordova 文件系统 API 不可用'));
+        return;
+      }
+
+      // 获取文件系统
+      window.resolveLocalFileSystemURL(
+        (window as any).cordova.file.externalDataDirectory ||
+          (window as any).cordova.file.dataDirectory,
+        (dirEntry: any) => {
+          // 创建文件
+          dirEntry.getFile(
+            filename,
+            { create: true, exclusive: false },
+            (fileEntry: any) => {
+              // 创建文件写入器
+              fileEntry.createWriter(
+                (fileWriter: any) => {
+                  fileWriter.onwriteend = () => {
+                    console.log('[Cordova下载] 文件保存成功:', fileEntry.nativeURL);
+                    resolve(fileEntry.nativeURL);
+                  };
+
+                  fileWriter.onerror = (error: any) => {
+                    console.error('[Cordova下载] 文件写入失败:', error);
+                    reject(new Error('文件写入失败'));
+                  };
+
+                  // 写入数据
+                  const blob = new Blob([data]);
+                  fileWriter.write(blob);
+                },
+                (error: any) => {
+                  console.error('[Cordova下载] 创建文件写入器失败:', error);
+                  reject(new Error('创建文件写入器失败'));
+                }
+              );
+            },
+            (error: any) => {
+              console.error('[Cordova下载] 创建文件失败:', error);
+              reject(new Error('创建文件失败'));
+            }
+          );
+        },
+        (error: any) => {
+          console.error('[Cordova下载] 获取文件系统失败:', error);
+          reject(new Error('获取文件系统失败'));
+        }
+      );
+    });
+  }
+
+  /**
+   * 获取支持的功能特性
+   */
+  getSupportedFeatures(): DownloadFeatures {
+    return {
+      supportsFileDialog: false, // Cordova 直接保存到固定目录
+      supportsCustomFilename: true, // 支持自定义文件名
+      supportsFileFilters: false, // 不支持文件类型过滤
+    };
+  }
+
+  /**
+   * 获取适配器名称
+   */
+  getName(): string {
+    return 'CordovaDownloadAdapter';
+  }
+}

+ 60 - 0
src/services/download/adapters/ElectronDownloadAdapter.ts

@@ -0,0 +1,60 @@
+/**
+ * Electron 下载适配器
+ * 使用 Electron 的 IPC 通信调用主进程的文件保存功能
+ */
+
+import { IDownloadAdapter } from '../IDownloadAdapter';
+import { DownloadOptions, DownloadResult, DownloadFeatures } from '../types';
+
+export class ElectronDownloadAdapter implements IDownloadAdapter {
+  /**
+   * 执行下载操作
+   */
+  async download(options: DownloadOptions): Promise<DownloadResult> {
+    try {
+      const { data, filename, filters, title } = options;
+
+      // 检查 Electron API 是否可用
+      if (!window.electronAPI?.saveFile) {
+        throw new Error('Electron 文件保存 API 不可用');
+      }
+
+      // 将 Blob 转换为 ArrayBuffer
+      const arrayBuffer = data instanceof Blob ? await data.arrayBuffer() : data;
+
+      // 调用 Electron API 保存文件
+      const result = await window.electronAPI.saveFile({
+        data: arrayBuffer,
+        defaultFilename: filename,
+        filters: filters,
+        title: title || '保存文件',
+      });
+
+      return result;
+    } catch (error) {
+      console.error('[Electron下载] 下载失败:', error);
+      return {
+        success: false,
+        error: error instanceof Error ? error.message : '下载失败',
+      };
+    }
+  }
+
+  /**
+   * 获取支持的功能特性
+   */
+  getSupportedFeatures(): DownloadFeatures {
+    return {
+      supportsFileDialog: true, // 支持文件选择对话框
+      supportsCustomFilename: true, // 支持自定义文件名
+      supportsFileFilters: true, // 支持文件类型过滤
+    };
+  }
+
+  /**
+   * 获取适配器名称
+   */
+  getName(): string {
+    return 'ElectronDownloadAdapter';
+  }
+}

+ 15 - 0
src/services/download/index.ts

@@ -0,0 +1,15 @@
+/**
+ * 下载服务导出文件
+ */
+
+export { downloadManager, DownloadManager } from './DownloadManager';
+export type { IDownloadAdapter } from './IDownloadAdapter';
+export type {
+  DownloadOptions,
+  DownloadResult,
+  DownloadFeatures,
+  PlatformType,
+} from './types';
+export { BrowserDownloadAdapter } from './adapters/BrowserDownloadAdapter';
+export { ElectronDownloadAdapter } from './adapters/ElectronDownloadAdapter';
+export { CordovaDownloadAdapter } from './adapters/CordovaDownloadAdapter';

+ 90 - 0
src/services/download/types.ts

@@ -0,0 +1,90 @@
+/**
+ * 下载服务类型定义
+ */
+
+/**
+ * 平台类型
+ */
+export enum PlatformType {
+  Browser = 'browser',
+  Electron = 'electron',
+  Cordova = 'cordova',
+}
+
+/**
+ * 下载选项
+ */
+export interface DownloadOptions {
+  /**
+   * 文件数据(Blob 或 ArrayBuffer)
+   */
+  data: Blob | ArrayBuffer;
+
+  /**
+   * 默认文件名
+   */
+  filename: string;
+
+  /**
+   * 文件 MIME 类型(可选)
+   */
+  mimeType?: string;
+
+  /**
+   * 文件类型过滤器(Electron 专用)
+   */
+  filters?: Array<{
+    name: string;
+    extensions: string[];
+  }>;
+
+  /**
+   * 保存对话框标题(Electron/Cordova 专用)
+   */
+  title?: string;
+}
+
+/**
+ * 下载结果
+ */
+export interface DownloadResult {
+  /**
+   * 是否成功
+   */
+  success: boolean;
+
+  /**
+   * 文件路径(Electron/Cordova)
+   */
+  filePath?: string;
+
+  /**
+   * 是否被用户取消
+   */
+  canceled?: boolean;
+
+  /**
+   * 错误信息
+   */
+  error?: string;
+}
+
+/**
+ * 下载功能特性
+ */
+export interface DownloadFeatures {
+  /**
+   * 是否支持选择保存位置
+   */
+  supportsFileDialog: boolean;
+
+  /**
+   * 是否支持自定义文件名
+   */
+  supportsCustomFilename: boolean;
+
+  /**
+   * 是否支持文件类型过滤
+   */
+  supportsFileFilters: boolean;
+}

+ 53 - 2
src/states/patient/DiagnosticReport/PdfPreviewModal.tsx

@@ -1,8 +1,10 @@
-import { Modal, Result } from 'antd';
+import { Modal, Result, Button, message } from 'antd';
+import { DownloadOutlined } from '@ant-design/icons';
 import { useEffect, useState } from 'react';
 import { useSelector } from 'react-redux';
 import { RootState, useAppDispatch } from '@/states/store';
 import { setNeedsPreview } from './slice';
+import { downloadManager } from '@/services/download';
 
 export default function PdfPreviewModal() {
   const [url, setUrl] = useState<string | null>(null);
@@ -28,6 +30,44 @@ export default function PdfPreviewModal() {
     };
   }, [needsPreview, previewResponse]);
 
+  /**
+   * 下载 PDF
+   */
+  const handleDownload = async () => {
+    if (!previewResponse) {
+      message.error('没有可下载的 PDF');
+      return;
+    }
+
+    try {
+      // 生成文件名(使用患者信息和时间戳)
+      const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
+      const filename = `诊断报告_${timestamp}.pdf`;
+
+      // 执行下载
+      const result = await downloadManager.download({
+        data: previewResponse,
+        filename: filename,
+        mimeType: 'application/pdf',
+        filters: [
+          { name: 'PDF 文件', extensions: ['pdf'] },
+        ],
+        title: '保存诊断报告',
+      });
+
+      if (result.success) {
+        message.success('PDF 下载成功');
+      } else if (result.canceled) {
+        message.info('已取消下载');
+      } else {
+        message.error(`下载失败: ${result.error}`);
+      }
+    } catch (error) {
+      console.error('下载 PDF 失败:', error);
+      message.error('下载失败,请重试');
+    }
+  };
+
   return (
     <Modal
       title="PDF 预览"
@@ -35,7 +75,18 @@ export default function PdfPreviewModal() {
       onCancel={() => {
         dispatch(setNeedsPreview(false));
       }}
-      footer={null}
+      footer={
+        <div style={{ textAlign: 'right' }}>
+          <Button
+            type="primary"
+            icon={<DownloadOutlined />}
+            onClick={handleDownload}
+            disabled={!previewResponse}
+          >
+            下载
+          </Button>
+        </div>
+      }
       width="90vw"
       bodyStyle={{ height: '85vh', padding: 0 }}
     >

+ 34 - 1
types/global.d.ts

@@ -41,9 +41,36 @@ declare namespace NodeJS {
 declare const API_BASE_URL_FROM_WEBPACK: string;
 declare const MQTT_BROKER_URL_FROM_WEBPACK: string;
 
-// Electron Storage API 类型定义
+// Electron API 类型定义
 declare global {
   interface Window {
+    // Electron 主要 API
+    electronAPI?: {
+      // 系统操作
+      exitApp: () => Promise<{ success: boolean }>;
+      shutdownSystem: () => Promise<{
+        success: boolean;
+        error?: string;
+        requiresAdmin?: boolean;
+      }>;
+      // 摄像头权限
+      checkCameraPermission?: () => Promise<boolean>;
+      requestCameraPermission?: () => Promise<boolean>;
+      // 文件保存
+      saveFile: (options: {
+        data: ArrayBuffer;
+        defaultFilename: string;
+        filters?: Array<{ name: string; extensions: string[] }>;
+        title?: string;
+      }) => Promise<{
+        success: boolean;
+        filePath?: string;
+        canceled?: boolean;
+        error?: string;
+      }>;
+    };
+
+    // Electron 存储 API
     electronStorage?: {
       getItem: (key: string) => Promise<string | null>;
       setItem: (key: string, value: string) => Promise<void>;
@@ -56,6 +83,12 @@ declare global {
 declare global {
   interface Window {
     cordova?: any;
+    // Cordova 文件系统 API
+    resolveLocalFileSystemURL?: (
+      path: string,
+      successCallback: (entry: any) => void,
+      errorCallback?: (error: any) => void
+    ) => void;
   }
 
   interface Navigator {