From 8b096fcf530e3085a3cd4b867d01f3c9fb245786 Mon Sep 17 00:00:00 2001 From: xue jiahao Date: Tue, 17 Mar 2026 08:23:09 +0800 Subject: [PATCH] feat: add download all as zip feature - Add adm-zip dependency for zip compression - Add DownloadAllZipParams/Result IPC types - Implement registerDownloadAllZip handler in main process - Update frontend download flow with save dialog - ZIP includes pc/, mobile/ folders and metadata.json --- package-lock.json | 20 ++++ package.json | 2 + src/main/ipc-handlers.ts | 92 +++++++++++++++++++ src/preload/index.d.ts | 4 +- src/preload/index.ts | 1 + .../src/components/AppDetail/AppDetail.tsx | 30 +++++- src/shared/types/ipc.ts | 14 ++- 7 files changed, 155 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 498b1a9..d79ffeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "@electron-toolkit/utils": "^3.0.0", "@kintone/rest-api-client": "^6.1.2", "@lobehub/ui": "^5.5.1", + "@types/adm-zip": "^0.5.7", "@uiw/react-codemirror": "^4.23.0", + "adm-zip": "^0.5.16", "antd": "^6.1.0", "antd-style": "^4.1.0", "electron-store": "^10.0.0", @@ -4727,6 +4729,15 @@ "node": ">=10" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmmirror.com/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5798,6 +5809,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", diff --git a/package.json b/package.json index 9b2edd6..dbe68d8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "@electron-toolkit/utils": "^3.0.0", "@kintone/rest-api-client": "^6.1.2", "@lobehub/ui": "^5.5.1", + "@types/adm-zip": "^0.5.7", "@uiw/react-codemirror": "^4.23.0", + "adm-zip": "^0.5.16", "antd": "^6.1.0", "antd-style": "^4.1.0", "electron-store": "^10.0.0", diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 117482e..7325da3 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -6,6 +6,7 @@ import { ipcMain, dialog, app } from "electron"; import { autoUpdater } from "electron-updater"; +import AdmZip from "adm-zip"; import { v4 as uuidv4 } from "uuid"; import { saveDomain, @@ -31,6 +32,8 @@ import type { DeployParams, DeployResult, DownloadParams, + DownloadAllZipParams, + DownloadAllZipResult, DownloadResult, GetVersionsParams, RollbackParams, @@ -490,6 +493,94 @@ function registerDownload(): void { }); } +/** + * Download all files as ZIP + */ +function registerDownloadAllZip(): void { + handle( + "downloadAllZip", + async (params) => { + const client = await getClient(params.domainId); + const domainWithPassword = await getDomain(params.domainId); + + if (!domainWithPassword) { + throw new Error(getErrorMessage("domainNotFound")); + } + + const appDetail = await client.getAppDetail(params.appId); + const zip = new AdmZip(); + const metadata = { + downloadedAt: new Date().toISOString(), + domain: domainWithPassword.domain, + appId: params.appId, + appName: appDetail.name, + }; + + // Download and add PC files + const pcJsFiles = appDetail.customization?.desktop?.js || []; + const pcCssFiles = appDetail.customization?.desktop?.css || []; + + for (const [index, file] of pcJsFiles.entries()) { + const fileKey = getFileKey(file); + if (fileKey) { + const content = await client.getFileContent(fileKey); + const buffer = Buffer.from(content.content || "", "base64"); + const fileName = getDisplayName(file, "js", index); + zip.addFile(`desktop-js/${fileName}`, buffer); + } + } + + for (const [index, file] of pcCssFiles.entries()) { + const fileKey = getFileKey(file); + if (fileKey) { + const content = await client.getFileContent(fileKey); + const buffer = Buffer.from(content.content || "", "base64"); + const fileName = getDisplayName(file, "css", index); + zip.addFile(`desktop-css/${fileName}`, buffer); + } + } + + // Download and add Mobile files + const mobileJsFiles = appDetail.customization?.mobile?.js || []; + const mobileCssFiles = appDetail.customization?.mobile?.css || []; + + for (const [index, file] of mobileJsFiles.entries()) { + const fileKey = getFileKey(file); + if (fileKey) { + const content = await client.getFileContent(fileKey); + const buffer = Buffer.from(content.content || "", "base64"); + const fileName = getDisplayName(file, "js", index); + zip.addFile(`mobile-js/${fileName}`, buffer); + } + } + + for (const [index, file] of mobileCssFiles.entries()) { + const fileKey = getFileKey(file); + if (fileKey) { + const content = await client.getFileContent(fileKey); + const buffer = Buffer.from(content.content || "", "base64"); + const fileName = getDisplayName(file, "css", index); + zip.addFile(`mobile-css/${fileName}`, buffer); + } + } + + // Add metadata.json + zip.addFile( + "metadata.json", + Buffer.from(JSON.stringify(metadata, null, 2), "utf-8"), + ); + + // Write ZIP to user-provided path + zip.writeZip(params.savePath); + + return { + success: true, + path: params.savePath, + }; + }, + ); +} + // ==================== Version IPC Handlers ==================== /** @@ -628,6 +719,7 @@ export function registerIpcHandlers(): void { // Download registerDownload(); + registerDownloadAllZip(); // Version registerGetVersions(); diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 758dce0..4a9dbbf 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -11,7 +11,8 @@ import type { DeployResult, DownloadParams, DownloadResult, - GetVersionsParams, + DownloadAllZipParams, + DownloadAllZipResult, RollbackParams, SetLocaleParams, ShowSaveDialogParams, @@ -61,6 +62,7 @@ export interface SelfAPI { // ==================== Download ==================== download: (params: DownloadParams) => Promise; + downloadAllZip: (params: DownloadAllZipParams) => Promise>; // ==================== Version Management ==================== getVersions: (params: GetVersionsParams) => Promise>; diff --git a/src/preload/index.ts b/src/preload/index.ts index a8775d6..8ad49cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ const api: SelfAPI = { // Download download: (params) => ipcRenderer.invoke("download", params), + downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params), // Version management getVersions: (params) => ipcRenderer.invoke("getVersions", params), diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index 2e94348..1335bd1 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -5,7 +5,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Spin, Tag, Space, message } from "antd"; +import { Spin, Tag, Space, App as AntApp } from "antd"; import { Button, Empty, MaterialFileTypeIcon } from "@lobehub/ui"; import { LayoutGrid, @@ -148,13 +148,14 @@ const useStyles = createStyles(({ token, css }) => ({ })); const AppDetail: React.FC = () => { - const { t } = useTranslation("app"); + const { t } = useTranslation(["app", "common"]); const { styles } = useStyles(); const token = useTheme(); const { currentDomain } = useDomainStore(); const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } = useAppStore(); const { viewMode, selectedFile, setViewMode, setSelectedFile } = useSessionStore(); + const { message } = AntApp.useApp(); // Download state: track which file is being downloaded const [downloadingKey, setDownloadingKey] = React.useState( @@ -306,18 +307,37 @@ const AppDetail: React.FC = () => { // Download all files const handleDownloadAll = async () => { - if (!currentDomain || !selectedAppId || downloadingAll) return; + if (!currentDomain || !selectedAppId || downloadingAll || !currentApp) return; + // Generate default filename: {appName}_{YYYY-MM-DD}.zip + const appName = currentApp.name || "app"; + const sanitizedAppName = appName.replace(/[:*?"<>|]/g, "_"); + const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD + const defaultFilename = `${sanitizedAppName}_${date}.zip`; + + // Show save dialog first + const dialogResult = await window.api.showSaveDialog({ + defaultPath: defaultFilename, + }); + + if (!dialogResult.success || !dialogResult.data) { + // User cancelled - return without error message + return; + } + + const savePath = dialogResult.data; setDownloadingAll(true); try { - const result = await window.api.download({ + const result = await window.api.downloadAllZip({ domainId: currentDomain.id, appId: selectedAppId, + savePath, }); if (result.success) { - message.success(t("downloadAllSuccess", { path: result.path })); + + message.success(t("downloadAllSuccess", { path: result.data.path, ns: "common" })) } else { message.error(result.error || t("downloadFailed", { ns: "common" })); } diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index d453274..b3b3f54 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -97,6 +97,16 @@ export interface DownloadResult { metadata?: DownloadMetadata; error?: string; } +export interface DownloadAllZipResult { + success: boolean; + path?: string; + error?: string; +} +export interface DownloadAllZipParams { + domainId: string; + appId: string; + savePath: string; +} // ==================== Version IPC Types ==================== @@ -125,6 +135,7 @@ export interface ShowSaveDialogParams { export interface SaveFileContentParams { filePath: string; content: string; // Base64 encoded +} // ==================== App Version & Update IPC Types ==================== export interface UpdateInfo { @@ -138,8 +149,6 @@ export interface CheckUpdateResult { updateInfo?: UpdateInfo; } - -} // ==================== IPC API Interface ==================== export interface ElectronAPI { @@ -165,6 +174,7 @@ export interface ElectronAPI { // Download download: (params: DownloadParams) => Promise; + downloadAllZip: (params: DownloadAllZipParams) => Promise; // Version management getVersions: (params: GetVersionsParams) => Promise>;