diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index 2667e76..d15a339 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -112,31 +112,36 @@ const AppDetail: React.FC = () => { setSelectedFile(null); }, [selectedAppId]); - const loadAppDetail = useCallback(async () => { - if (!currentDomain || !selectedAppId) return undefined; + const loadAppDetail = useCallback( + async (onSuccessCallback?: () => Promise | void) => { + if (!currentDomain || !selectedAppId) return undefined; - setLoading(true); - try { - const result = await window.api.getAppDetail({ - domainId: currentDomain.id, - appId: selectedAppId, - }); + setLoading(true); + try { + const result = await window.api.getAppDetail({ + domainId: currentDomain.id, + appId: selectedAppId, + }); - // Check if we're still on the same app and component is mounted before updating - if (result.success) { - // Clear changes and reload from Kintone - fileChangeStore.clearChanges(currentDomain.id, selectedAppId); - setCurrentApp(result.data); - return result.data; + // Check if we're still on the same app and component is mounted before updating + if (result.success) { + setCurrentApp(result.data); + // Call the callback if provided + if (onSuccessCallback) { + await onSuccessCallback(); + } + return result.data; + } + return undefined; + } catch (error) { + console.error("Failed to load app detail:", error); + return undefined; + } finally { + setLoading(false); } - return undefined; - } catch (error) { - console.error("Failed to load app detail:", error); - return undefined; - } finally { - setLoading(false); - } - }, [currentDomain, selectedAppId, setCurrentApp, setLoading]); + }, + [currentDomain, selectedAppId, setCurrentApp, setLoading] + ); // Load app detail when selected useEffect(() => { @@ -168,7 +173,9 @@ const AppDetail: React.FC = () => { setRefreshing(true); try { - await loadAppDetail(); + // Clear changes before reloading from Kintone + fileChangeStore.clearChanges(currentDomain.id, selectedAppId); + await loadAppDetail(() => fileChangeStore.clearChanges(currentDomain.id, selectedAppId)); message.success(t("refreshSuccess", { ns: "common" })); } catch (error) { console.error("Failed to refresh:", error); @@ -312,7 +319,8 @@ const AppDetail: React.FC = () => { if (result.success) { message.success(t("deploySuccess")); - await loadAppDetail(); + // Clear changes after successful deploy + await loadAppDetail(() => fileChangeStore.clearChanges(currentDomain.id, selectedAppId)); } else { message.error(result.error || t("deployFailed")); } diff --git a/src/renderer/src/components/AppList/AppList.tsx b/src/renderer/src/components/AppList/AppList.tsx index a12799c..a1cb5c6 100644 --- a/src/renderer/src/components/AppList/AppList.tsx +++ b/src/renderer/src/components/AppList/AppList.tsx @@ -10,9 +10,8 @@ import { Button, Tooltip, Empty, Select, Input } from "@lobehub/ui"; import { RefreshCw, Search, ArrowUpDown, ArrowDownUp } from "lucide-react"; import { createStyles } from "antd-style"; -import { useAppStore } from "@renderer/stores"; -import { useDomainStore } from "@renderer/stores"; -import { useUIStore } from "@renderer/stores"; +import { useAppStore, useDomainStore, useUIStore, useFileChangeStore } from "@renderer/stores"; +import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck"; import type { AppDetail } from "@shared/types/kintone"; import AppListItem from "./AppListItem"; @@ -80,6 +79,8 @@ const AppList: React.FC = () => { const { currentDomain } = useDomainStore(); const { apps, loading, error, searchText, loadedAt, selectedAppId, setApps, setLoading, setError, setSearchText, setSelectedAppId } = useAppStore(); const { pinnedApps, appSortBy, appSortOrder, togglePinnedApp, setAppSortBy, setAppSortOrder } = useUIStore(); + const { clearChanges } = useFileChangeStore(); + const { checkAndConfirmAppSwitch } = usePendingChangesCheck(); const currentPinnedApps = currentDomain ? pinnedApps[currentDomain.id] || [] : []; @@ -165,7 +166,17 @@ const AppList: React.FC = () => { const displayApps = [...pinnedAppsList, ...unpinnedAppsList]; // Handle item click - const handleItemClick = (app: AppDetail) => { + const handleItemClick = async (app: AppDetail) => { + // Check for pending changes before switching + const confirmed = await checkAndConfirmAppSwitch(app.appId); + if (!confirmed) { + return; + } + + // Clear changes from previous app before switching + if (selectedAppId && currentDomain && selectedAppId !== app.appId) { + clearChanges(currentDomain.id, selectedAppId); + } setSelectedAppId(app.appId); }; diff --git a/src/renderer/src/components/DomainManager/DomainList.tsx b/src/renderer/src/components/DomainManager/DomainList.tsx index c457b93..101f519 100644 --- a/src/renderer/src/components/DomainManager/DomainList.tsx +++ b/src/renderer/src/components/DomainManager/DomainList.tsx @@ -9,6 +9,7 @@ import { Button, SortableList, Tooltip } from "@lobehub/ui"; import { Pencil, Trash2 } from "lucide-react"; import { createStyles } from "antd-style"; import { useDomainStore } from "@renderer/stores"; +import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck"; import type { Domain } from "@shared/types/domain"; const useStyles = createStyles(({ token, css }) => ({ @@ -97,11 +98,17 @@ const DomainList: React.FC = ({ onEdit }) => { const { t } = useTranslation("domain"); const { styles } = useStyles(); const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } = useDomainStore(); + const { checkAndConfirmDomainSwitch } = usePendingChangesCheck(); - const handleSelect = (domain: Domain) => { + const handleSelect = async (domain: Domain) => { if (currentDomain?.id === domain.id) { return; } + // Check for pending changes before switching domain + const confirmed = await checkAndConfirmDomainSwitch(domain.id); + if (!confirmed) { + return; + } switchDomain(domain); }; diff --git a/src/renderer/src/hooks/usePendingChangesCheck.ts b/src/renderer/src/hooks/usePendingChangesCheck.ts new file mode 100644 index 0000000..d5c8b4b --- /dev/null +++ b/src/renderer/src/hooks/usePendingChangesCheck.ts @@ -0,0 +1,115 @@ +/** + * usePendingChangesCheck Hook + * 检查是否有未保存的变更,并弹出确认对话框 + */ + +import { Modal } from "antd"; +import { useTranslation } from "react-i18next"; +import { useFileChangeStore } from "@renderer/stores/fileChangeStore"; +import { useAppStore } from "@renderer/stores/appStore"; +import { useDomainStore } from "@renderer/stores/domainStore"; + +interface PendingChangesCheckResult { + /** + * 检查切换 app 是否有未保存的变更 + * @param targetAppId 目标 app ID + * @returns Promise 用户确认返回 true,取消返回 false + */ + checkAndConfirmAppSwitch: (targetAppId: string) => Promise; + + /** + * 检查切换 domain 是否有未保存的变更 + * @param targetDomainId 目标 domain ID + * @returns Promise 用户确认返回 true,取消返回 false + */ + checkAndConfirmDomainSwitch: (targetDomainId: string) => Promise; +} + +export const usePendingChangesCheck = (): PendingChangesCheckResult => { + const { t } = useTranslation("common"); + const { hasPendingChanges, getChangeCount } = useFileChangeStore(); + const { currentDomain } = useDomainStore(); + const { selectedAppId } = useAppStore(); + + /** + * 获取变更详情描述 + */ + const getChangeDescription = (domainId: string, appId: string): string => { + const changes = getChangeCount(domainId, appId); + const parts: string[] = []; + + if (changes.added > 0) { + parts.push(t("pendingChangesAdded", { count: changes.added })); + } + if (changes.deleted > 0) { + parts.push(t("pendingChangesDeleted", { count: changes.deleted })); + } + if (changes.reordered > 0) { + parts.push(t("pendingChangesReordered", { count: changes.reordered })); + } + + return parts.join(", "); + }; + + /** + * 检查并确认切换 app + */ + const checkAndConfirmAppSwitch = async (targetAppId: string): Promise => { + // 如果是同一个 app,不需要确认 + if (!currentDomain || !selectedAppId || selectedAppId === targetAppId) { + return true; + } + + // 检查当前 app 是否有未保存的变更 + if (!hasPendingChanges(currentDomain.id, selectedAppId)) { + return true; + } + + // 有未保存的变更,弹窗确认 + const changeDesc = getChangeDescription(currentDomain.id, selectedAppId); + + return new Promise((resolve) => { + Modal.confirm({ + title: t("unsavedChangesTitle"), + content: t("unsavedChangesAppMessage", { changes: changeDesc }), + okText: t("confirm"), + cancelText: t("cancel"), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + }; + + /** + * 检查并确认切换 domain + */ + const checkAndConfirmDomainSwitch = async (targetDomainId: string): Promise => { + // 如果是同一个 domain,不需要确认 + if (!currentDomain || currentDomain.id === targetDomainId) { + return true; + } + + // 检查当前 domain 和 app 是否有未保存的变更 + if (selectedAppId && hasPendingChanges(currentDomain.id, selectedAppId)) { + const changeDesc = getChangeDescription(currentDomain.id, selectedAppId); + + return new Promise((resolve) => { + Modal.confirm({ + title: t("unsavedChangesTitle"), + content: t("unsavedChangesDomainMessage", { changes: changeDesc }), + okText: t("confirm"), + cancelText: t("cancel"), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + } + + return true; + }; + + return { + checkAndConfirmAppSwitch, + checkAndConfirmDomainSwitch, + }; +}; diff --git a/src/renderer/src/locales/en-US/common.json b/src/renderer/src/locales/en-US/common.json index dbdda53..d290e05 100644 --- a/src/renderer/src/locales/en-US/common.json +++ b/src/renderer/src/locales/en-US/common.json @@ -38,5 +38,11 @@ "refreshSuccess": "Refreshed successfully", "refreshFailed": "Refresh failed", "downloadFile": "Download file", - "deleteFile": "Delete file" + "deleteFile": "Delete file", + "unsavedChangesTitle": "Unsaved Changes", + "unsavedChangesAppMessage": "The current app has unsaved changes ({{changes}}). Switching apps will discard these changes. Continue?", + "unsavedChangesDomainMessage": "The current app has unsaved changes ({{changes}}). Switching domains will discard these changes. Continue?", + "pendingChangesAdded": "{{count}} added file(s)", + "pendingChangesDeleted": "{{count}} deleted file(s)", + "pendingChangesReordered": "{{count}} reordered file(s)" } diff --git a/src/renderer/src/locales/ja-JP/common.json b/src/renderer/src/locales/ja-JP/common.json index f8c5eca..3595a80 100644 --- a/src/renderer/src/locales/ja-JP/common.json +++ b/src/renderer/src/locales/ja-JP/common.json @@ -38,5 +38,11 @@ "refreshSuccess": "更新しました", "refreshFailed": "更新に失敗しました", "downloadFile": "ファイルをダウンロード", - "deleteFile": "ファイルを削除" + "deleteFile": "ファイルを削除", + "unsavedChangesTitle": "未保存の変更", + "unsavedChangesAppMessage": "現在のアプリには未保存の変更があります ({{changes}})。アプリを切り替えると、これらの変更は失われます。続行しますか?", + "unsavedChangesDomainMessage": "現在のアプリには未保存の変更があります ({{changes}})。ドメインを切り替えると、これらの変更は失われます。続行しますか?", + "pendingChangesAdded": "{{count}} 件の追加ファイル", + "pendingChangesDeleted": "{{count}} 件の削除ファイル", + "pendingChangesReordered": "{{count}} 件の移動ファイル" } diff --git a/src/renderer/src/locales/zh-CN/common.json b/src/renderer/src/locales/zh-CN/common.json index 4376365..24d0a8a 100644 --- a/src/renderer/src/locales/zh-CN/common.json +++ b/src/renderer/src/locales/zh-CN/common.json @@ -38,5 +38,11 @@ "refreshSuccess": "刷新成功", "refreshFailed": "刷新失败", "downloadFile": "下载文件", - "deleteFile": "删除文件" + "deleteFile": "删除文件", + "unsavedChangesTitle": "未保存的变更", + "unsavedChangesAppMessage": "当前应用有未保存的变更 ({{changes}}),切换应用将丢失这些变更。是否继续?", + "unsavedChangesDomainMessage": "当前应用有未保存的变更 ({{changes}}),切换域名将丢失这些变更。是否继续?", + "pendingChangesAdded": "{{count}} 个新增文件", + "pendingChangesDeleted": "{{count}} 个删除文件", + "pendingChangesReordered": "{{count}} 个移动文件" } diff --git a/src/renderer/src/stores/domainStore.ts b/src/renderer/src/stores/domainStore.ts index 397ebc2..f1f72b1 100644 --- a/src/renderer/src/stores/domainStore.ts +++ b/src/renderer/src/stores/domainStore.ts @@ -5,6 +5,7 @@ import { create } from "zustand"; import { useAppStore } from "./appStore"; +import { useFileChangeStore } from "./fileChangeStore"; import { persist } from "zustand/middleware"; import type { Domain, DomainWithStatus } from "@shared/types/domain"; import type { ConnectionStatus } from "@shared/types/domain"; @@ -191,15 +192,24 @@ export const useDomainStore = create()( switchDomain: async (domain: Domain) => { const appStore = useAppStore.getState(); + const fileChangeStore = useFileChangeStore.getState(); // Track the domain ID at request start (closure variable) const requestDomainId = domain.id; + // Get previous domain ID before switching (to clear pending changes) + const previousDomainId = get().currentDomain?.id; + // 1. reset appStore.setLoading(true); appStore.setApps([]); appStore.setSelectedAppId(null); + // Clear pending file changes from previous domain + if (previousDomainId) { + fileChangeStore.clearDomainChanges(previousDomainId); + } + // 2. Set current domain set({ currentDomain: domain }); diff --git a/src/renderer/src/stores/fileChangeStore.ts b/src/renderer/src/stores/fileChangeStore.ts index 3a4d472..b59b5ef 100644 --- a/src/renderer/src/stores/fileChangeStore.ts +++ b/src/renderer/src/stores/fileChangeStore.ts @@ -74,6 +74,12 @@ interface FileChangeState { */ clearChanges: (domainId: string, appId: string) => void; + /** + * Clear all pending changes for all apps under a given domain. + * Removes all entries with keys starting with `${domainId}:`. + */ + clearDomainChanges: (domainId: string) => void; + /** Get all files for an app (all statuses) */ getFiles: (domainId: string, appId: string) => FileEntry[]; @@ -83,6 +89,9 @@ interface FileChangeState { /** Count of added, deleted, and reordered files */ getChangeCount: (domainId: string, appId: string) => { added: number; deleted: number; reordered: number }; + /** Check if there are pending changes for an app */ + hasPendingChanges: (domainId: string, appId: string) => boolean; + isInitialized: (domainId: string, appId: string) => boolean; } @@ -240,6 +249,19 @@ export const useFileChangeStore = create()( })); }, + clearDomainChanges: (domainId) => { + const prefix = `${domainId}:`; + set((state) => { + const newAppFiles: Record = {}; + for (const key of Object.keys(state.appFiles)) { + if (!key.startsWith(prefix)) { + newAppFiles[key] = state.appFiles[key]; + } + } + return { appFiles: newAppFiles }; + }); + }, + getFiles: (domainId, appId) => { const key = appKey(domainId, appId); return get().appFiles[key]?.files ?? []; @@ -262,6 +284,13 @@ export const useFileChangeStore = create()( }; }, + hasPendingChanges: (domainId, appId) => { + const key = appKey(domainId, appId); + const files = get().appFiles[key]?.files ?? []; + + return files.some((f) => f.status === "added" || f.status === "deleted" || f.status === "reordered"); + }, + isInitialized: (domainId, appId) => { const key = appKey(domainId, appId); return get().appFiles[key]?.initialized ?? false;