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);
}, [selectedAppId]);
const loadAppDetail = useCallback(async () => {
if (!currentDomain || !selectedAppId) return undefined;
const loadAppDetail = useCallback(
async (onSuccessCallback?: () => Promise<void> | 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"));
}

View File

@@ -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);
};

View File

@@ -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<DomainListProps> = ({ 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);
};

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",
"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)"
}

View File

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

View File

@@ -38,5 +38,11 @@
"refreshSuccess": "刷新成功",
"refreshFailed": "刷新失败",
"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 { 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<DomainState>()(
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 });

View File

@@ -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<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) => {
const key = appKey(domainId, appId);
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) => {
const key = appKey(domainId, appId);
return get().appFiles[key]?.initialized ?? false;