Update UX
This commit is contained in:
@@ -112,7 +112,8 @@ const AppDetail: React.FC = () => {
|
|||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
}, [selectedAppId]);
|
}, [selectedAppId]);
|
||||||
|
|
||||||
const loadAppDetail = useCallback(async () => {
|
const loadAppDetail = useCallback(
|
||||||
|
async (onSuccessCallback?: () => Promise<void> | void) => {
|
||||||
if (!currentDomain || !selectedAppId) return undefined;
|
if (!currentDomain || !selectedAppId) return undefined;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -124,9 +125,11 @@ const AppDetail: React.FC = () => {
|
|||||||
|
|
||||||
// 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
|
|
||||||
fileChangeStore.clearChanges(currentDomain.id, selectedAppId);
|
|
||||||
setCurrentApp(result.data);
|
setCurrentApp(result.data);
|
||||||
|
// Call the callback if provided
|
||||||
|
if (onSuccessCallback) {
|
||||||
|
await onSuccessCallback();
|
||||||
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -136,7 +139,9 @@ const AppDetail: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentDomain, selectedAppId, setCurrentApp, setLoading]);
|
},
|
||||||
|
[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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
115
src/renderer/src/hooks/usePendingChangesCheck.ts
Normal file
115
src/renderer/src/hooks/usePendingChangesCheck.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,11 @@
|
|||||||
"refreshSuccess": "更新しました",
|
"refreshSuccess": "更新しました",
|
||||||
"refreshFailed": "更新に失敗しました",
|
"refreshFailed": "更新に失敗しました",
|
||||||
"downloadFile": "ファイルをダウンロード",
|
"downloadFile": "ファイルをダウンロード",
|
||||||
"deleteFile": "ファイルを削除"
|
"deleteFile": "ファイルを削除",
|
||||||
|
"unsavedChangesTitle": "未保存の変更",
|
||||||
|
"unsavedChangesAppMessage": "現在のアプリには未保存の変更があります ({{changes}})。アプリを切り替えると、これらの変更は失われます。続行しますか?",
|
||||||
|
"unsavedChangesDomainMessage": "現在のアプリには未保存の変更があります ({{changes}})。ドメインを切り替えると、これらの変更は失われます。続行しますか?",
|
||||||
|
"pendingChangesAdded": "{{count}} 件の追加ファイル",
|
||||||
|
"pendingChangesDeleted": "{{count}} 件の削除ファイル",
|
||||||
|
"pendingChangesReordered": "{{count}} 件の移動ファイル"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,11 @@
|
|||||||
"refreshSuccess": "刷新成功",
|
"refreshSuccess": "刷新成功",
|
||||||
"refreshFailed": "刷新失败",
|
"refreshFailed": "刷新失败",
|
||||||
"downloadFile": "下载文件",
|
"downloadFile": "下载文件",
|
||||||
"deleteFile": "删除文件"
|
"deleteFile": "删除文件",
|
||||||
|
"unsavedChangesTitle": "未保存的变更",
|
||||||
|
"unsavedChangesAppMessage": "当前应用有未保存的变更 ({{changes}}),切换应用将丢失这些变更。是否继续?",
|
||||||
|
"unsavedChangesDomainMessage": "当前应用有未保存的变更 ({{changes}}),切换域名将丢失这些变更。是否继续?",
|
||||||
|
"pendingChangesAdded": "{{count}} 个新增文件",
|
||||||
|
"pendingChangesDeleted": "{{count}} 个删除文件",
|
||||||
|
"pendingChangesReordered": "{{count}} 个移动文件"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user