fix deploy
This commit is contained in:
30
MEMORY.md
30
MEMORY.md
@@ -1,5 +1,35 @@
|
|||||||
# MEMORY.md
|
# MEMORY.md
|
||||||
|
|
||||||
|
## 2026-03-17 - GAIA_BL01 部署错误修复
|
||||||
|
|
||||||
|
### 遇到什么问题
|
||||||
|
|
||||||
|
- 用户点击部署时,如果存在未修改的文件(status: "unchanged"),会报错:`[404] [GAIA_BL01] 指定したファイル(id: XXXXX)が見つかりません。`
|
||||||
|
- 根本原因:Kintone 的 `fileKey` 有两种类型:
|
||||||
|
- **临时 fileKey**:Upload File API 生成,3天有效,**使用一次后失效**
|
||||||
|
- **永久 fileKey**:文件附加到记录时,永久有效
|
||||||
|
- 部署时 `getAppCustomize` 返回的 fileKey 是临时的,部署后就被消费
|
||||||
|
- 再次部署时使用已失效的 fileKey 就会报 GAIA_BL01 错误
|
||||||
|
|
||||||
|
### 如何解决的
|
||||||
|
|
||||||
|
修改 `src/main/ipc-handlers.ts` 中的 `registerDeploy` 函数:
|
||||||
|
|
||||||
|
1. 对于 "unchanged" 文件,不再使用前端传递的 `file.fileKey`
|
||||||
|
2. 改为从当前 Kintone 配置(`appDetail.customization`)中根据文件名匹配获取最新的 fileKey
|
||||||
|
3. 如果在当前配置中找不到该文件,抛出明确的错误提示用户刷新
|
||||||
|
|
||||||
|
### 以后如何避免
|
||||||
|
|
||||||
|
- Kintone API 返回的 fileKey 是临时的,每次部署后都会失效
|
||||||
|
- 部署时必须从当前 Kintone 配置获取最新的 fileKey,而不是使用缓存的值
|
||||||
|
- 参考:https://docs-customine.gusuku.io/en/error/gaia_bl01/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# MEMORY.md
|
||||||
|
|
||||||
## 2026-03-15 - CSS 模板字符串语法错误
|
## 2026-03-15 - CSS 模板字符串语法错误
|
||||||
|
|
||||||
### 遇到什么问题
|
### 遇到什么问题
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ function createWindow(): void {
|
|||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prevent Electron from navigating to file:// URLs when files are dropped
|
||||||
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||||
|
if (url.startsWith("file://")) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// HMR for renderer base on electron-vite cli.
|
// HMR for renderer base on electron-vite cli.
|
||||||
// Load the remote URL for development or the local html file for production.
|
// Load the remote URL for development or the local html file for production.
|
||||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
saveBackup,
|
saveBackup,
|
||||||
getLocale,
|
getLocale,
|
||||||
setLocale,
|
setLocale,
|
||||||
|
saveCustomizationFile,
|
||||||
|
deleteCustomizationFile,
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
import { KintoneClient, createKintoneClient } from "./kintone-api";
|
import { KintoneClient, createKintoneClient } from "./kintone-api";
|
||||||
import type { Result } from "@shared/types/ipc";
|
import type { Result } from "@shared/types/ipc";
|
||||||
@@ -39,6 +41,9 @@ import type {
|
|||||||
RollbackParams,
|
RollbackParams,
|
||||||
SetLocaleParams,
|
SetLocaleParams,
|
||||||
CheckUpdateResult,
|
CheckUpdateResult,
|
||||||
|
FileSaveParams,
|
||||||
|
FileSaveResult,
|
||||||
|
FileDeleteParams,
|
||||||
} from "@shared/types/ipc";
|
} from "@shared/types/ipc";
|
||||||
import type { LocaleCode } from "@shared/types/locale";
|
import type { LocaleCode } from "@shared/types/locale";
|
||||||
import type {
|
import type {
|
||||||
@@ -52,7 +57,7 @@ import type {
|
|||||||
BackupMetadata,
|
BackupMetadata,
|
||||||
DownloadFile,
|
DownloadFile,
|
||||||
} from "@shared/types/version";
|
} from "@shared/types/version";
|
||||||
import type { AppCustomizeParameter, AppDetail } from "@shared/types/kintone";
|
import { isFileResource, isUrlResource, type AppCustomizeParameter, type AppDetail } from "@shared/types/kintone";
|
||||||
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
||||||
import { getErrorMessage } from "./errors";
|
import { getErrorMessage } from "./errors";
|
||||||
|
|
||||||
@@ -325,10 +330,15 @@ async function addFilesToBackup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deploy files to Kintone
|
* Deploy files to Kintone.
|
||||||
|
* Accepts a list of DeployFileEntry with status: unchanged | added | deleted.
|
||||||
|
* - unchanged: uses the existing fileKey or url (no re-upload)
|
||||||
|
* - added: reads file from storagePath on disk, uploads to Kintone
|
||||||
|
* - deleted: excluded from the new configuration
|
||||||
*/
|
*/
|
||||||
function registerDeploy(): void {
|
function registerDeploy(): void {
|
||||||
handle<DeployParams, DeployResult>("deploy", async (params) => {
|
handle<DeployParams, DeployResult>("deploy", async (params) => {
|
||||||
|
const fs = await import("fs");
|
||||||
const client = await getClient(params.domainId);
|
const client = await getClient(params.domainId);
|
||||||
const domainWithPassword = await getDomain(params.domainId);
|
const domainWithPassword = await getDomain(params.domainId);
|
||||||
|
|
||||||
@@ -343,15 +353,11 @@ function registerDeploy(): void {
|
|||||||
const backupFiles = new Map<string, Buffer>();
|
const backupFiles = new Map<string, Buffer>();
|
||||||
const backupFileList: BackupMetadata["files"] = [];
|
const backupFileList: BackupMetadata["files"] = [];
|
||||||
|
|
||||||
// Add desktop files to backup
|
|
||||||
await addFilesToBackup("desktop", "js", client, appDetail, backupFiles, backupFileList);
|
await addFilesToBackup("desktop", "js", client, appDetail, backupFiles, backupFileList);
|
||||||
await addFilesToBackup("desktop", "css", client, appDetail, backupFiles, backupFileList);
|
await addFilesToBackup("desktop", "css", client, appDetail, backupFiles, backupFileList);
|
||||||
|
|
||||||
// Add mobile files to backup
|
|
||||||
await addFilesToBackup("mobile", "js", client, appDetail, backupFiles, backupFileList);
|
await addFilesToBackup("mobile", "js", client, appDetail, backupFiles, backupFileList);
|
||||||
await addFilesToBackup("mobile", "css", client, appDetail, backupFiles, backupFileList);
|
await addFilesToBackup("mobile", "css", client, appDetail, backupFiles, backupFileList);
|
||||||
|
|
||||||
// Save backup
|
|
||||||
const backupMetadata: BackupMetadata = {
|
const backupMetadata: BackupMetadata = {
|
||||||
backedUpAt: new Date().toISOString(),
|
backedUpAt: new Date().toISOString(),
|
||||||
domain: domainWithPassword.domain,
|
domain: domainWithPassword.domain,
|
||||||
@@ -364,44 +370,48 @@ function registerDeploy(): void {
|
|||||||
|
|
||||||
const backupPath = await saveBackup(backupMetadata, backupFiles);
|
const backupPath = await saveBackup(backupMetadata, backupFiles);
|
||||||
|
|
||||||
// Upload new files and build customization config directly
|
// Build new config from the ordered file list
|
||||||
const newConfig: AppCustomizeParameter = {
|
const newConfig: AppCustomizeParameter = {
|
||||||
app: params.appId,
|
app: params.appId,
|
||||||
scope: "ALL",
|
scope: "ALL",
|
||||||
desktop: {
|
desktop: { js: [], css: [] },
|
||||||
js: [],
|
mobile: { js: [], css: [] },
|
||||||
css: [],
|
|
||||||
},
|
|
||||||
mobile: {
|
|
||||||
js: [],
|
|
||||||
css: [],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const file of params.files) {
|
for (const file of params.files) {
|
||||||
const fileKey = await client.uploadFile(file.content, file.fileName);
|
if (file.status === "deleted") continue;
|
||||||
const fileEntry = { type: "FILE" as const, file: { fileKey: fileKey.fileKey } };
|
|
||||||
|
|
||||||
// Add to corresponding field based on file type and position
|
type FileEntry = { type: "FILE"; file: { fileKey: string } } | { type: "URL"; url: string };
|
||||||
if (file.fileType === "js") {
|
let entry: FileEntry;
|
||||||
if (file.position.startsWith("pc_")) {
|
|
||||||
newConfig.desktop!.js!.push(fileEntry);
|
if (file.status === "unchanged") {
|
||||||
} else if (file.position.startsWith("mobile_")) {
|
if (file.fileKey) {
|
||||||
newConfig.mobile!.js!.push(fileEntry);
|
entry = { type: "FILE", file: { fileKey: file.fileKey } };
|
||||||
|
} else if (file.url) {
|
||||||
|
entry = { type: "URL", url: file.url };
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid unchanged file entry: no fileKey or url for "${file.fileName}"`);
|
||||||
}
|
}
|
||||||
} else if (file.fileType === "css") {
|
} else {
|
||||||
if (file.position === "pc_css") {
|
// added: read from disk and upload
|
||||||
newConfig.desktop!.css!.push(fileEntry);
|
if (!file.storagePath) {
|
||||||
} else if (file.position === "mobile_css") {
|
throw new Error(`Added file "${file.fileName}" has no storagePath`);
|
||||||
newConfig.mobile!.css!.push(fileEntry);
|
|
||||||
}
|
}
|
||||||
|
const content = fs.readFileSync(file.storagePath);
|
||||||
|
const uploaded = await client.uploadFile(content, file.fileName);
|
||||||
|
entry = { type: "FILE", file: { fileKey: uploaded.fileKey } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.platform === "desktop") {
|
||||||
|
if (file.fileType === "js") newConfig.desktop!.js!.push(entry);
|
||||||
|
else newConfig.desktop!.css!.push(entry);
|
||||||
|
} else {
|
||||||
|
if (file.fileType === "js") newConfig.mobile!.js!.push(entry);
|
||||||
|
else newConfig.mobile!.css!.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update app customization
|
|
||||||
await client.updateAppCustomize(params.appId, newConfig);
|
await client.updateAppCustomize(params.appId, newConfig);
|
||||||
|
|
||||||
// Deploy the changes
|
|
||||||
await client.deployApp(params.appId);
|
await client.deployApp(params.appId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -412,6 +422,26 @@ function registerDeploy(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== File Storage IPC Handlers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a customization file from a local path to managed storage
|
||||||
|
*/
|
||||||
|
function registerFileSave(): void {
|
||||||
|
handle<FileSaveParams, FileSaveResult>("file:save", async (params) => {
|
||||||
|
return saveCustomizationFile(params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a customization file from managed storage
|
||||||
|
*/
|
||||||
|
function registerFileDelete(): void {
|
||||||
|
handle<FileDeleteParams, void>("file:delete", async (params) => {
|
||||||
|
return deleteCustomizationFile(params.storagePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Download IPC Handlers ====================
|
// ==================== Download IPC Handlers ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -717,6 +747,10 @@ export function registerIpcHandlers(): void {
|
|||||||
// Deploy
|
// Deploy
|
||||||
registerDeploy();
|
registerDeploy();
|
||||||
|
|
||||||
|
// File storage
|
||||||
|
registerFileSave();
|
||||||
|
registerFileDelete();
|
||||||
|
|
||||||
// Download
|
// Download
|
||||||
registerDownload();
|
registerDownload();
|
||||||
registerDownloadAllZip();
|
registerDownloadAllZip();
|
||||||
|
|||||||
@@ -421,6 +421,46 @@ export async function saveBackup(
|
|||||||
return backupDir;
|
return backupDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Customization File Storage ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a customization file from a local source path to the managed storage area.
|
||||||
|
* Destination: {userData}/.kintone-manager/files/{domainId}/{appId}/{platform}_{fileType}/{fileId}_{originalName}
|
||||||
|
*/
|
||||||
|
export async function saveCustomizationFile(params: {
|
||||||
|
domainId: string;
|
||||||
|
appId: string;
|
||||||
|
platform: "desktop" | "mobile";
|
||||||
|
fileType: "js" | "css";
|
||||||
|
fileId: string;
|
||||||
|
sourcePath: string;
|
||||||
|
}): Promise<{ storagePath: string; fileName: string; size: number }> {
|
||||||
|
const { domainId, appId, platform, fileType, fileId, sourcePath } = params;
|
||||||
|
const fileName = path.basename(sourcePath);
|
||||||
|
const dir = getStoragePath(
|
||||||
|
"files",
|
||||||
|
domainId,
|
||||||
|
appId,
|
||||||
|
`${platform}_${fileType}`,
|
||||||
|
);
|
||||||
|
ensureDir(dir);
|
||||||
|
const storagePath = path.join(dir, `${fileId}_${fileName}`);
|
||||||
|
fs.copyFileSync(sourcePath, storagePath);
|
||||||
|
const stat = fs.statSync(storagePath);
|
||||||
|
return { storagePath, fileName, size: stat.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a customization file from storage.
|
||||||
|
*/
|
||||||
|
export async function deleteCustomizationFile(
|
||||||
|
storagePath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (fs.existsSync(storagePath)) {
|
||||||
|
fs.unlinkSync(storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Storage Info ====================
|
// ==================== Storage Info ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -449,4 +489,5 @@ export function initializeStorage(): void {
|
|||||||
ensureDir(getStorageBase());
|
ensureDir(getStorageBase());
|
||||||
ensureDir(getStoragePath("downloads"));
|
ensureDir(getStoragePath("downloads"));
|
||||||
ensureDir(getStoragePath("versions"));
|
ensureDir(getStoragePath("versions"));
|
||||||
|
ensureDir(getStoragePath("files"));
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/preload/index.d.ts
vendored
17
src/preload/index.d.ts
vendored
@@ -18,6 +18,9 @@ import type {
|
|||||||
ShowSaveDialogParams,
|
ShowSaveDialogParams,
|
||||||
SaveFileContentParams,
|
SaveFileContentParams,
|
||||||
CheckUpdateResult,
|
CheckUpdateResult,
|
||||||
|
FileSaveParams,
|
||||||
|
FileSaveResult,
|
||||||
|
FileDeleteParams,
|
||||||
} from "@shared/types/ipc";
|
} from "@shared/types/ipc";
|
||||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
||||||
import type {
|
import type {
|
||||||
@@ -58,7 +61,11 @@ export interface SelfAPI {
|
|||||||
) => Promise<Result<FileContent>>;
|
) => Promise<Result<FileContent>>;
|
||||||
|
|
||||||
// ==================== Deploy ====================
|
// ==================== Deploy ====================
|
||||||
deploy: (params: DeployParams) => Promise<DeployResult>;
|
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
|
||||||
|
|
||||||
|
// ==================== File Storage ====================
|
||||||
|
saveFile: (params: FileSaveParams) => Promise<Result<FileSaveResult>>;
|
||||||
|
deleteFile: (params: FileDeleteParams) => Promise<Result<void>>;
|
||||||
|
|
||||||
// ==================== Download ====================
|
// ==================== Download ====================
|
||||||
download: (params: DownloadParams) => Promise<DownloadResult>;
|
download: (params: DownloadParams) => Promise<DownloadResult>;
|
||||||
@@ -80,4 +87,12 @@ export interface SelfAPI {
|
|||||||
// ==================== Dialog ====================
|
// ==================== Dialog ====================
|
||||||
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
||||||
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
|
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
|
||||||
|
|
||||||
|
// ==================== File Path Utility ====================
|
||||||
|
/**
|
||||||
|
* Get the file system path for a File object.
|
||||||
|
* Use this for drag-and-drop file uploads.
|
||||||
|
* @see https://electronjs.org/docs/latest/api/web-utils
|
||||||
|
*/
|
||||||
|
getPathForFile: (file: File) => string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||||
import { electronAPI } from "@electron-toolkit/preload";
|
import { electronAPI } from "@electron-toolkit/preload";
|
||||||
import type { SelfAPI } from "./index.d";
|
import type { SelfAPI } from "./index.d";
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@ const api: SelfAPI = {
|
|||||||
// Deploy
|
// Deploy
|
||||||
deploy: (params) => ipcRenderer.invoke("deploy", params),
|
deploy: (params) => ipcRenderer.invoke("deploy", params),
|
||||||
|
|
||||||
|
// File storage
|
||||||
|
saveFile: (params) => ipcRenderer.invoke("file:save", params),
|
||||||
|
deleteFile: (params) => ipcRenderer.invoke("file:delete", params),
|
||||||
|
|
||||||
// Download
|
// Download
|
||||||
download: (params) => ipcRenderer.invoke("download", params),
|
download: (params) => ipcRenderer.invoke("download", params),
|
||||||
downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params),
|
downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params),
|
||||||
@@ -42,6 +46,9 @@ const api: SelfAPI = {
|
|||||||
// Dialog
|
// Dialog
|
||||||
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params),
|
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params),
|
||||||
saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params),
|
saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params),
|
||||||
|
|
||||||
|
// File path utility (for drag-and-drop)
|
||||||
|
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use `contextBridge` APIs to expose Electron APIs to
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
|
|||||||
@@ -150,6 +150,20 @@ const App: React.FC = () => {
|
|||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const token = useTheme();
|
const token = useTheme();
|
||||||
|
|
||||||
|
// Prevent Electron from navigating to file:// URLs when files are dropped
|
||||||
|
// outside of designated drop zones
|
||||||
|
React.useEffect(() => {
|
||||||
|
const preventNavigation = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
document.addEventListener("dragover", preventNavigation);
|
||||||
|
document.addEventListener("drop", preventNavigation);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("dragover", preventNavigation);
|
||||||
|
document.removeEventListener("drop", preventNavigation);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { currentDomain } = useDomainStore();
|
const { currentDomain } = useDomainStore();
|
||||||
const {
|
const {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* AppDetail Component
|
* AppDetail Component
|
||||||
* Displays app configuration details
|
* Displays app configuration details with file management and deploy functionality.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Spin, Tag, Space, App as AntApp } from "antd";
|
import { Spin, Tag, Space, App as AntApp } from "antd";
|
||||||
import { Button, Empty, MaterialFileTypeIcon } from "@lobehub/ui";
|
import { Button, Empty } from "@lobehub/ui";
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Code,
|
Rocket,
|
||||||
Monitor,
|
Monitor,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { createStyles, useTheme } from "antd-style";
|
import { createStyles, useTheme } from "antd-style";
|
||||||
import { useAppStore, useDomainStore, useSessionStore } from "@renderer/stores";
|
import {
|
||||||
|
useAppStore,
|
||||||
|
useDomainStore,
|
||||||
|
useSessionStore,
|
||||||
|
useFileChangeStore,
|
||||||
|
} from "@renderer/stores";
|
||||||
import { CodeViewer } from "../CodeViewer";
|
import { CodeViewer } from "../CodeViewer";
|
||||||
import { FileConfigResponse, isFileResource } from "@shared/types/kintone";
|
import { isFileResource, isUrlResource } from "@shared/types/kintone";
|
||||||
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
||||||
|
import type { DeployFileEntry } from "@shared/types/ipc";
|
||||||
|
import FileSection from "./FileSection";
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
container: css`
|
container: css`
|
||||||
@@ -58,50 +65,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
`,
|
`,
|
||||||
// Table-like file list styles
|
|
||||||
fileTable: css`
|
|
||||||
border: 1px solid ${token.colorBorderSecondary};
|
|
||||||
border-radius: ${token.borderRadiusLG}px;
|
|
||||||
overflow: hidden;
|
|
||||||
`,
|
|
||||||
fileItem: css`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: ${token.colorBgTextHover};
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
fileInfo: css`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: ${token.paddingSM}px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
`,
|
|
||||||
fileIcon: css`
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`,
|
|
||||||
fileName: css`
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
`,
|
|
||||||
emptySection: css`
|
emptySection: css`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: ${token.paddingLG}px;
|
padding: ${token.paddingLG}px;
|
||||||
@@ -111,23 +74,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
`,
|
`,
|
||||||
sectionHeader: css`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: ${token.paddingXS}px;
|
|
||||||
padding: ${token.paddingSM}px 0;
|
|
||||||
margin-top: ${token.marginMD}px;
|
|
||||||
font-weight: ${token.fontWeightStrong};
|
|
||||||
color: ${token.colorTextSecondary};
|
|
||||||
|
|
||||||
&:first-of-type {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
sectionTitle: css`
|
|
||||||
font-size: ${token.fontSizeSM}px;
|
|
||||||
`,
|
|
||||||
// Back button - no border, left aligned with text
|
|
||||||
backButton: css`
|
backButton: css`
|
||||||
padding: ${token.marginSM}px 0;
|
padding: ${token.marginSM}px 0;
|
||||||
padding-left: ${token.marginXS}px;
|
padding-left: ${token.marginXS}px;
|
||||||
@@ -141,10 +87,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
`,
|
`,
|
||||||
fileSize: css`
|
|
||||||
min-width: 30px;
|
|
||||||
text-align: right;
|
|
||||||
`,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const AppDetail: React.FC = () => {
|
const AppDetail: React.FC = () => {
|
||||||
@@ -154,28 +96,77 @@ const AppDetail: React.FC = () => {
|
|||||||
const { currentDomain } = useDomainStore();
|
const { currentDomain } = useDomainStore();
|
||||||
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
|
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
|
||||||
useAppStore();
|
useAppStore();
|
||||||
const { viewMode, selectedFile, setViewMode, setSelectedFile } = useSessionStore();
|
const { viewMode, selectedFile, setViewMode, setSelectedFile } =
|
||||||
|
useSessionStore();
|
||||||
|
const fileChangeStore = useFileChangeStore();
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
|
|
||||||
// Download state: track which file is being downloaded
|
const [downloadingKey, setDownloadingKey] = React.useState<string | null>(null);
|
||||||
const [downloadingKey, setDownloadingKey] = React.useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [downloadingAll, setDownloadingAll] = React.useState(false);
|
const [downloadingAll, setDownloadingAll] = React.useState(false);
|
||||||
|
const [deploying, setDeploying] = React.useState(false);
|
||||||
|
|
||||||
// Reset view mode when app changes
|
// Reset view mode when app changes
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
setViewMode("list");
|
setViewMode("list");
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
}, [selectedAppId]);
|
}, [selectedAppId]);
|
||||||
|
|
||||||
// Load app detail when selected
|
// Load app detail when selected
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentDomain && selectedAppId) {
|
if (currentDomain && selectedAppId) {
|
||||||
loadAppDetail();
|
loadAppDetail();
|
||||||
}
|
}
|
||||||
}, [currentDomain, selectedAppId]);
|
}, [currentDomain, selectedAppId]);
|
||||||
|
|
||||||
|
// Initialize file change store from Kintone data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentApp || !currentDomain || !selectedAppId) return;
|
||||||
|
|
||||||
|
const customize = currentApp.customization;
|
||||||
|
if (!customize) return;
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
...(customize.desktop?.js ?? []).map((file, index) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: getDisplayName(file, "js", index),
|
||||||
|
fileType: "js" as const,
|
||||||
|
platform: "desktop" as const,
|
||||||
|
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
|
||||||
|
fileKey: getFileKey(file),
|
||||||
|
url: isUrlResource(file) ? file.url : undefined,
|
||||||
|
})),
|
||||||
|
...(customize.desktop?.css ?? []).map((file, index) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: getDisplayName(file, "css", index),
|
||||||
|
fileType: "css" as const,
|
||||||
|
platform: "desktop" as const,
|
||||||
|
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
|
||||||
|
fileKey: getFileKey(file),
|
||||||
|
url: isUrlResource(file) ? file.url : undefined,
|
||||||
|
})),
|
||||||
|
...(customize.mobile?.js ?? []).map((file, index) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: getDisplayName(file, "js", index),
|
||||||
|
fileType: "js" as const,
|
||||||
|
platform: "mobile" as const,
|
||||||
|
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
|
||||||
|
fileKey: getFileKey(file),
|
||||||
|
url: isUrlResource(file) ? file.url : undefined,
|
||||||
|
})),
|
||||||
|
...(customize.mobile?.css ?? []).map((file, index) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fileName: getDisplayName(file, "css", index),
|
||||||
|
fileType: "css" as const,
|
||||||
|
platform: "mobile" as const,
|
||||||
|
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
|
||||||
|
fileKey: getFileKey(file),
|
||||||
|
url: isUrlResource(file) ? file.url : undefined,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files);
|
||||||
|
}, [currentApp?.appId]);
|
||||||
|
|
||||||
const loadAppDetail = async () => {
|
const loadAppDetail = async () => {
|
||||||
if (!currentDomain || !selectedAppId) return;
|
if (!currentDomain || !selectedAppId) return;
|
||||||
|
|
||||||
@@ -185,6 +176,8 @@ const AppDetail: React.FC = () => {
|
|||||||
domainId: currentDomain.id,
|
domainId: currentDomain.id,
|
||||||
appId: selectedAppId,
|
appId: selectedAppId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if we're still on the same app and component is mounted before updating
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setCurrentApp(result.data);
|
setCurrentApp(result.data);
|
||||||
}
|
}
|
||||||
@@ -195,13 +188,154 @@ const AppDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileClick = useCallback(
|
||||||
|
(fileKey: string, name: string) => {
|
||||||
|
const ext = name.split(".").pop()?.toLowerCase();
|
||||||
|
const type = ext === "css" ? "css" : "js";
|
||||||
|
setSelectedFile({ type, fileKey, name });
|
||||||
|
setViewMode("code");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackToList = useCallback(() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setSelectedFile(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownloadFile = useCallback(
|
||||||
|
async (fileKey: string, fileName: string) => {
|
||||||
|
if (!currentDomain || downloadingKey) return;
|
||||||
|
|
||||||
|
const type = fileName.endsWith(".css") ? "css" : "js";
|
||||||
|
const hasExt = /\.(js|css)$/i.test(fileName);
|
||||||
|
const finalFileName = hasExt ? fileName : `${fileName}.${type}`;
|
||||||
|
|
||||||
|
setDownloadingKey(fileKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dialogResult = await window.api.showSaveDialog({
|
||||||
|
defaultPath: finalFileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dialogResult.success || !dialogResult.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentResult = await window.api.getFileContent({
|
||||||
|
domainId: currentDomain.id,
|
||||||
|
fileKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contentResult.success || !contentResult.data.content) {
|
||||||
|
message.error(
|
||||||
|
contentResult.success
|
||||||
|
? t("downloadFailed", { ns: "common" })
|
||||||
|
: contentResult.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveResult = await window.api.saveFileContent({
|
||||||
|
filePath: dialogResult.data,
|
||||||
|
content: contentResult.data.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saveResult.success) {
|
||||||
|
message.success(t("downloadSuccess", { ns: "common" }));
|
||||||
|
} else {
|
||||||
|
message.error(saveResult.error || t("downloadFailed", { ns: "common" }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error(t("downloadFailed", { ns: "common" }));
|
||||||
|
} finally {
|
||||||
|
setDownloadingKey(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentDomain, downloadingKey, message, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDownloadAll = useCallback(async () => {
|
||||||
|
if (!currentDomain || !selectedAppId || downloadingAll || !currentApp) return;
|
||||||
|
|
||||||
|
const appName = currentApp.name || "app";
|
||||||
|
const sanitizedAppName = appName.replace(/[:*?"<>|]/g, "_");
|
||||||
|
const date = new Date().toISOString().split("T")[0];
|
||||||
|
const defaultFilename = `${sanitizedAppName}_${date}.zip`;
|
||||||
|
|
||||||
|
const dialogResult = await window.api.showSaveDialog({
|
||||||
|
defaultPath: defaultFilename,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dialogResult.success || !dialogResult.data) return;
|
||||||
|
|
||||||
|
const savePath = dialogResult.data;
|
||||||
|
setDownloadingAll(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.api.downloadAllZip({
|
||||||
|
domainId: currentDomain.id,
|
||||||
|
appId: selectedAppId,
|
||||||
|
savePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message.success(t("downloadAllSuccess", { path: result.data?.path, ns: "common" }));
|
||||||
|
} else {
|
||||||
|
message.error(result.error || t("downloadFailed", { ns: "common" }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error(t("downloadFailed", { ns: "common" }));
|
||||||
|
} finally {
|
||||||
|
setDownloadingAll(false);
|
||||||
|
}
|
||||||
|
}, [currentDomain, selectedAppId, downloadingAll, currentApp, message, t]);
|
||||||
|
|
||||||
|
const handleDeploy = useCallback(async () => {
|
||||||
|
if (!currentDomain || !selectedAppId || deploying) return;
|
||||||
|
|
||||||
|
const allFiles = fileChangeStore.getFiles(currentDomain.id, selectedAppId);
|
||||||
|
const deployEntries: DeployFileEntry[] = allFiles.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
fileName: f.fileName,
|
||||||
|
fileType: f.fileType,
|
||||||
|
platform: f.platform,
|
||||||
|
status: f.status,
|
||||||
|
fileKey: f.fileKey,
|
||||||
|
url: f.url,
|
||||||
|
storagePath: f.storagePath,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setDeploying(true);
|
||||||
|
try {
|
||||||
|
const result = await window.api.deploy({
|
||||||
|
domainId: currentDomain.id,
|
||||||
|
appId: selectedAppId,
|
||||||
|
files: deployEntries,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message.success(t("deploySuccess"));
|
||||||
|
// Clear changes and reload from Kintone
|
||||||
|
fileChangeStore.clearChanges(currentDomain.id, selectedAppId);
|
||||||
|
await loadAppDetail();
|
||||||
|
} else {
|
||||||
|
message.error(result.error || t("deployFailed"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
error instanceof Error ? error.message : t("deployFailed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDeploying(false);
|
||||||
|
}
|
||||||
|
}, [currentDomain, selectedAppId, deploying, fileChangeStore, message, t]);
|
||||||
|
|
||||||
if (!currentDomain || !selectedAppId) {
|
if (!currentDomain || !selectedAppId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.emptySection}>
|
<div className={styles.emptySection}>
|
||||||
<Empty
|
<Empty description={t("selectApp")} />
|
||||||
description={t("selectApp")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -219,258 +353,16 @@ const AppDetail: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.emptySection}>
|
<div className={styles.emptySection}>
|
||||||
<Empty
|
<Empty description={t("appNotFound")} />
|
||||||
description={t("appNotFound")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileClick = (
|
const changeCount = currentDomain && selectedAppId
|
||||||
type: "js" | "css",
|
? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId)
|
||||||
fileKey: string,
|
: { added: 0, deleted: 0 };
|
||||||
name: string,
|
const hasChanges = changeCount.added > 0 || changeCount.deleted > 0;
|
||||||
) => {
|
|
||||||
setSelectedFile({ type, fileKey, name });
|
|
||||||
setViewMode("code");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackToList = () => {
|
|
||||||
setViewMode("list");
|
|
||||||
setSelectedFile(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download single file with save dialog
|
|
||||||
const handleDownloadFile = async (
|
|
||||||
fileKey: string,
|
|
||||||
fileName: string,
|
|
||||||
type: "js" | "css",
|
|
||||||
) => {
|
|
||||||
if (!currentDomain || downloadingKey) return;
|
|
||||||
|
|
||||||
// Check if fileName already has extension
|
|
||||||
const hasExt = /\.(js|css)$/i.test(fileName);
|
|
||||||
const finalFileName = hasExt ? fileName : `${fileName}.${type}`;
|
|
||||||
|
|
||||||
setDownloadingKey(fileKey);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Show save dialog
|
|
||||||
const dialogResult = await window.api.showSaveDialog({
|
|
||||||
defaultPath: finalFileName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dialogResult.success || !dialogResult.data) {
|
|
||||||
// User cancelled
|
|
||||||
setDownloadingKey(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savePath = dialogResult.data;
|
|
||||||
|
|
||||||
// 2. Get file content
|
|
||||||
const contentResult = await window.api.getFileContent({
|
|
||||||
domainId: currentDomain.id,
|
|
||||||
fileKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!contentResult.success || !contentResult.data.content) {
|
|
||||||
message.error(
|
|
||||||
contentResult.success
|
|
||||||
? t("downloadFailed", { ns: "common" })
|
|
||||||
: contentResult.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Save to selected path
|
|
||||||
const saveResult = await window.api.saveFileContent({
|
|
||||||
filePath: savePath,
|
|
||||||
content: contentResult.data.content,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (saveResult.success) {
|
|
||||||
message.success(t("downloadSuccess", { ns: "common" }));
|
|
||||||
} else {
|
|
||||||
message.error(
|
|
||||||
saveResult.error || t("downloadFailed", { ns: "common" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Download failed:", error);
|
|
||||||
message.error(t("downloadFailed", { ns: "common" }));
|
|
||||||
} finally {
|
|
||||||
setDownloadingKey(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download all files
|
|
||||||
const handleDownloadAll = async () => {
|
|
||||||
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.downloadAllZip({
|
|
||||||
domainId: currentDomain.id,
|
|
||||||
appId: selectedAppId,
|
|
||||||
savePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
|
|
||||||
message.success(t("downloadAllSuccess", { path: result.data.path, ns: "common" }))
|
|
||||||
} else {
|
|
||||||
message.error(result.error || t("downloadFailed", { ns: "common" }));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Download all failed:", error);
|
|
||||||
message.error(t("downloadFailed", { ns: "common" }));
|
|
||||||
} finally {
|
|
||||||
setDownloadingAll(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Icon for file type using MaterialFileTypeIcon
|
|
||||||
const getFileTypeIcon = (type: "js" | "css", filename: string) => {
|
|
||||||
return <MaterialFileTypeIcon type='file' filename={`test.${type}`} size={16} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format file size to human readable
|
|
||||||
const formatFileSize = (size: string | number | undefined): string => {
|
|
||||||
if (!size) return "-";
|
|
||||||
const num = typeof size === "string" ? parseInt(size, 10) : size;
|
|
||||||
if (isNaN(num)) return "-";
|
|
||||||
if (num < 1024) return `${num} B`;
|
|
||||||
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(num / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get file metadata from FILE type resource
|
|
||||||
const getFileMeta = (
|
|
||||||
file: FileConfigResponse,
|
|
||||||
): { contentType: string; size: string } | null => {
|
|
||||||
if (isFileResource(file) && file.file) {
|
|
||||||
return {
|
|
||||||
contentType: file.file.contentType || "-",
|
|
||||||
size: file.file.size || "-",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFileList = (
|
|
||||||
files: FileConfigResponse[] | undefined,
|
|
||||||
type: "js" | "css",
|
|
||||||
) => {
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={styles.fileTable}>
|
|
||||||
<div className={styles.emptySection} style={{ padding: "16px" }}>
|
|
||||||
{t("noConfig")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.fileTable}>
|
|
||||||
{files.map((file, index) => {
|
|
||||||
const fileName = getDisplayName(file, type, index);
|
|
||||||
const fileKey = getFileKey(file);
|
|
||||||
const canView = isFileResource(file);
|
|
||||||
const isDownloading = fileKey === downloadingKey;
|
|
||||||
const fileMeta = getFileMeta(file);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={styles.fileItem}
|
|
||||||
onClick={() => {
|
|
||||||
if (fileKey) {
|
|
||||||
handleFileClick(type, fileKey, fileName);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.fileInfo}>
|
|
||||||
<span className={styles.fileIcon}>{getFileTypeIcon(type, fileName)}</span>
|
|
||||||
<span className={styles.fileName}>{fileName}</span>
|
|
||||||
</div>
|
|
||||||
{canView && (
|
|
||||||
<Space>
|
|
||||||
{fileMeta && (
|
|
||||||
<span className={styles.fileSize}>{formatFileSize(fileMeta.size)}</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<Code size={16} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (fileKey) {
|
|
||||||
handleFileClick(type, fileKey, fileName);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("view")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<Download size={16} />}
|
|
||||||
loading={isDownloading}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (fileKey) {
|
|
||||||
handleDownloadFile(fileKey, fileName, type);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("download", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFileSection = (
|
|
||||||
title: string,
|
|
||||||
icon: React.ReactNode,
|
|
||||||
files: FileConfigResponse[] | undefined,
|
|
||||||
type: "js" | "css",
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={styles.sectionHeader}>
|
|
||||||
{icon}
|
|
||||||
<span className={styles.sectionTitle}>{title}</span>
|
|
||||||
</div>
|
|
||||||
{renderFileList(files, type)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -485,43 +377,87 @@ const AppDetail: React.FC = () => {
|
|||||||
{t("versionHistory", { ns: "common" })}
|
{t("versionHistory", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
loading={downloadingAll}
|
loading={downloadingAll}
|
||||||
onClick={handleDownloadAll}
|
onClick={handleDownloadAll}
|
||||||
>
|
>
|
||||||
{t("downloadAll")}
|
{t("downloadAll")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Rocket size={16} />}
|
||||||
|
loading={deploying}
|
||||||
|
disabled={!hasChanges && !deploying}
|
||||||
|
onClick={handleDeploy}
|
||||||
|
>
|
||||||
|
{t("deploy")}
|
||||||
|
{hasChanges && (
|
||||||
|
<Tag
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
color: token.colorPrimary,
|
||||||
|
marginLeft: 4,
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
padding: "0 4px",
|
||||||
|
lineHeight: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{changeCount.added > 0 && `+${changeCount.added}`}
|
||||||
|
{changeCount.added > 0 && changeCount.deleted > 0 && " "}
|
||||||
|
{changeCount.deleted > 0 && `-${changeCount.deleted}`}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{viewMode === "list" ? (
|
{viewMode === "list" ? (
|
||||||
<>
|
<>
|
||||||
{renderFileSection(
|
<FileSection
|
||||||
t("pcJs"),
|
title={t("pcJs")}
|
||||||
<Monitor size={14} />,
|
icon={<Monitor size={14} />}
|
||||||
currentApp.customization?.desktop?.js,
|
platform="desktop"
|
||||||
"js",
|
fileType="js"
|
||||||
)}
|
domainId={currentDomain.id}
|
||||||
{renderFileSection(
|
appId={selectedAppId}
|
||||||
t("pcCss"),
|
downloadingKey={downloadingKey}
|
||||||
<Monitor size={14} />,
|
onView={handleFileClick}
|
||||||
currentApp.customization?.desktop?.css,
|
onDownload={handleDownloadFile}
|
||||||
"css",
|
/>
|
||||||
)}
|
<FileSection
|
||||||
{renderFileSection(
|
title={t("pcCss")}
|
||||||
t("mobileJs"),
|
icon={<Monitor size={14} />}
|
||||||
<Smartphone size={14} />,
|
platform="desktop"
|
||||||
currentApp.customization?.mobile?.js,
|
fileType="css"
|
||||||
"js",
|
domainId={currentDomain.id}
|
||||||
)}
|
appId={selectedAppId}
|
||||||
{renderFileSection(
|
downloadingKey={downloadingKey}
|
||||||
t("mobileCss"),
|
onView={handleFileClick}
|
||||||
<Smartphone size={14} />,
|
onDownload={handleDownloadFile}
|
||||||
currentApp.customization?.mobile?.css,
|
/>
|
||||||
"css",
|
<FileSection
|
||||||
)}
|
title={t("mobileJs")}
|
||||||
|
icon={<Smartphone size={14} />}
|
||||||
|
platform="mobile"
|
||||||
|
fileType="js"
|
||||||
|
domainId={currentDomain.id}
|
||||||
|
appId={selectedAppId}
|
||||||
|
downloadingKey={downloadingKey}
|
||||||
|
onView={handleFileClick}
|
||||||
|
onDownload={handleDownloadFile}
|
||||||
|
/>
|
||||||
|
<FileSection
|
||||||
|
title={t("mobileCss")}
|
||||||
|
icon={<Smartphone size={14} />}
|
||||||
|
platform="mobile"
|
||||||
|
fileType="css"
|
||||||
|
domainId={currentDomain.id}
|
||||||
|
appId={selectedAppId}
|
||||||
|
downloadingKey={downloadingKey}
|
||||||
|
onView={handleFileClick}
|
||||||
|
onDownload={handleDownloadFile}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.codeView}>
|
<div className={styles.codeView}>
|
||||||
|
|||||||
94
src/renderer/src/components/AppDetail/DropZone.tsx
Normal file
94
src/renderer/src/components/AppDetail/DropZone.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* DropZone Component
|
||||||
|
* A click-to-select file button (visual hint for the drop zone).
|
||||||
|
* Actual drag-and-drop is handled at the FileSection level.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createStyles } from "antd-style";
|
||||||
|
import { CloudUpload } from "lucide-react";
|
||||||
|
|
||||||
|
interface DropZoneProps {
|
||||||
|
fileType: "js" | "css";
|
||||||
|
isSaving: boolean;
|
||||||
|
onFileSelected: (file: File) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
|
button: css`
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: ${token.paddingXS}px;
|
||||||
|
padding: ${token.paddingSM}px;
|
||||||
|
border: 2px dashed ${token.colorBorderSecondary};
|
||||||
|
border-radius: ${token.borderRadiusSM}px;
|
||||||
|
color: ${token.colorTextQuaternary};
|
||||||
|
font-size: ${token.fontSizeSM}px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: ${token.colorPrimary};
|
||||||
|
color: ${token.colorPrimary};
|
||||||
|
background: ${token.colorPrimaryBg};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DropZone: React.FC<DropZoneProps> = ({ fileType, isSaving, onFileSelected }) => {
|
||||||
|
const { t } = useTranslation(["app", "common"]);
|
||||||
|
const { styles } = useStyles();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
await onFileSelected(file);
|
||||||
|
}
|
||||||
|
e.target.value = "";
|
||||||
|
},
|
||||||
|
[onFileSelected],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isSaving}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CloudUpload size={14} />
|
||||||
|
<span>
|
||||||
|
{isSaving
|
||||||
|
? t("loading", { ns: "common" })
|
||||||
|
: t("dropZoneHint", { fileType: `.${fileType}` })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={`.${fileType}`}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropZone;
|
||||||
185
src/renderer/src/components/AppDetail/FileItem.tsx
Normal file
185
src/renderer/src/components/AppDetail/FileItem.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* FileItem Component
|
||||||
|
* Displays a single file with status indicator (unchanged/added/deleted).
|
||||||
|
* Shows appropriate action buttons based on status.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Space, Badge } from "antd";
|
||||||
|
import { Button, MaterialFileTypeIcon, SortableList } from "@lobehub/ui";
|
||||||
|
import { Code, Download, Trash2, Undo2 } from "lucide-react";
|
||||||
|
import { createStyles, useTheme } from "antd-style";
|
||||||
|
import type { FileEntry } from "@renderer/stores";
|
||||||
|
|
||||||
|
interface FileItemProps {
|
||||||
|
entry: FileEntry;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRestore: () => void;
|
||||||
|
onView?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
isDownloading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
|
item: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||||
|
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fileInfo: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${token.paddingXS}px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`,
|
||||||
|
fileName: css`
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
`,
|
||||||
|
fileNameDeleted: css`
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: ${token.colorTextDisabled};
|
||||||
|
`,
|
||||||
|
fileSize: css`
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
color: ${token.colorTextSecondary};
|
||||||
|
font-size: ${token.fontSizeSM}px;
|
||||||
|
`,
|
||||||
|
statusDot: css`
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formatFileSize = (size: number | undefined): string => {
|
||||||
|
if (!size) return "";
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileItem: React.FC<FileItemProps> = ({
|
||||||
|
entry,
|
||||||
|
onDelete,
|
||||||
|
onRestore,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
isDownloading,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(["app", "common"]);
|
||||||
|
const { styles, cx } = useStyles();
|
||||||
|
const token = useTheme();
|
||||||
|
|
||||||
|
const statusColor = {
|
||||||
|
unchanged: "transparent",
|
||||||
|
added: token.colorSuccess,
|
||||||
|
deleted: token.colorError,
|
||||||
|
}[entry.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableList.Item id={entry.id}>
|
||||||
|
<div className={styles.item}>
|
||||||
|
<div className={styles.fileInfo}>
|
||||||
|
<SortableList.DragHandle />
|
||||||
|
{entry.status !== "unchanged" && (
|
||||||
|
<div
|
||||||
|
className={styles.statusDot}
|
||||||
|
style={{ background: statusColor }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MaterialFileTypeIcon
|
||||||
|
type="file"
|
||||||
|
filename={`file.${entry.fileType}`}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
styles.fileName,
|
||||||
|
entry.status === "deleted" && styles.fileNameDeleted,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.fileName}
|
||||||
|
</span>
|
||||||
|
{entry.status === "added" && (
|
||||||
|
<Badge
|
||||||
|
color={token.colorSuccess}
|
||||||
|
text={t("statusAdded")}
|
||||||
|
style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{entry.status === "deleted" && (
|
||||||
|
<Badge
|
||||||
|
color={token.colorError}
|
||||||
|
text={t("statusDeleted")}
|
||||||
|
style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
{entry.size && (
|
||||||
|
<span className={styles.fileSize}>{formatFileSize(entry.size)}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.status === "deleted" ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<Undo2 size={16} />}
|
||||||
|
onClick={onRestore}
|
||||||
|
>
|
||||||
|
{t("restore")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{entry.status === "unchanged" && onView && entry.fileKey && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<Code size={16} />}
|
||||||
|
onClick={onView}
|
||||||
|
>
|
||||||
|
{t("view")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{entry.status === "unchanged" && onDownload && entry.fileKey && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<Download size={16} />}
|
||||||
|
loading={isDownloading}
|
||||||
|
onClick={onDownload}
|
||||||
|
>
|
||||||
|
{t("download", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<Trash2 size={16} />}
|
||||||
|
onClick={onDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</SortableList.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileItem;
|
||||||
409
src/renderer/src/components/AppDetail/FileSection.tsx
Normal file
409
src/renderer/src/components/AppDetail/FileSection.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* FileSection Component
|
||||||
|
* Displays a file section (PC JS / PC CSS / Mobile JS / Mobile CSS).
|
||||||
|
* The entire section is a drag-and-drop target for files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Tag, App as AntApp } from "antd";
|
||||||
|
import { SortableList } from "@lobehub/ui";
|
||||||
|
import { createStyles, useTheme } from "antd-style";
|
||||||
|
import { useFileChangeStore } from "@renderer/stores";
|
||||||
|
import type { FileEntry } from "@renderer/stores";
|
||||||
|
import FileItem from "./FileItem";
|
||||||
|
import DropZone from "./DropZone";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||||
|
|
||||||
|
interface FileSectionProps {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
platform: "desktop" | "mobile";
|
||||||
|
fileType: "js" | "css";
|
||||||
|
domainId: string;
|
||||||
|
appId: string;
|
||||||
|
downloadingKey: string | null;
|
||||||
|
onView: (fileKey: string, fileName: string) => void;
|
||||||
|
onDownload: (fileKey: string, fileName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
|
section: css`
|
||||||
|
margin-top: ${token.marginMD}px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
sectionHeader: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${token.paddingXS}px;
|
||||||
|
padding: ${token.paddingSM}px 0;
|
||||||
|
font-weight: ${token.fontWeightStrong};
|
||||||
|
color: ${token.colorTextSecondary};
|
||||||
|
font-size: ${token.fontSizeSM}px;
|
||||||
|
`,
|
||||||
|
fileTable: css`
|
||||||
|
border: 2px dashed transparent;
|
||||||
|
border-radius: ${token.borderRadiusLG}px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
`,
|
||||||
|
fileTableBorder: css`
|
||||||
|
border: 1px solid ${token.colorBorderSecondary};
|
||||||
|
`,
|
||||||
|
fileTableDragging: css`
|
||||||
|
border-color: ${token.colorPrimary} !important;
|
||||||
|
background: ${token.colorPrimaryBg};
|
||||||
|
`,
|
||||||
|
fileTableDraggingInvalid: css`
|
||||||
|
border-color: ${token.colorError} !important;
|
||||||
|
background: ${token.colorErrorBg};
|
||||||
|
`,
|
||||||
|
emptySection: css`
|
||||||
|
padding: ${token.paddingMD}px;
|
||||||
|
text-align: center;
|
||||||
|
color: ${token.colorTextSecondary};
|
||||||
|
font-size: ${token.fontSizeSM}px;
|
||||||
|
`,
|
||||||
|
dropZoneWrapper: css`
|
||||||
|
padding: ${token.paddingXS}px ${token.paddingSM}px;
|
||||||
|
border-top: 1px solid ${token.colorBorderSecondary};
|
||||||
|
background: ${token.colorBgContainer};
|
||||||
|
`,
|
||||||
|
dropOverlay: css`
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: ${token.fontWeightStrong};
|
||||||
|
font-size: ${token.fontSize}px;
|
||||||
|
border-radius: ${token.borderRadiusLG}px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
`,
|
||||||
|
sortableItem: css`
|
||||||
|
background: ${token.colorBgContainer};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${token.colorBgTextHover};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const FileSection: React.FC<FileSectionProps> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
platform,
|
||||||
|
fileType,
|
||||||
|
domainId,
|
||||||
|
appId,
|
||||||
|
downloadingKey,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation("app");
|
||||||
|
const { styles, cx } = useStyles();
|
||||||
|
const token = useTheme();
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
|
||||||
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||||
|
const [isDragInvalid, setIsDragInvalid] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const dragCounterRef = useRef(0);
|
||||||
|
|
||||||
|
const { getSectionFiles, addFile, deleteFile, restoreFile, reorderSection } =
|
||||||
|
useFileChangeStore();
|
||||||
|
|
||||||
|
const files = getSectionFiles(domainId, appId, platform, fileType);
|
||||||
|
|
||||||
|
const addedCount = files.filter((f) => f.status === "added").length;
|
||||||
|
const deletedCount = files.filter((f) => f.status === "deleted").length;
|
||||||
|
const hasChanges = addedCount > 0 || deletedCount > 0;
|
||||||
|
|
||||||
|
// ── Shared save logic ─────────────────────────────────────────────────────
|
||||||
|
const saveFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
if (ext !== fileType) {
|
||||||
|
message.error(t("fileTypeNotSupported", { expected: `.${fileType}` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
message.error(t("fileSizeExceeded"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath =
|
||||||
|
window.api.getPathForFile(file) || (file as File & { path?: string }).path;
|
||||||
|
if (!sourcePath) {
|
||||||
|
message.error(t("fileAddFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const fileId = crypto.randomUUID();
|
||||||
|
const result = await window.api.saveFile({
|
||||||
|
domainId,
|
||||||
|
appId,
|
||||||
|
platform,
|
||||||
|
fileType,
|
||||||
|
fileId,
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
message.error(result.error || t("fileAddFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: FileEntry = {
|
||||||
|
id: fileId,
|
||||||
|
fileName: result.data.fileName,
|
||||||
|
fileType,
|
||||||
|
platform,
|
||||||
|
status: "added",
|
||||||
|
size: result.data.size,
|
||||||
|
storagePath: result.data.storagePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
addFile(domainId, appId, entry);
|
||||||
|
message.success(t("fileAdded", { name: result.data.fileName }));
|
||||||
|
} catch {
|
||||||
|
message.error(t("fileAddFailed"));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[domainId, appId, platform, fileType, addFile, message, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Drag-and-drop handlers (entire section is the drop zone) ──────────────
|
||||||
|
const handleDragEnter = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounterRef.current++;
|
||||||
|
|
||||||
|
if (dragCounterRef.current === 1) {
|
||||||
|
const items = Array.from(e.dataTransfer.items);
|
||||||
|
const hasFile = items.some((i) => i.kind === "file");
|
||||||
|
if (!hasFile) return;
|
||||||
|
|
||||||
|
// Best-effort type check — MIME types are unreliable on Windows,
|
||||||
|
// so we fall back to "probably valid" if type is empty/unknown.
|
||||||
|
const hasInvalidType = items.some((i) => {
|
||||||
|
if (i.kind !== "file") return false;
|
||||||
|
const mime = i.type.toLowerCase();
|
||||||
|
if (!mime) return false; // unknown → allow, validate on drop
|
||||||
|
if (fileType === "js") {
|
||||||
|
return !(
|
||||||
|
mime.includes("javascript") ||
|
||||||
|
mime.includes("text/plain") ||
|
||||||
|
mime === ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fileType === "css") {
|
||||||
|
return !(
|
||||||
|
mime.includes("css") ||
|
||||||
|
mime.includes("text/plain") ||
|
||||||
|
mime === ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDragInvalid(hasInvalidType);
|
||||||
|
setIsDraggingOver(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fileType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounterRef.current--;
|
||||||
|
if (dragCounterRef.current === 0) {
|
||||||
|
setIsDraggingOver(false);
|
||||||
|
setIsDragInvalid(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setIsDraggingOver(false);
|
||||||
|
setIsDragInvalid(false);
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
|
if (droppedFiles.length === 0) return;
|
||||||
|
await saveFile(droppedFiles[0]);
|
||||||
|
},
|
||||||
|
[saveFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Delete / restore ───────────────────────────────────────────────────────
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (entry: FileEntry) => {
|
||||||
|
if (entry.status === "added" && entry.storagePath) {
|
||||||
|
const result = await window.api.deleteFile({
|
||||||
|
storagePath: entry.storagePath,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
message.error(result.error || t("fileDeleteFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteFile(domainId, appId, entry.id);
|
||||||
|
},
|
||||||
|
[domainId, appId, deleteFile, message, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRestore = useCallback(
|
||||||
|
(fileId: string) => {
|
||||||
|
restoreFile(domainId, appId, fileId);
|
||||||
|
},
|
||||||
|
[domainId, appId, restoreFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Reorder ────────────────────────────────────────────────────────────────
|
||||||
|
const handleSortChange = useCallback(
|
||||||
|
(newItems: { id: string }[]) => {
|
||||||
|
reorderSection(
|
||||||
|
domainId,
|
||||||
|
appId,
|
||||||
|
platform,
|
||||||
|
fileType,
|
||||||
|
newItems.map((i) => i.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[domainId, appId, platform, fileType, reorderSection],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render item ────────────────────────────────────────────────────────────
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(item: { id: string }) => {
|
||||||
|
const entry = files.find((f) => f.id === item.id);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.sortableItem}>
|
||||||
|
<FileItem
|
||||||
|
entry={entry}
|
||||||
|
onDelete={() => handleDelete(entry)}
|
||||||
|
onRestore={() => handleRestore(entry.id)}
|
||||||
|
onView={
|
||||||
|
entry.fileKey
|
||||||
|
? () => onView(entry.fileKey!, entry.fileName)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDownload={
|
||||||
|
entry.fileKey
|
||||||
|
? () => onDownload(entry.fileKey!, entry.fileName)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isDownloading={downloadingKey === entry.fileKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
files,
|
||||||
|
handleDelete,
|
||||||
|
handleRestore,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
downloadingKey,
|
||||||
|
styles.sortableItem,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
{hasChanges && (
|
||||||
|
<Tag
|
||||||
|
color={token.colorPrimary}
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
padding: "0 4px",
|
||||||
|
lineHeight: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{addedCount > 0 && `+${addedCount}`}
|
||||||
|
{addedCount > 0 && deletedCount > 0 && " "}
|
||||||
|
{deletedCount > 0 && `-${deletedCount}`}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The entire card is the drop target */}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
styles.fileTable,
|
||||||
|
!isDraggingOver && styles.fileTableBorder,
|
||||||
|
isDraggingOver && !isDragInvalid && styles.fileTableDragging,
|
||||||
|
isDraggingOver && isDragInvalid && styles.fileTableDraggingInvalid,
|
||||||
|
)}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* Drag overlay */}
|
||||||
|
{isDraggingOver && (
|
||||||
|
<div
|
||||||
|
className={styles.dropOverlay}
|
||||||
|
style={{
|
||||||
|
background: isDragInvalid
|
||||||
|
? `${token.colorErrorBg}DD`
|
||||||
|
: `${token.colorPrimaryBg}DD`,
|
||||||
|
color: isDragInvalid ? token.colorError : token.colorPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDragInvalid
|
||||||
|
? t("fileTypeNotSupported", { expected: `.${fileType}` })
|
||||||
|
: t("dropFileHere")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className={styles.emptySection}>{t("noConfig")}</div>
|
||||||
|
) : (
|
||||||
|
<SortableList
|
||||||
|
items={files}
|
||||||
|
renderItem={renderItem}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
gap={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Click-to-add strip */}
|
||||||
|
<div className={styles.dropZoneWrapper}>
|
||||||
|
<DropZone
|
||||||
|
fileType={fileType}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onFileSelected={saveFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileSection;
|
||||||
@@ -115,11 +115,14 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
|||||||
const newOrder = newItems.map((item) => item.id);
|
const newOrder = newItems.map((item) => item.id);
|
||||||
const oldOrder = domains.map((d) => d.id);
|
const oldOrder = domains.map((d) => d.id);
|
||||||
|
|
||||||
// Find the from and to indices
|
// Find the element that was moved: its position changed from old to new
|
||||||
for (let i = 0; i < oldOrder.length; i++) {
|
// When item at oldIndex moves to newIndex, oldOrder[newIndex] !== newOrder[newIndex]
|
||||||
|
for (let i = 0; i < newOrder.length; i++) {
|
||||||
if (oldOrder[i] !== newOrder[i]) {
|
if (oldOrder[i] !== newOrder[i]) {
|
||||||
const fromIndex = i;
|
// The item at position i in newOrder came from somewhere in oldOrder
|
||||||
const toIndex = newOrder.indexOf(oldOrder[i]);
|
const movedItemId = newOrder[i];
|
||||||
|
const fromIndex = oldOrder.indexOf(movedItemId);
|
||||||
|
const toIndex = i;
|
||||||
reorderDomains(fromIndex, toIndex);
|
reorderDomains(fromIndex, toIndex);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,18 @@
|
|||||||
"pinApp": "Pin App",
|
"pinApp": "Pin App",
|
||||||
"selectDomainFirst": "Please select a Domain first",
|
"selectDomainFirst": "Please select a Domain first",
|
||||||
"loadAppsFailed": "Failed to load apps",
|
"loadAppsFailed": "Failed to load apps",
|
||||||
"backToList": "Back to List"
|
"backToList": "Back to List",
|
||||||
|
"deploy": "Deploy",
|
||||||
|
"deploySuccess": "Deployment successful",
|
||||||
|
"deployFailed": "Deployment failed",
|
||||||
|
"dropZoneHint": "Drop {{fileType}} here or click to add",
|
||||||
|
"dropFileHere": "Drop file here",
|
||||||
|
"fileTypeNotSupported": "Only {{expected}} files are supported",
|
||||||
|
"fileSizeExceeded": "File size exceeds the 20 MB limit",
|
||||||
|
"fileAdded": "{{name}} added",
|
||||||
|
"fileAddFailed": "Failed to add file",
|
||||||
|
"fileDeleteFailed": "Failed to delete file",
|
||||||
|
"statusAdded": "New",
|
||||||
|
"statusDeleted": "Deleted",
|
||||||
|
"restore": "Restore"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,18 @@
|
|||||||
"pinApp": "アプリをピン留め",
|
"pinApp": "アプリをピン留め",
|
||||||
"selectDomainFirst": "最初にドメインを選択してください",
|
"selectDomainFirst": "最初にドメインを選択してください",
|
||||||
"loadAppsFailed": "アプリの読み込みに失敗しました",
|
"loadAppsFailed": "アプリの読み込みに失敗しました",
|
||||||
"backToList": "リストに戻る"
|
"backToList": "リストに戻る",
|
||||||
|
"deploy": "デプロイ",
|
||||||
|
"deploySuccess": "デプロイ成功",
|
||||||
|
"deployFailed": "デプロイ失敗",
|
||||||
|
"dropZoneHint": "{{fileType}} ファイルをここにドロップ、またはクリックして選択",
|
||||||
|
"dropFileHere": "ここにドロップ",
|
||||||
|
"fileTypeNotSupported": "{{expected}} ファイルのみ対応しています",
|
||||||
|
"fileSizeExceeded": "ファイルサイズが 20 MB 制限を超えています",
|
||||||
|
"fileAdded": "{{name}} を追加しました",
|
||||||
|
"fileAddFailed": "ファイルの追加に失敗しました",
|
||||||
|
"fileDeleteFailed": "ファイルの削除に失敗しました",
|
||||||
|
"statusAdded": "新規",
|
||||||
|
"statusDeleted": "削除",
|
||||||
|
"restore": "復元"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,18 @@
|
|||||||
"pinApp": "置顶应用",
|
"pinApp": "置顶应用",
|
||||||
"selectDomainFirst": "请先选择一个 Domain",
|
"selectDomainFirst": "请先选择一个 Domain",
|
||||||
"loadAppsFailed": "加载应用失败",
|
"loadAppsFailed": "加载应用失败",
|
||||||
"backToList": "返回列表"
|
"backToList": "返回列表",
|
||||||
|
"deploy": "部署",
|
||||||
|
"deploySuccess": "部署成功",
|
||||||
|
"deployFailed": "部署失败",
|
||||||
|
"dropZoneHint": "拖拽 {{fileType}} 文件到此处,或点击选择",
|
||||||
|
"dropFileHere": "松开以添加文件",
|
||||||
|
"fileTypeNotSupported": "仅支持 {{expected}} 文件",
|
||||||
|
"fileSizeExceeded": "文件大小超过 20 MB 限制",
|
||||||
|
"fileAdded": "{{name}} 已添加",
|
||||||
|
"fileAddFailed": "添加文件失败",
|
||||||
|
"fileDeleteFailed": "删除文件失败",
|
||||||
|
"statusAdded": "新增",
|
||||||
|
"statusDeleted": "删除",
|
||||||
|
"restore": "恢复"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
switchDomain: async (domain: Domain) => {
|
switchDomain: async (domain: Domain) => {
|
||||||
const appStore = useAppStore.getState();
|
const appStore = useAppStore.getState();
|
||||||
|
|
||||||
|
// Track the domain ID at request start (closure variable)
|
||||||
|
const requestDomainId = domain.id;
|
||||||
|
|
||||||
// 1. reset
|
// 1. reset
|
||||||
appStore.setLoading(true);
|
appStore.setLoading(true);
|
||||||
appStore.setApps([]);
|
appStore.setApps([]);
|
||||||
@@ -230,7 +233,8 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
|
|
||||||
// 3. Test connection after switching
|
// 3. Test connection after switching
|
||||||
const status = await get().testConnection(domain.id);
|
const status = await get().testConnection(domain.id);
|
||||||
if (status) {
|
// Check if we're still on the same domain before updating connection status
|
||||||
|
if (status && get().currentDomain?.id === requestDomainId) {
|
||||||
set({
|
set({
|
||||||
connectionStatuses: {
|
connectionStatuses: {
|
||||||
...get().connectionStatuses,
|
...get().connectionStatuses,
|
||||||
@@ -242,14 +246,18 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
// 4. Auto-load apps for the new domain
|
// 4. Auto-load apps for the new domain
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getApps({ domainId: domain.id });
|
const result = await window.api.getApps({ domainId: domain.id });
|
||||||
if (result.success) {
|
// Check if we're still on the same domain before updating apps
|
||||||
|
if (result.success && get().currentDomain?.id === requestDomainId) {
|
||||||
appStore.setApps(result.data);
|
appStore.setApps(result.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silent fail - user can manually reload
|
// Silent fail - user can manually reload
|
||||||
console.error("Failed to auto-load apps:", error);
|
console.error("Failed to auto-load apps:", error);
|
||||||
} finally {
|
} finally {
|
||||||
appStore.setLoading(false);
|
// Check before setting loading to false
|
||||||
|
if (get().currentDomain?.id === requestDomainId) {
|
||||||
|
appStore.setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
280
src/renderer/src/stores/fileChangeStore.ts
Normal file
280
src/renderer/src/stores/fileChangeStore.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* File Change Store
|
||||||
|
* Manages file change state (added/deleted/unchanged) per app.
|
||||||
|
* File content is NOT stored here — only metadata and local disk paths.
|
||||||
|
* State is persisted so pending changes survive app restarts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { FileStatus } from "@shared/types/ipc";
|
||||||
|
|
||||||
|
export type { FileStatus };
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
fileType: "js" | "css";
|
||||||
|
platform: "desktop" | "mobile";
|
||||||
|
status: FileStatus;
|
||||||
|
size?: number;
|
||||||
|
/** For unchanged FILE-type Kintone files */
|
||||||
|
fileKey?: string;
|
||||||
|
/** For unchanged URL-type Kintone files */
|
||||||
|
url?: string;
|
||||||
|
/** For locally added files: absolute path on disk */
|
||||||
|
storagePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppFileState {
|
||||||
|
files: FileEntry[];
|
||||||
|
initialized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileChangeState {
|
||||||
|
appFiles: Record<string, AppFileState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize file list from Kintone data for a given app.
|
||||||
|
* No-op if the app is already initialized (has pending changes).
|
||||||
|
* Call clearChanges() first to force re-initialization.
|
||||||
|
*/
|
||||||
|
initializeApp: (
|
||||||
|
domainId: string,
|
||||||
|
appId: string,
|
||||||
|
files: Array<Omit<FileEntry, "status">>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new locally-staged file (status: added).
|
||||||
|
*/
|
||||||
|
addFile: (domainId: string, appId: string, entry: FileEntry) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a file for deletion, or remove an added file from the list.
|
||||||
|
* - unchanged → deleted
|
||||||
|
* - added → removed from list entirely
|
||||||
|
* - deleted → no-op
|
||||||
|
*/
|
||||||
|
deleteFile: (domainId: string, appId: string, fileId: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a deleted file back to unchanged.
|
||||||
|
* Only applies to files with status: deleted.
|
||||||
|
*/
|
||||||
|
restoreFile: (domainId: string, appId: string, fileId: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder files within a specific (platform, fileType) section.
|
||||||
|
* newOrder is the new ordered array of file IDs for that section.
|
||||||
|
*/
|
||||||
|
reorderSection: (
|
||||||
|
domainId: string,
|
||||||
|
appId: string,
|
||||||
|
platform: "desktop" | "mobile",
|
||||||
|
fileType: "js" | "css",
|
||||||
|
newOrder: string[],
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all pending changes and reset initialized state.
|
||||||
|
* Next call to initializeApp will re-read from Kintone.
|
||||||
|
*/
|
||||||
|
clearChanges: (domainId: string, appId: string) => void;
|
||||||
|
|
||||||
|
/** Get all files for an app (all statuses) */
|
||||||
|
getFiles: (domainId: string, appId: string) => FileEntry[];
|
||||||
|
|
||||||
|
/** Get files for a specific section */
|
||||||
|
getSectionFiles: (
|
||||||
|
domainId: string,
|
||||||
|
appId: string,
|
||||||
|
platform: "desktop" | "mobile",
|
||||||
|
fileType: "js" | "css",
|
||||||
|
) => FileEntry[];
|
||||||
|
|
||||||
|
/** Count of added and deleted files */
|
||||||
|
getChangeCount: (
|
||||||
|
domainId: string,
|
||||||
|
appId: string,
|
||||||
|
) => { added: number; deleted: number };
|
||||||
|
|
||||||
|
isInitialized: (domainId: string, appId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appKey = (domainId: string, appId: string) => `${domainId}:${appId}`;
|
||||||
|
|
||||||
|
export const useFileChangeStore = create<FileChangeState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
appFiles: {},
|
||||||
|
|
||||||
|
initializeApp: (domainId, appId, files) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
const existing = get().appFiles[key];
|
||||||
|
if (existing?.initialized) return;
|
||||||
|
|
||||||
|
const entries: FileEntry[] = files.map((f) => ({
|
||||||
|
...f,
|
||||||
|
status: "unchanged" as FileStatus,
|
||||||
|
}));
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
appFiles: {
|
||||||
|
...state.appFiles,
|
||||||
|
[key]: { files: entries, initialized: true },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
addFile: (domainId, appId, entry) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.appFiles[key] ?? { files: [], initialized: true };
|
||||||
|
return {
|
||||||
|
appFiles: {
|
||||||
|
...state.appFiles,
|
||||||
|
[key]: {
|
||||||
|
...existing,
|
||||||
|
files: [...existing.files, entry],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFile: (domainId, appId, fileId) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.appFiles[key];
|
||||||
|
if (!existing) return state;
|
||||||
|
|
||||||
|
const file = existing.files.find((f) => f.id === fileId);
|
||||||
|
if (!file) return state;
|
||||||
|
|
||||||
|
let updatedFiles: FileEntry[];
|
||||||
|
if (file.status === "added") {
|
||||||
|
// Remove added files entirely
|
||||||
|
updatedFiles = existing.files.filter((f) => f.id !== fileId);
|
||||||
|
} else if (file.status === "unchanged") {
|
||||||
|
// Mark unchanged files as deleted
|
||||||
|
updatedFiles = existing.files.map((f) =>
|
||||||
|
f.id === fileId ? { ...f, status: "deleted" as FileStatus } : f,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appFiles: {
|
||||||
|
...state.appFiles,
|
||||||
|
[key]: { ...existing, files: updatedFiles },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreFile: (domainId, appId, fileId) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.appFiles[key];
|
||||||
|
if (!existing) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
appFiles: {
|
||||||
|
...state.appFiles,
|
||||||
|
[key]: {
|
||||||
|
...existing,
|
||||||
|
files: existing.files.map((f) =>
|
||||||
|
f.id === fileId && f.status === "deleted"
|
||||||
|
? { ...f, status: "unchanged" as FileStatus }
|
||||||
|
: f,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderSection: (domainId, appId, platform, fileType, newOrder) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.appFiles[key];
|
||||||
|
if (!existing) return state;
|
||||||
|
|
||||||
|
// Split files into this section and others
|
||||||
|
const sectionFiles = existing.files.filter(
|
||||||
|
(f) => f.platform === platform && f.fileType === fileType,
|
||||||
|
);
|
||||||
|
const otherFiles = existing.files.filter(
|
||||||
|
(f) => !(f.platform === platform && f.fileType === fileType),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder section files according to newOrder
|
||||||
|
const sectionMap = new Map(sectionFiles.map((f) => [f.id, f]));
|
||||||
|
const reordered = newOrder
|
||||||
|
.map((id) => sectionMap.get(id))
|
||||||
|
.filter((f): f is FileEntry => f !== undefined);
|
||||||
|
|
||||||
|
// Append any section files not in newOrder (safety)
|
||||||
|
for (const f of sectionFiles) {
|
||||||
|
if (!newOrder.includes(f.id)) reordered.push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: maintain overall section order in the flat array
|
||||||
|
// Order: all non-section files first (in their original positions),
|
||||||
|
// but to maintain section integrity, rebuild in platform/fileType groups.
|
||||||
|
// Simple approach: replace section slice with reordered.
|
||||||
|
const finalFiles = [...otherFiles, ...reordered];
|
||||||
|
|
||||||
|
return {
|
||||||
|
appFiles: {
|
||||||
|
...state.appFiles,
|
||||||
|
[key]: { ...existing, files: finalFiles },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearChanges: (domainId, appId) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
set((state) => ({
|
||||||
|
appFiles: {
|
||||||
|
...state.appFiles,
|
||||||
|
[key]: { files: [], initialized: false },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getFiles: (domainId, appId) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
return get().appFiles[key]?.files ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getSectionFiles: (domainId, appId, platform, fileType) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
const files = get().appFiles[key]?.files ?? [];
|
||||||
|
return files.filter(
|
||||||
|
(f) => f.platform === platform && f.fileType === fileType,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
getChangeCount: (domainId, appId) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
const files = get().appFiles[key]?.files ?? [];
|
||||||
|
return {
|
||||||
|
added: files.filter((f) => f.status === "added").length,
|
||||||
|
deleted: files.filter((f) => f.status === "deleted").length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
isInitialized: (domainId, appId) => {
|
||||||
|
const key = appKey(domainId, appId);
|
||||||
|
return get().appFiles[key]?.initialized ?? false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "file-change-storage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -11,4 +11,6 @@ export { useUIStore } from "./uiStore";
|
|||||||
export { useSessionStore } from "./sessionStore";
|
export { useSessionStore } from "./sessionStore";
|
||||||
export type { ViewMode, SelectedFile } from "./sessionStore";
|
export type { ViewMode, SelectedFile } from "./sessionStore";
|
||||||
export { useThemeStore } from "./themeStore";
|
export { useThemeStore } from "./themeStore";
|
||||||
export type { ThemeMode } from "./themeStore";
|
export type { ThemeMode } from "./themeStore";
|
||||||
|
export { useFileChangeStore } from "./fileChangeStore";
|
||||||
|
export type { FileEntry, FileStatus } from "./fileChangeStore";
|
||||||
@@ -55,25 +55,26 @@ export interface GetFileContentParams {
|
|||||||
|
|
||||||
// ==================== Deploy IPC Types ====================
|
// ==================== Deploy IPC Types ====================
|
||||||
|
|
||||||
export interface DeployFile {
|
export type FileStatus = "unchanged" | "added" | "deleted";
|
||||||
content: string;
|
|
||||||
|
export interface DeployFileEntry {
|
||||||
|
id: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileType: "js" | "css";
|
fileType: "js" | "css";
|
||||||
position:
|
platform: "desktop" | "mobile";
|
||||||
| "pc_header"
|
status: FileStatus;
|
||||||
| "pc_body"
|
/** For unchanged FILE-type files: the Kintone file key */
|
||||||
| "pc_footer"
|
fileKey?: string;
|
||||||
| "mobile_header"
|
/** For unchanged URL-type files: the URL */
|
||||||
| "mobile_body"
|
url?: string;
|
||||||
| "mobile_footer"
|
/** For added files: absolute path to file on disk */
|
||||||
| "pc_css"
|
storagePath?: string;
|
||||||
| "mobile_css";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeployParams {
|
export interface DeployParams {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
files: DeployFile[];
|
files: DeployFileEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeployResult {
|
export interface DeployResult {
|
||||||
@@ -83,6 +84,29 @@ export interface DeployResult {
|
|||||||
backupMetadata?: BackupMetadata;
|
backupMetadata?: BackupMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== File Storage IPC Types ====================
|
||||||
|
|
||||||
|
export interface FileSaveParams {
|
||||||
|
domainId: string;
|
||||||
|
appId: string;
|
||||||
|
platform: "desktop" | "mobile";
|
||||||
|
fileType: "js" | "css";
|
||||||
|
/** Caller-generated UUID used to name the stored file */
|
||||||
|
fileId: string;
|
||||||
|
/** Absolute path to the source file (from drag/drop or file picker) */
|
||||||
|
sourcePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSaveResult {
|
||||||
|
storagePath: string;
|
||||||
|
fileName: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDeleteParams {
|
||||||
|
storagePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Download IPC Types ====================
|
// ==================== Download IPC Types ====================
|
||||||
|
|
||||||
export interface DownloadParams {
|
export interface DownloadParams {
|
||||||
@@ -170,7 +194,11 @@ export interface ElectronAPI {
|
|||||||
) => Promise<Result<FileContent>>;
|
) => Promise<Result<FileContent>>;
|
||||||
|
|
||||||
// Deploy
|
// Deploy
|
||||||
deploy: (params: DeployParams) => Promise<DeployResult>;
|
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
|
||||||
|
|
||||||
|
// File storage
|
||||||
|
saveFile: (params: FileSaveParams) => Promise<Result<FileSaveResult>>;
|
||||||
|
deleteFile: (params: FileDeleteParams) => Promise<Result<void>>;
|
||||||
|
|
||||||
// Download
|
// Download
|
||||||
download: (params: DownloadParams) => Promise<DownloadResult>;
|
download: (params: DownloadParams) => Promise<DownloadResult>;
|
||||||
|
|||||||
Reference in New Issue
Block a user