From 91856b09cad982ad71ddbc5bc32e89408c7c911d Mon Sep 17 00:00:00 2001 From: yyh Date: Sun, 18 Jan 2026 23:07:33 +0800 Subject: [PATCH] refactor: migrate appDetail from Zustand to TanStack Query - Remove appDetail and setAppDetail from Zustand store - Use useAppDetail hook for server state management - Child components now call useAppDetail(appId) directly via useParams() - Replace setAppDetail calls with useInvalidateAppDetail for cache invalidation - Keep only client UI state in Zustand (sidebar, modals) - Split sidebar initialization useEffect for clearer separation of concerns - Update test mocks to use TanStack Query pattern - Fix missing dependencies in use-checklist.ts useMemo/useCallback hooks --- .../(appDetailLayout)/[appId]/layout-main.tsx | 145 ++++++++---------- .../[appId]/overview/card-view.tsx | 14 +- .../[appId]/overview/chart-view.tsx | 4 +- web/app/components/app-sidebar/app-info.tsx | 31 ++-- .../app-sidebar/app-sidebar-dropdown.tsx | 6 +- .../components/app/app-publisher/index.tsx | 16 +- .../config-var/config-modal/index.tsx | 6 +- .../components/app/configuration/index.tsx | 33 ++-- .../app/log-annotation/index.spec.tsx | 30 +++- .../components/app/log-annotation/index.tsx | 7 +- web/app/components/app/overview/app-card.tsx | 20 +-- web/app/components/app/store.ts | 5 - .../components/app/switch-app-modal/index.tsx | 9 +- .../app/workflow-log/detail.spec.tsx | 40 +++-- .../components/app/workflow-log/detail.tsx | 7 +- .../components/app/workflow-log/list.spec.tsx | 46 +++++- .../base/agent-log-modal/detail.tsx | 6 +- .../base/agent-log-modal/index.stories.tsx | 58 ++++--- .../base/message-log-modal/index.stories.tsx | 54 ++++--- .../base/message-log-modal/index.tsx | 6 +- web/app/components/develop/index.tsx | 6 +- web/app/components/header/app-nav/index.tsx | 77 ++++------ web/app/components/header/nav/index.tsx | 8 - .../header/nav/nav-selector/index.tsx | 3 - .../workflow-header/features-trigger.tsx | 22 +-- .../components/workflow-header/index.tsx | 12 +- .../components/workflow-panel.tsx | 8 +- .../components/workflow-app/hooks/use-DSL.ts | 6 +- .../workflow-app/hooks/use-is-chat-mode.ts | 6 +- .../workflow-app/hooks/use-workflow-init.ts | 19 ++- .../workflow-app/hooks/use-workflow-run.ts | 12 +- web/app/components/workflow-app/index.tsx | 10 +- .../workflow/header/view-workflow-history.tsx | 1 - .../hooks/use-auto-generate-webhook-url.ts | 8 +- .../workflow/hooks/use-checklist.ts | 15 +- .../components/workflow/hooks/use-workflow.ts | 6 +- .../_base/components/workflow-panel/index.tsx | 5 +- .../nodes/trigger-webhook/use-config.ts | 6 +- .../workflow/panel/chat-record/index.tsx | 6 +- .../panel/debug-and-preview/chat-wrapper.tsx | 6 +- .../components/workflow/update-dsl-modal.tsx | 6 +- web/eslint-suppressions.json | 19 --- web/service/use-apps.ts | 11 ++ 43 files changed, 439 insertions(+), 382 deletions(-) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 470f4477fa..801d6cfd47 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import type { NavIcon } from '@/app/components/app-sidebar/navLink' -import type { App } from '@/types/app' import { RiDashboard2Fill, RiDashboard2Line, @@ -12,13 +11,11 @@ import { RiTerminalWindowFill, RiTerminalWindowLine, } from '@remixicon/react' -import { useUnmount } from 'ahooks' import dynamic from 'next/dynamic' import { usePathname, useRouter } from 'next/navigation' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' import AppSideBar from '@/app/components/app-sidebar' import { useStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' @@ -26,7 +23,7 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' -import { fetchAppDetailDirect } from '@/service/apps' +import { useAppDetail } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' import s from './style.module.css' @@ -41,47 +38,41 @@ export type IAppDetailLayoutProps = { } const AppDetailLayout: FC = (props) => { - const { - children, - appId, // get appId in path - } = props + const { children, appId } = props const { t } = useTranslation() const router = useRouter() const pathname = usePathname() const media = useBreakpoints() const isMobile = media === MediaType.mobile - const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() - const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({ - appDetail: state.appDetail, - setAppDetail: state.setAppDetail, - setAppSidebarExpand: state.setAppSidebarExpand, - }))) - const showTagManagementModal = useTagStore(s => s.showTagManagementModal) - const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false) - const [appDetailRes, setAppDetailRes] = useState(null) - const [navigation, setNavigation] = useState>([]) - const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => { - const navConfig = [ + const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace } = useAppContext() + const setAppSidebarExpand = useStore(s => s.setAppSidebarExpand) + const showTagManagementModal = useTagStore(s => s.showTagManagementModal) + + const { data: appDetail, isPending, error } = useAppDetail(appId) + + const navigation = useMemo(() => { + if (!appDetail) + return [] + + const mode = appDetail.mode + const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT + + return [ ...(isCurrentWorkspaceEditor ? [{ name: t('appMenus.promptEng', { ns: 'common' }), - href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`, - icon: RiTerminalWindowLine, - selectedIcon: RiTerminalWindowFill, + href: `/app/${appId}/${isWorkflowMode ? 'workflow' : 'configuration'}`, + icon: RiTerminalWindowLine as NavIcon, + selectedIcon: RiTerminalWindowFill as NavIcon, }] : [] ), { name: t('appMenus.apiAccess', { ns: 'common' }), href: `/app/${appId}/develop`, - icon: RiTerminalBoxLine, - selectedIcon: RiTerminalBoxFill, + icon: RiTerminalBoxLine as NavIcon, + selectedIcon: RiTerminalBoxFill as NavIcon, }, ...(isCurrentWorkspaceEditor ? [{ @@ -89,74 +80,64 @@ const AppDetailLayout: FC = (props) => { ? t('appMenus.logAndAnn', { ns: 'common' }) : t('appMenus.logs', { ns: 'common' }), href: `/app/${appId}/logs`, - icon: RiFileList3Line, - selectedIcon: RiFileList3Fill, + icon: RiFileList3Line as NavIcon, + selectedIcon: RiFileList3Fill as NavIcon, }] : [] ), { name: t('appMenus.overview', { ns: 'common' }), href: `/app/${appId}/overview`, - icon: RiDashboard2Line, - selectedIcon: RiDashboard2Fill, + icon: RiDashboard2Line as NavIcon, + selectedIcon: RiDashboard2Fill as NavIcon, }, ] - return navConfig - }, [t]) + }, [appDetail, appId, isCurrentWorkspaceEditor, t]) useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' })) useEffect(() => { - if (appDetail) { - const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' - const mode = isMobile ? 'collapse' : 'expand' - setAppSidebarExpand(isMobile ? mode : localeMode) - // TODO: consider screen size and mode - // if ((appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === 'workflow') && (pathname).endsWith('workflow')) - // setAppSidebarExpand('collapse') - } - }, [appDetail, isMobile]) + if (!appDetail) + return + const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' + setAppSidebarExpand(localeMode) + }, [appDetail, setAppSidebarExpand]) useEffect(() => { - setAppDetail() - setIsLoadingAppDetail(true) - fetchAppDetailDirect({ url: '/apps', id: appId }).then((res: App) => { - setAppDetailRes(res) - }).catch((e: any) => { - if (e.status === 404) - router.replace('/apps') - }).finally(() => { - setIsLoadingAppDetail(false) - }) - }, [appId, pathname]) + if (isMobile) + setAppSidebarExpand('collapse') + }, [isMobile, setAppSidebarExpand]) useEffect(() => { - if (!appDetailRes || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail) - return - const res = appDetailRes - // redirection - const canIEditApp = isCurrentWorkspaceEditor - if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs'))) { - router.replace(`/app/${appId}/overview`) + if (!appDetail || isLoadingCurrentWorkspace) return + + const mode = appDetail.mode + const isWorkflowMode = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT + + if (!isCurrentWorkspaceEditor) { + const restrictedPaths = ['configuration', 'workflow', 'logs'] + if (restrictedPaths.some(p => pathname.endsWith(p))) { + router.replace(`/app/${appId}/overview`) + return + } } - if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) { + + if (isWorkflowMode && pathname.endsWith('configuration')) router.replace(`/app/${appId}/workflow`) - } - else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) { + else if (!isWorkflowMode && pathname.endsWith('workflow')) router.replace(`/app/${appId}/configuration`) - } - else { - setAppDetail({ ...res, enable_sso: false }) - setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode)) - } - }, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace]) + }, [appDetail, isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, pathname, appId, router]) - useUnmount(() => { - setAppDetail() - }) + useEffect(() => { + if (error) { + const httpError = error as { status?: number } + if (httpError.status === 404) + router.replace('/apps') + } + }, [error, router]) - if (!appDetail) { + if (isPending) { return (
@@ -164,13 +145,12 @@ const AppDetailLayout: FC = (props) => { ) } + if (!appDetail) + return null + return (
- {appDetail && ( - - )} +
{children}
@@ -180,4 +160,5 @@ const AppDetailLayout: FC = (props) => {
) } + export default React.memo(AppDetailLayout) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index 81b4f2474e..1d3814fefc 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -5,13 +5,13 @@ import type { BlockEnum } from '@/app/components/workflow/types' import type { UpdateAppSiteCodeResponse } from '@/models/app' import type { App } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' +import { useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import AppCard from '@/app/components/app/overview/app-card' import TriggerCard from '@/app/components/app/overview/trigger-card' -import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { ToastContext } from '@/app/components/base/toast' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' @@ -19,11 +19,11 @@ import { isTriggerNode } from '@/app/components/workflow/types' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useDocLink } from '@/context/i18n' import { - fetchAppDetail, updateAppSiteAccessToken, updateAppSiteConfig, updateAppSiteStatus, } from '@/service/apps' +import { useAppDetail } from '@/service/use-apps' import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum } from '@/types/app' import { asyncRunSafe } from '@/utils' @@ -38,8 +38,8 @@ const CardView: FC = ({ appId, isInPanel, className }) => { const { t } = useTranslation() const docLink = useDocLink() const { notify } = useContext(ToastContext) - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) + const queryClient = useQueryClient() + const { data: appDetail } = useAppDetail(appId) const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW const showMCPCard = isInPanel @@ -90,11 +90,7 @@ const CardView: FC = ({ appId, isInPanel, className }) => { : null const updateAppDetail = async () => { - try { - const res = await fetchAppDetail({ url: '/apps', id: appId }) - setAppDetail({ ...res }) - } - catch (error) { console.error(error) } + await queryClient.invalidateQueries({ queryKey: ['apps', 'detail', appId] }) } const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index b6e902f456..ada41d2403 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -8,8 +8,8 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter' import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart' -import { useStore as useAppStore } from '@/app/components/app/store' import { IS_CLOUD_EDITION } from '@/config' +import { useAppDetail } from '@/service/use-apps' import LongTimeRangePicker from './long-time-range-picker' import TimeRangePicker from './time-range-picker' @@ -34,7 +34,7 @@ export type IChartViewProps = { export default function ChartView({ appId, headerRight }: IChartViewProps) { const { t } = useTranslation() - const appDetail = useAppStore(state => state.appDetail) + const { data: appDetail } = useAppDetail(appId) const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow' const isWorkflow = appDetail?.mode === 'workflow' const [period, setPeriod] = useState(IS_CLOUD_EDITION diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index 255feaccdf..19a5dd7e28 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -11,14 +11,14 @@ import { RiFileDownloadLine, RiFileUploadLine, } from '@remixicon/react' +import { useQueryClient } from '@tanstack/react-query' import dynamic from 'next/dynamic' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view' -import { useStore as useAppStore } from '@/app/components/app/store' import Button from '@/app/components/base/button' import ContentDialog from '@/app/components/base/content-dialog' import { ToastContext } from '@/app/components/base/toast' @@ -26,7 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' -import { useInvalidateAppList } from '@/service/use-apps' +import { useAppDetail, useInvalidateAppList } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -64,9 +64,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const { t } = useTranslation() const { notify } = useContext(ToastContext) const { replace } = useRouter() + const { appId } = useParams() + const queryClient = useQueryClient() const { onPlanInfoChanged } = useProviderContext() - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) + const { data: appDetail } = useAppDetail(appId as string) const invalidateAppList = useInvalidateAppList() const [open, setOpen] = useState(openState) const [showEditModal, setShowEditModal] = useState(false) @@ -77,6 +78,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const [secretEnvList, setSecretEnvList] = useState([]) const [showExportWarning, setShowExportWarning] = useState(false) + const invalidateAppDetail = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['apps', 'detail', appId] }) + }, [queryClient, appId]) + const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, @@ -89,7 +94,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx if (!appDetail) return try { - const app = await updateAppInfo({ + await updateAppInfo({ appID: appDetail.id, name, icon_type, @@ -104,12 +109,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx type: 'success', message: t('editDone', { ns: 'app' }), }) - setAppDetail(app) + invalidateAppDetail() } catch { notify({ type: 'error', message: t('editFailed', { ns: 'app' }) }) } - }, [appDetail, notify, setAppDetail, t]) + }, [appDetail, notify, invalidateAppDetail, t]) const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => { if (!appDetail) @@ -195,7 +200,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) }) invalidateAppList() onPlanInfoChanged() - setAppDetail() replace('/apps') } catch (e: any) { @@ -205,7 +209,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx }) } setShowConfirmDelete(false) - }, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t]) + }, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, t]) const { isCurrentWorkspaceEditor } = useAppContext() @@ -242,7 +246,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx ] const secondaryOperations: Operation[] = [ - // Import DSL (conditional) ...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) ? [{ id: 'import', @@ -255,7 +258,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx }, }] : [], - // Divider { id: 'divider-1', title: '', @@ -263,7 +265,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx onClick: () => { /* divider has no action */ }, type: 'divider' as const, }, - // Delete operation { id: 'delete', title: t('operation.delete', { ns: 'common' }), @@ -276,7 +277,6 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx }, ] - // Keep the switch operation separate as it's not part of the main operations const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT) ? { id: 'switch', @@ -370,11 +370,9 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('types.advanced', { ns: 'app' }) : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('types.agent', { ns: 'app' }) : appDetail.mode === AppModeEnum.CHAT ? t('types.chatbot', { ns: 'app' }) : appDetail.mode === AppModeEnum.COMPLETION ? t('types.completion', { ns: 'app' }) : t('types.workflow', { ns: 'app' })}
- {/* description */} {appDetail.description && (
{appDetail.description}
)} - {/* operations */} - {/* Switch operation (if available) */} {switchOperation && (