From 9fddc9836e7aa774bd76ca1a13c4d13d6583972c Mon Sep 17 00:00:00 2001 From: xue jiahao Date: Tue, 17 Mar 2026 13:50:20 +0800 Subject: [PATCH] fix deploy --- MEMORY.md | 30 + src/main/index.ts | 7 + src/main/ipc-handlers.ts | 96 ++- src/main/storage.ts | 41 ++ src/preload/index.d.ts | 17 +- src/preload/index.ts | 9 +- src/renderer/src/App.tsx | 14 + .../src/components/AppDetail/AppDetail.tsx | 642 ++++++++---------- .../src/components/AppDetail/DropZone.tsx | 94 +++ .../src/components/AppDetail/FileItem.tsx | 185 +++++ .../src/components/AppDetail/FileSection.tsx | 409 +++++++++++ .../components/DomainManager/DomainList.tsx | 11 +- src/renderer/src/locales/en-US/app.json | 15 +- src/renderer/src/locales/ja-JP/app.json | 15 +- src/renderer/src/locales/zh-CN/app.json | 15 +- src/renderer/src/stores/domainStore.ts | 14 +- src/renderer/src/stores/fileChangeStore.ts | 280 ++++++++ src/renderer/src/stores/index.ts | 4 +- src/shared/types/ipc.ts | 54 +- 19 files changed, 1542 insertions(+), 410 deletions(-) create mode 100644 src/renderer/src/components/AppDetail/DropZone.tsx create mode 100644 src/renderer/src/components/AppDetail/FileItem.tsx create mode 100644 src/renderer/src/components/AppDetail/FileSection.tsx create mode 100644 src/renderer/src/stores/fileChangeStore.ts diff --git a/MEMORY.md b/MEMORY.md index 7ab9711..18d2145 100644 --- a/MEMORY.md +++ b/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 模板字符串语法错误 ### 遇到什么问题 diff --git a/src/main/index.ts b/src/main/index.ts index 4de08ea..8cd6538 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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"]) { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 7325da3..3a81ca9 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -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("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(); 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("file:save", async (params) => { + return saveCustomizationFile(params); + }); +} + +/** + * Delete a customization file from managed storage + */ +function registerFileDelete(): void { + handle("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(); diff --git a/src/main/storage.ts b/src/main/storage.ts index 4c39e07..68085cb 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -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 { + 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")); } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4a9dbbf..b499471 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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>; // ==================== Deploy ==================== - deploy: (params: DeployParams) => Promise; + deploy: (params: DeployParams) => Promise>; + + // ==================== File Storage ==================== + saveFile: (params: FileSaveParams) => Promise>; + deleteFile: (params: FileDeleteParams) => Promise>; // ==================== Download ==================== download: (params: DownloadParams) => Promise; @@ -80,4 +87,12 @@ export interface SelfAPI { // ==================== Dialog ==================== showSaveDialog: (params: ShowSaveDialogParams) => Promise>; saveFileContent: (params: SaveFileContentParams) => Promise>; + + // ==================== 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; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8ad49cb..62b6be4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a275ee9..7cd66c2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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, diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index 1335bd1..622168e 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -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( - null, - ); + const [downloadingKey, setDownloadingKey] = React.useState(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 (
- +
); @@ -219,258 +353,16 @@ const AppDetail: React.FC = () => { return (
- +
); } - 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 ; - }; - - // 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 ( -
-
- {t("noConfig")} -
-
- ); - } - - return ( -
- {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 ( -
{ - if (fileKey) { - handleFileClick(type, fileKey, fileName); - } - }} - > -
- {getFileTypeIcon(type, fileName)} - {fileName} -
- {canView && ( - - {fileMeta && ( - {formatFileSize(fileMeta.size)} - )} - - - - )} -
- ); - })} -
- ); - }; - - const renderFileSection = ( - title: string, - icon: React.ReactNode, - files: FileConfigResponse[] | undefined, - type: "js" | "css", - ) => { - return ( -
-
- {icon} - {title} -
- {renderFileList(files, type)} -
- ); - }; + const changeCount = currentDomain && selectedAppId + ? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId) + : { added: 0, deleted: 0 }; + const hasChanges = changeCount.added > 0 || changeCount.deleted > 0; return (
@@ -485,43 +377,87 @@ const AppDetail: React.FC = () => { {t("versionHistory", { ns: "common" })} +
{viewMode === "list" ? ( <> - {renderFileSection( - t("pcJs"), - , - currentApp.customization?.desktop?.js, - "js", - )} - {renderFileSection( - t("pcCss"), - , - currentApp.customization?.desktop?.css, - "css", - )} - {renderFileSection( - t("mobileJs"), - , - currentApp.customization?.mobile?.js, - "js", - )} - {renderFileSection( - t("mobileCss"), - , - currentApp.customization?.mobile?.css, - "css", - )} + } + platform="desktop" + fileType="js" + domainId={currentDomain.id} + appId={selectedAppId} + downloadingKey={downloadingKey} + onView={handleFileClick} + onDownload={handleDownloadFile} + /> + } + platform="desktop" + fileType="css" + domainId={currentDomain.id} + appId={selectedAppId} + downloadingKey={downloadingKey} + onView={handleFileClick} + onDownload={handleDownloadFile} + /> + } + platform="mobile" + fileType="js" + domainId={currentDomain.id} + appId={selectedAppId} + downloadingKey={downloadingKey} + onView={handleFileClick} + onDownload={handleDownloadFile} + /> + } + platform="mobile" + fileType="css" + domainId={currentDomain.id} + appId={selectedAppId} + downloadingKey={downloadingKey} + onView={handleFileClick} + onDownload={handleDownloadFile} + /> ) : (
diff --git a/src/renderer/src/components/AppDetail/DropZone.tsx b/src/renderer/src/components/AppDetail/DropZone.tsx new file mode 100644 index 0000000..ab8039e --- /dev/null +++ b/src/renderer/src/components/AppDetail/DropZone.tsx @@ -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; +} + +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 = ({ fileType, isSaving, onFileSelected }) => { + const { t } = useTranslation(["app", "common"]); + const { styles } = useStyles(); + const inputRef = useRef(null); + + const handleClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + await onFileSelected(file); + } + e.target.value = ""; + }, + [onFileSelected], + ); + + return ( + <> + + + + ); +}; + +export default DropZone; diff --git a/src/renderer/src/components/AppDetail/FileItem.tsx b/src/renderer/src/components/AppDetail/FileItem.tsx new file mode 100644 index 0000000..a7c0b87 --- /dev/null +++ b/src/renderer/src/components/AppDetail/FileItem.tsx @@ -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 = ({ + 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 ( + +
+
+ + {entry.status !== "unchanged" && ( +
+ )} + + + {entry.fileName} + + {entry.status === "added" && ( + + )} + {entry.status === "deleted" && ( + + )} +
+ + + {entry.size && ( + {formatFileSize(entry.size)} + )} + + {entry.status === "deleted" ? ( + + ) : ( + <> + {entry.status === "unchanged" && onView && entry.fileKey && ( + + )} + {entry.status === "unchanged" && onDownload && entry.fileKey && ( + + )} +
+ + ); +}; + +export default FileItem; diff --git a/src/renderer/src/components/AppDetail/FileSection.tsx b/src/renderer/src/components/AppDetail/FileSection.tsx new file mode 100644 index 0000000..b59c94d --- /dev/null +++ b/src/renderer/src/components/AppDetail/FileSection.tsx @@ -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 = ({ + 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 ( +
+ 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} + /> +
+ ); + }, + [ + files, + handleDelete, + handleRestore, + onView, + onDownload, + downloadingKey, + styles.sortableItem, + ], + ); + + // ── Render ───────────────────────────────────────────────────────────────── + return ( +
+
+ {icon} + {title} + {hasChanges && ( + + {addedCount > 0 && `+${addedCount}`} + {addedCount > 0 && deletedCount > 0 && " "} + {deletedCount > 0 && `-${deletedCount}`} + + )} +
+ + {/* The entire card is the drop target */} +
+ {/* Drag overlay */} + {isDraggingOver && ( +
+ {isDragInvalid + ? t("fileTypeNotSupported", { expected: `.${fileType}` }) + : t("dropFileHere")} +
+ )} + + {/* File list */} + {files.length === 0 ? ( +
{t("noConfig")}
+ ) : ( + + )} + + {/* Click-to-add strip */} +
+ +
+
+
+ ); +}; + +export default FileSection; diff --git a/src/renderer/src/components/DomainManager/DomainList.tsx b/src/renderer/src/components/DomainManager/DomainList.tsx index 3e39662..7b07cb0 100644 --- a/src/renderer/src/components/DomainManager/DomainList.tsx +++ b/src/renderer/src/components/DomainManager/DomainList.tsx @@ -115,11 +115,14 @@ const DomainList: React.FC = ({ 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; } diff --git a/src/renderer/src/locales/en-US/app.json b/src/renderer/src/locales/en-US/app.json index 741674d..5e8f950 100644 --- a/src/renderer/src/locales/en-US/app.json +++ b/src/renderer/src/locales/en-US/app.json @@ -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" } diff --git a/src/renderer/src/locales/ja-JP/app.json b/src/renderer/src/locales/ja-JP/app.json index cce5d33..fc8841b 100644 --- a/src/renderer/src/locales/ja-JP/app.json +++ b/src/renderer/src/locales/ja-JP/app.json @@ -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": "復元" } diff --git a/src/renderer/src/locales/zh-CN/app.json b/src/renderer/src/locales/zh-CN/app.json index 27f20ba..7410328 100644 --- a/src/renderer/src/locales/zh-CN/app.json +++ b/src/renderer/src/locales/zh-CN/app.json @@ -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": "恢复" } diff --git a/src/renderer/src/stores/domainStore.ts b/src/renderer/src/stores/domainStore.ts index 4fae523..59f4b2b 100644 --- a/src/renderer/src/stores/domainStore.ts +++ b/src/renderer/src/stores/domainStore.ts @@ -220,6 +220,9 @@ export const useDomainStore = create()( 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()( // 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()( // 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); + } } }, diff --git a/src/renderer/src/stores/fileChangeStore.ts b/src/renderer/src/stores/fileChangeStore.ts new file mode 100644 index 0000000..70414fc --- /dev/null +++ b/src/renderer/src/stores/fileChangeStore.ts @@ -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; + + /** + * 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>, + ) => 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()( + 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", + }, + ), +); diff --git a/src/renderer/src/stores/index.ts b/src/renderer/src/stores/index.ts index 8cf391e..dbf04fc 100644 --- a/src/renderer/src/stores/index.ts +++ b/src/renderer/src/stores/index.ts @@ -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"; \ No newline at end of file +export type { ThemeMode } from "./themeStore"; +export { useFileChangeStore } from "./fileChangeStore"; +export type { FileEntry, FileStatus } from "./fileChangeStore"; \ No newline at end of file diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index b3b3f54..549b809 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -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>; // Deploy - deploy: (params: DeployParams) => Promise; + deploy: (params: DeployParams) => Promise>; + + // File storage + saveFile: (params: FileSaveParams) => Promise>; + deleteFile: (params: FileDeleteParams) => Promise>; // Download download: (params: DownloadParams) => Promise;