fix deploy
This commit is contained in:
30
MEMORY.md
30
MEMORY.md
@@ -1,5 +1,35 @@
|
||||
# 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 模板字符串语法错误
|
||||
|
||||
### 遇到什么问题
|
||||
|
||||
@@ -43,6 +43,13 @@ function createWindow(): void {
|
||||
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.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
saveBackup,
|
||||
getLocale,
|
||||
setLocale,
|
||||
saveCustomizationFile,
|
||||
deleteCustomizationFile,
|
||||
} from "./storage";
|
||||
import { KintoneClient, createKintoneClient } from "./kintone-api";
|
||||
import type { Result } from "@shared/types/ipc";
|
||||
@@ -39,6 +41,9 @@ import type {
|
||||
RollbackParams,
|
||||
SetLocaleParams,
|
||||
CheckUpdateResult,
|
||||
FileSaveParams,
|
||||
FileSaveResult,
|
||||
FileDeleteParams,
|
||||
} from "@shared/types/ipc";
|
||||
import type { LocaleCode } from "@shared/types/locale";
|
||||
import type {
|
||||
@@ -52,7 +57,7 @@ import type {
|
||||
BackupMetadata,
|
||||
DownloadFile,
|
||||
} 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 { 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 {
|
||||
handle<DeployParams, DeployResult>("deploy", async (params) => {
|
||||
const fs = await import("fs");
|
||||
const client = await getClient(params.domainId);
|
||||
const domainWithPassword = await getDomain(params.domainId);
|
||||
|
||||
@@ -343,15 +353,11 @@ function registerDeploy(): void {
|
||||
const backupFiles = new Map<string, Buffer>();
|
||||
const backupFileList: BackupMetadata["files"] = [];
|
||||
|
||||
// Add desktop files to backup
|
||||
await addFilesToBackup("desktop", "js", 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", "css", client, appDetail, backupFiles, backupFileList);
|
||||
|
||||
// Save backup
|
||||
const backupMetadata: BackupMetadata = {
|
||||
backedUpAt: new Date().toISOString(),
|
||||
domain: domainWithPassword.domain,
|
||||
@@ -364,44 +370,48 @@ function registerDeploy(): void {
|
||||
|
||||
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 = {
|
||||
app: params.appId,
|
||||
scope: "ALL",
|
||||
desktop: {
|
||||
js: [],
|
||||
css: [],
|
||||
},
|
||||
mobile: {
|
||||
js: [],
|
||||
css: [],
|
||||
},
|
||||
desktop: { js: [], css: [] },
|
||||
mobile: { js: [], css: [] },
|
||||
};
|
||||
|
||||
for (const file of params.files) {
|
||||
const fileKey = await client.uploadFile(file.content, file.fileName);
|
||||
const fileEntry = { type: "FILE" as const, file: { fileKey: fileKey.fileKey } };
|
||||
if (file.status === "deleted") continue;
|
||||
|
||||
// Add to corresponding field based on file type and position
|
||||
if (file.fileType === "js") {
|
||||
if (file.position.startsWith("pc_")) {
|
||||
newConfig.desktop!.js!.push(fileEntry);
|
||||
} else if (file.position.startsWith("mobile_")) {
|
||||
newConfig.mobile!.js!.push(fileEntry);
|
||||
type FileEntry = { type: "FILE"; file: { fileKey: string } } | { type: "URL"; url: string };
|
||||
let entry: FileEntry;
|
||||
|
||||
if (file.status === "unchanged") {
|
||||
if (file.fileKey) {
|
||||
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") {
|
||||
if (file.position === "pc_css") {
|
||||
newConfig.desktop!.css!.push(fileEntry);
|
||||
} else if (file.position === "mobile_css") {
|
||||
newConfig.mobile!.css!.push(fileEntry);
|
||||
} else {
|
||||
// added: read from disk and upload
|
||||
if (!file.storagePath) {
|
||||
throw new Error(`Added file "${file.fileName}" has no storagePath`);
|
||||
}
|
||||
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);
|
||||
|
||||
// Deploy the changes
|
||||
await client.deployApp(params.appId);
|
||||
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
@@ -717,6 +747,10 @@ export function registerIpcHandlers(): void {
|
||||
// Deploy
|
||||
registerDeploy();
|
||||
|
||||
// File storage
|
||||
registerFileSave();
|
||||
registerFileDelete();
|
||||
|
||||
// Download
|
||||
registerDownload();
|
||||
registerDownloadAllZip();
|
||||
|
||||
@@ -421,6 +421,46 @@ export async function saveBackup(
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
@@ -449,4 +489,5 @@ export function initializeStorage(): void {
|
||||
ensureDir(getStorageBase());
|
||||
ensureDir(getStoragePath("downloads"));
|
||||
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,
|
||||
SaveFileContentParams,
|
||||
CheckUpdateResult,
|
||||
FileSaveParams,
|
||||
FileSaveResult,
|
||||
FileDeleteParams,
|
||||
} from "@shared/types/ipc";
|
||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
||||
import type {
|
||||
@@ -58,7 +61,11 @@ export interface SelfAPI {
|
||||
) => Promise<Result<FileContent>>;
|
||||
|
||||
// ==================== 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: (params: DownloadParams) => Promise<DownloadResult>;
|
||||
@@ -80,4 +87,12 @@ export interface SelfAPI {
|
||||
// ==================== Dialog ====================
|
||||
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
||||
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 type { SelfAPI } from "./index.d";
|
||||
|
||||
@@ -22,6 +22,10 @@ const api: SelfAPI = {
|
||||
// Deploy
|
||||
deploy: (params) => ipcRenderer.invoke("deploy", params),
|
||||
|
||||
// File storage
|
||||
saveFile: (params) => ipcRenderer.invoke("file:save", params),
|
||||
deleteFile: (params) => ipcRenderer.invoke("file:delete", params),
|
||||
|
||||
// Download
|
||||
download: (params) => ipcRenderer.invoke("download", params),
|
||||
downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params),
|
||||
@@ -42,6 +46,9 @@ const api: SelfAPI = {
|
||||
// Dialog
|
||||
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", 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
|
||||
|
||||
@@ -150,6 +150,20 @@ const App: React.FC = () => {
|
||||
const { styles } = useStyles();
|
||||
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 {
|
||||
sidebarWidth,
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
/**
|
||||
* 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 { Spin, Tag, Space, App as AntApp } from "antd";
|
||||
import { Button, Empty, MaterialFileTypeIcon } from "@lobehub/ui";
|
||||
import { Button, Empty } from "@lobehub/ui";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Download,
|
||||
History,
|
||||
Code,
|
||||
Rocket,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
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 { FileConfigResponse, isFileResource } from "@shared/types/kintone";
|
||||
import { isFileResource, isUrlResource } from "@shared/types/kintone";
|
||||
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
||||
import type { DeployFileEntry } from "@shared/types/ipc";
|
||||
import FileSection from "./FileSection";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
@@ -58,50 +65,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
align-items: center;
|
||||
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`
|
||||
height: 100%;
|
||||
padding: ${token.paddingLG}px;
|
||||
@@ -111,23 +74,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
justify-content: 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`
|
||||
padding: ${token.marginSM}px 0;
|
||||
padding-left: ${token.marginXS}px;
|
||||
@@ -141,10 +87,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
fileSize: css`
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
`,
|
||||
}));
|
||||
|
||||
const AppDetail: React.FC = () => {
|
||||
@@ -154,28 +96,77 @@ const AppDetail: React.FC = () => {
|
||||
const { currentDomain } = useDomainStore();
|
||||
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
|
||||
useAppStore();
|
||||
const { viewMode, selectedFile, setViewMode, setSelectedFile } = useSessionStore();
|
||||
const { viewMode, selectedFile, setViewMode, setSelectedFile } =
|
||||
useSessionStore();
|
||||
const fileChangeStore = useFileChangeStore();
|
||||
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 [deploying, setDeploying] = React.useState(false);
|
||||
|
||||
// Reset view mode when app changes
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setViewMode("list");
|
||||
setSelectedFile(null);
|
||||
}, [selectedAppId]);
|
||||
|
||||
// Load app detail when selected
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (currentDomain && selectedAppId) {
|
||||
loadAppDetail();
|
||||
}
|
||||
}, [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 () => {
|
||||
if (!currentDomain || !selectedAppId) return;
|
||||
|
||||
@@ -185,6 +176,8 @@ const AppDetail: React.FC = () => {
|
||||
domainId: currentDomain.id,
|
||||
appId: selectedAppId,
|
||||
});
|
||||
|
||||
// Check if we're still on the same app and component is mounted before updating
|
||||
if (result.success) {
|
||||
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) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.emptySection}>
|
||||
<Empty
|
||||
description={t("selectApp")}
|
||||
/>
|
||||
<Empty description={t("selectApp")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -219,258 +353,16 @@ const AppDetail: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.emptySection}>
|
||||
<Empty
|
||||
description={t("appNotFound")}
|
||||
/>
|
||||
<Empty description={t("appNotFound")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFileClick = (
|
||||
type: "js" | "css",
|
||||
fileKey: string,
|
||||
name: string,
|
||||
) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
const changeCount = currentDomain && selectedAppId
|
||||
? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId)
|
||||
: { added: 0, deleted: 0 };
|
||||
const hasChanges = changeCount.added > 0 || changeCount.deleted > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -485,43 +377,87 @@ const AppDetail: React.FC = () => {
|
||||
{t("versionHistory", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Download size={16} />}
|
||||
loading={downloadingAll}
|
||||
onClick={handleDownloadAll}
|
||||
>
|
||||
{t("downloadAll")}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{viewMode === "list" ? (
|
||||
<>
|
||||
{renderFileSection(
|
||||
t("pcJs"),
|
||||
<Monitor size={14} />,
|
||||
currentApp.customization?.desktop?.js,
|
||||
"js",
|
||||
)}
|
||||
{renderFileSection(
|
||||
t("pcCss"),
|
||||
<Monitor size={14} />,
|
||||
currentApp.customization?.desktop?.css,
|
||||
"css",
|
||||
)}
|
||||
{renderFileSection(
|
||||
t("mobileJs"),
|
||||
<Smartphone size={14} />,
|
||||
currentApp.customization?.mobile?.js,
|
||||
"js",
|
||||
)}
|
||||
{renderFileSection(
|
||||
t("mobileCss"),
|
||||
<Smartphone size={14} />,
|
||||
currentApp.customization?.mobile?.css,
|
||||
"css",
|
||||
)}
|
||||
<FileSection
|
||||
title={t("pcJs")}
|
||||
icon={<Monitor size={14} />}
|
||||
platform="desktop"
|
||||
fileType="js"
|
||||
domainId={currentDomain.id}
|
||||
appId={selectedAppId}
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<FileSection
|
||||
title={t("pcCss")}
|
||||
icon={<Monitor size={14} />}
|
||||
platform="desktop"
|
||||
fileType="css"
|
||||
domainId={currentDomain.id}
|
||||
appId={selectedAppId}
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<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}>
|
||||
|
||||
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 oldOrder = domains.map((d) => d.id);
|
||||
|
||||
// Find the from and to indices
|
||||
for (let i = 0; i < oldOrder.length; i++) {
|
||||
// Find the element that was moved: its position changed from old to new
|
||||
// When item at oldIndex moves to newIndex, oldOrder[newIndex] !== newOrder[newIndex]
|
||||
for (let i = 0; i < newOrder.length; i++) {
|
||||
if (oldOrder[i] !== newOrder[i]) {
|
||||
const fromIndex = i;
|
||||
const toIndex = newOrder.indexOf(oldOrder[i]);
|
||||
// The item at position i in newOrder came from somewhere in oldOrder
|
||||
const movedItemId = newOrder[i];
|
||||
const fromIndex = oldOrder.indexOf(movedItemId);
|
||||
const toIndex = i;
|
||||
reorderDomains(fromIndex, toIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -35,5 +35,18 @@
|
||||
"pinApp": "Pin App",
|
||||
"selectDomainFirst": "Please select a Domain first",
|
||||
"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": "アプリをピン留め",
|
||||
"selectDomainFirst": "最初にドメインを選択してください",
|
||||
"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": "置顶应用",
|
||||
"selectDomainFirst": "请先选择一个 Domain",
|
||||
"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) => {
|
||||
const appStore = useAppStore.getState();
|
||||
|
||||
// Track the domain ID at request start (closure variable)
|
||||
const requestDomainId = domain.id;
|
||||
|
||||
// 1. reset
|
||||
appStore.setLoading(true);
|
||||
appStore.setApps([]);
|
||||
@@ -230,7 +233,8 @@ export const useDomainStore = create<DomainState>()(
|
||||
|
||||
// 3. Test connection after switching
|
||||
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({
|
||||
connectionStatuses: {
|
||||
...get().connectionStatuses,
|
||||
@@ -242,14 +246,18 @@ export const useDomainStore = create<DomainState>()(
|
||||
// 4. Auto-load apps for the new domain
|
||||
try {
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail - user can manually reload
|
||||
console.error("Failed to auto-load apps:", error);
|
||||
} 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 type { ViewMode, SelectedFile } from "./sessionStore";
|
||||
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 ====================
|
||||
|
||||
export interface DeployFile {
|
||||
content: string;
|
||||
export type FileStatus = "unchanged" | "added" | "deleted";
|
||||
|
||||
export interface DeployFileEntry {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileType: "js" | "css";
|
||||
position:
|
||||
| "pc_header"
|
||||
| "pc_body"
|
||||
| "pc_footer"
|
||||
| "mobile_header"
|
||||
| "mobile_body"
|
||||
| "mobile_footer"
|
||||
| "pc_css"
|
||||
| "mobile_css";
|
||||
platform: "desktop" | "mobile";
|
||||
status: FileStatus;
|
||||
/** For unchanged FILE-type files: the Kintone file key */
|
||||
fileKey?: string;
|
||||
/** For unchanged URL-type files: the URL */
|
||||
url?: string;
|
||||
/** For added files: absolute path to file on disk */
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
export interface DeployParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
files: DeployFile[];
|
||||
files: DeployFileEntry[];
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
@@ -83,6 +84,29 @@ export interface DeployResult {
|
||||
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 ====================
|
||||
|
||||
export interface DownloadParams {
|
||||
@@ -170,7 +194,11 @@ export interface ElectronAPI {
|
||||
) => Promise<Result<FileContent>>;
|
||||
|
||||
// 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: (params: DownloadParams) => Promise<DownloadResult>;
|
||||
|
||||
Reference in New Issue
Block a user