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
This commit is contained in:
2026-03-17 08:23:09 +08:00
parent a34401ce7a
commit 8b096fcf53
7 changed files with 155 additions and 8 deletions

20
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<DownloadAllZipParams, DownloadAllZipResult>(
"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();

View File

@@ -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<DownloadResult>;
downloadAllZip: (params: DownloadAllZipParams) => Promise<Result<DownloadAllZipResult>>;
// ==================== Version Management ====================
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;

View File

@@ -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),

View File

@@ -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<string | null>(
@@ -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" }));
}

View File

@@ -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<DownloadResult>;
downloadAllZip: (params: DownloadAllZipParams) => Promise<DownloadAllZipResult>;
// Version management
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;