Update UX

This commit is contained in:
2026-03-18 11:54:30 +08:00
parent d981cd4d8b
commit cf903872b8
9 changed files with 230 additions and 32 deletions

View File

@@ -112,31 +112,36 @@ const AppDetail: React.FC = () => {
setSelectedFile(null); setSelectedFile(null);
}, [selectedAppId]); }, [selectedAppId]);
const loadAppDetail = useCallback(async () => { const loadAppDetail = useCallback(
if (!currentDomain || !selectedAppId) return undefined; async (onSuccessCallback?: () => Promise<void> | void) => {
if (!currentDomain || !selectedAppId) return undefined;
setLoading(true); setLoading(true);
try { try {
const result = await window.api.getAppDetail({ const result = await window.api.getAppDetail({
domainId: currentDomain.id, domainId: currentDomain.id,
appId: selectedAppId, appId: selectedAppId,
}); });
// Check if we're still on the same app and component is mounted before updating // Check if we're still on the same app and component is mounted before updating
if (result.success) { if (result.success) {
// Clear changes and reload from Kintone setCurrentApp(result.data);
fileChangeStore.clearChanges(currentDomain.id, selectedAppId); // Call the callback if provided
setCurrentApp(result.data); if (onSuccessCallback) {
return result.data; 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) { [currentDomain, selectedAppId, setCurrentApp, setLoading]
console.error("Failed to load app detail:", error); );
return undefined;
} finally {
setLoading(false);
}
}, [currentDomain, selectedAppId, setCurrentApp, setLoading]);
// Load app detail when selected // Load app detail when selected
useEffect(() => { useEffect(() => {
@@ -168,7 +173,9 @@ const AppDetail: React.FC = () => {
setRefreshing(true); setRefreshing(true);
try { 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" })); message.success(t("refreshSuccess", { ns: "common" }));
} catch (error) { } catch (error) {
console.error("Failed to refresh:", error); console.error("Failed to refresh:", error);
@@ -312,7 +319,8 @@ const AppDetail: React.FC = () => {
if (result.success) { if (result.success) {
message.success(t("deploySuccess")); message.success(t("deploySuccess"));
await loadAppDetail(); // Clear changes after successful deploy
await loadAppDetail(() => fileChangeStore.clearChanges(currentDomain.id, selectedAppId));
} else { } else {
message.error(result.error || t("deployFailed")); message.error(result.error || t("deployFailed"));
} }

View File

@@ -10,9 +10,8 @@ import { Button, Tooltip, Empty, Select, Input } from "@lobehub/ui";
import { RefreshCw, Search, ArrowUpDown, ArrowDownUp } from "lucide-react"; import { RefreshCw, Search, ArrowUpDown, ArrowDownUp } from "lucide-react";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import { useAppStore } from "@renderer/stores"; import { useAppStore, useDomainStore, useUIStore, useFileChangeStore } from "@renderer/stores";
import { useDomainStore } from "@renderer/stores"; import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck";
import { useUIStore } from "@renderer/stores";
import type { AppDetail } from "@shared/types/kintone"; import type { AppDetail } from "@shared/types/kintone";
import AppListItem from "./AppListItem"; import AppListItem from "./AppListItem";
@@ -80,6 +79,8 @@ const AppList: React.FC = () => {
const { currentDomain } = useDomainStore(); const { currentDomain } = useDomainStore();
const { apps, loading, error, searchText, loadedAt, selectedAppId, setApps, setLoading, setError, setSearchText, setSelectedAppId } = useAppStore(); const { apps, loading, error, searchText, loadedAt, selectedAppId, setApps, setLoading, setError, setSearchText, setSelectedAppId } = useAppStore();
const { pinnedApps, appSortBy, appSortOrder, togglePinnedApp, setAppSortBy, setAppSortOrder } = useUIStore(); const { pinnedApps, appSortBy, appSortOrder, togglePinnedApp, setAppSortBy, setAppSortOrder } = useUIStore();
const { clearChanges } = useFileChangeStore();
const { checkAndConfirmAppSwitch } = usePendingChangesCheck();
const currentPinnedApps = currentDomain ? pinnedApps[currentDomain.id] || [] : []; const currentPinnedApps = currentDomain ? pinnedApps[currentDomain.id] || [] : [];
@@ -165,7 +166,17 @@ const AppList: React.FC = () => {
const displayApps = [...pinnedAppsList, ...unpinnedAppsList]; const displayApps = [...pinnedAppsList, ...unpinnedAppsList];
// Handle item click // 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); setSelectedAppId(app.appId);
}; };

View File

@@ -9,6 +9,7 @@ import { Button, SortableList, Tooltip } from "@lobehub/ui";
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck";
import type { Domain } from "@shared/types/domain"; import type { Domain } from "@shared/types/domain";
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
@@ -97,11 +98,17 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
const { t } = useTranslation("domain"); const { t } = useTranslation("domain");
const { styles } = useStyles(); const { styles } = useStyles();
const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } = useDomainStore(); const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } = useDomainStore();
const { checkAndConfirmDomainSwitch } = usePendingChangesCheck();
const handleSelect = (domain: Domain) => { const handleSelect = async (domain: Domain) => {
if (currentDomain?.id === domain.id) { if (currentDomain?.id === domain.id) {
return; return;
} }
// Check for pending changes before switching domain
const confirmed = await checkAndConfirmDomainSwitch(domain.id);
if (!confirmed) {
return;
}
switchDomain(domain); switchDomain(domain);
}; };

View File

@@ -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<boolean> 用户确认返回 true取消返回 false
*/
checkAndConfirmAppSwitch: (targetAppId: string) => Promise<boolean>;
/**
* 检查切换 domain 是否有未保存的变更
* @param targetDomainId 目标 domain ID
* @returns Promise<boolean> 用户确认返回 true取消返回 false
*/
checkAndConfirmDomainSwitch: (targetDomainId: string) => Promise<boolean>;
}
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<boolean> => {
// 如果是同一个 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<boolean> => {
// 如果是同一个 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,
};
};

View File

@@ -38,5 +38,11 @@
"refreshSuccess": "Refreshed successfully", "refreshSuccess": "Refreshed successfully",
"refreshFailed": "Refresh failed", "refreshFailed": "Refresh failed",
"downloadFile": "Download file", "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)"
} }

View File

@@ -38,5 +38,11 @@
"refreshSuccess": "更新しました", "refreshSuccess": "更新しました",
"refreshFailed": "更新に失敗しました", "refreshFailed": "更新に失敗しました",
"downloadFile": "ファイルをダウンロード", "downloadFile": "ファイルをダウンロード",
"deleteFile": "ファイルを削除" "deleteFile": "ファイルを削除",
"unsavedChangesTitle": "未保存の変更",
"unsavedChangesAppMessage": "現在のアプリには未保存の変更があります ({{changes}})。アプリを切り替えると、これらの変更は失われます。続行しますか?",
"unsavedChangesDomainMessage": "現在のアプリには未保存の変更があります ({{changes}})。ドメインを切り替えると、これらの変更は失われます。続行しますか?",
"pendingChangesAdded": "{{count}} 件の追加ファイル",
"pendingChangesDeleted": "{{count}} 件の削除ファイル",
"pendingChangesReordered": "{{count}} 件の移動ファイル"
} }

View File

@@ -38,5 +38,11 @@
"refreshSuccess": "刷新成功", "refreshSuccess": "刷新成功",
"refreshFailed": "刷新失败", "refreshFailed": "刷新失败",
"downloadFile": "下载文件", "downloadFile": "下载文件",
"deleteFile": "删除文件" "deleteFile": "删除文件",
"unsavedChangesTitle": "未保存的变更",
"unsavedChangesAppMessage": "当前应用有未保存的变更 ({{changes}}),切换应用将丢失这些变更。是否继续?",
"unsavedChangesDomainMessage": "当前应用有未保存的变更 ({{changes}}),切换域名将丢失这些变更。是否继续?",
"pendingChangesAdded": "{{count}} 个新增文件",
"pendingChangesDeleted": "{{count}} 个删除文件",
"pendingChangesReordered": "{{count}} 个移动文件"
} }

View File

@@ -5,6 +5,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { useAppStore } from "./appStore"; import { useAppStore } from "./appStore";
import { useFileChangeStore } from "./fileChangeStore";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { Domain, DomainWithStatus } from "@shared/types/domain"; import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type { ConnectionStatus } from "@shared/types/domain"; import type { ConnectionStatus } from "@shared/types/domain";
@@ -191,15 +192,24 @@ export const useDomainStore = create<DomainState>()(
switchDomain: async (domain: Domain) => { switchDomain: async (domain: Domain) => {
const appStore = useAppStore.getState(); const appStore = useAppStore.getState();
const fileChangeStore = useFileChangeStore.getState();
// Track the domain ID at request start (closure variable) // Track the domain ID at request start (closure variable)
const requestDomainId = domain.id; const requestDomainId = domain.id;
// Get previous domain ID before switching (to clear pending changes)
const previousDomainId = get().currentDomain?.id;
// 1. reset // 1. reset
appStore.setLoading(true); appStore.setLoading(true);
appStore.setApps([]); appStore.setApps([]);
appStore.setSelectedAppId(null); appStore.setSelectedAppId(null);
// Clear pending file changes from previous domain
if (previousDomainId) {
fileChangeStore.clearDomainChanges(previousDomainId);
}
// 2. Set current domain // 2. Set current domain
set({ currentDomain: domain }); set({ currentDomain: domain });

View File

@@ -74,6 +74,12 @@ interface FileChangeState {
*/ */
clearChanges: (domainId: string, appId: string) => void; 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) */ /** Get all files for an app (all statuses) */
getFiles: (domainId: string, appId: string) => FileEntry[]; getFiles: (domainId: string, appId: string) => FileEntry[];
@@ -83,6 +89,9 @@ interface FileChangeState {
/** Count of added, deleted, and reordered files */ /** Count of added, deleted, and reordered files */
getChangeCount: (domainId: string, appId: string) => { added: number; deleted: number; reordered: number }; 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; isInitialized: (domainId: string, appId: string) => boolean;
} }
@@ -240,6 +249,19 @@ export const useFileChangeStore = create<FileChangeState>()(
})); }));
}, },
clearDomainChanges: (domainId) => {
const prefix = `${domainId}:`;
set((state) => {
const newAppFiles: Record<string, AppFileState> = {};
for (const key of Object.keys(state.appFiles)) {
if (!key.startsWith(prefix)) {
newAppFiles[key] = state.appFiles[key];
}
}
return { appFiles: newAppFiles };
});
},
getFiles: (domainId, appId) => { getFiles: (domainId, appId) => {
const key = appKey(domainId, appId); const key = appKey(domainId, appId);
return get().appFiles[key]?.files ?? []; return get().appFiles[key]?.files ?? [];
@@ -262,6 +284,13 @@ export const useFileChangeStore = create<FileChangeState>()(
}; };
}, },
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) => { isInitialized: (domainId, appId) => {
const key = appKey(domainId, appId); const key = appKey(domainId, appId);
return get().appFiles[key]?.initialized ?? false; return get().appFiles[key]?.initialized ?? false;