mirror of
https://github.com/langgenius/dify.git
synced 2026-04-29 04:26:30 +08:00
fix: prevent empty state flash and add skeleton loading for app list (#30616)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
parent
7beed12eab
commit
44d7aaaf33
41
web/app/components/apps/app-card-skeleton.tsx
Normal file
41
web/app/components/apps/app-card-skeleton.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||||
|
|
||||||
|
type AppCardSkeletonProps = {
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton placeholder for App cards during loading states.
|
||||||
|
* Matches the visual layout of AppCard component.
|
||||||
|
*/
|
||||||
|
export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-[160px] rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4"
|
||||||
|
>
|
||||||
|
<SkeletonContainer className="h-full">
|
||||||
|
<SkeletonRow>
|
||||||
|
<SkeletonRectangle className="h-10 w-10 rounded-lg" />
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<SkeletonRectangle className="h-4 w-2/3" />
|
||||||
|
<SkeletonRectangle className="h-3 w-1/3" />
|
||||||
|
</div>
|
||||||
|
</SkeletonRow>
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<SkeletonRectangle className="h-3 w-full" />
|
||||||
|
<SkeletonRectangle className="h-3 w-4/5" />
|
||||||
|
</div>
|
||||||
|
</SkeletonContainer>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
AppCardSkeleton.displayName = 'AppCardSkeleton'
|
||||||
@ -27,7 +27,9 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
|||||||
import { CheckModal } from '@/hooks/use-pay'
|
import { CheckModal } from '@/hooks/use-pay'
|
||||||
import { useInfiniteAppList } from '@/service/use-apps'
|
import { useInfiniteAppList } from '@/service/use-apps'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
|
import { cn } from '@/utils/classnames'
|
||||||
import AppCard from './app-card'
|
import AppCard from './app-card'
|
||||||
|
import { AppCardSkeleton } from './app-card-skeleton'
|
||||||
import Empty from './empty'
|
import Empty from './empty'
|
||||||
import Footer from './footer'
|
import Footer from './footer'
|
||||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||||
@ -45,7 +47,7 @@ const List = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { systemFeatures } = useGlobalPublicStore()
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||||
const [activeTab, setActiveTab] = useQueryState(
|
const [activeTab, setActiveTab] = useQueryState(
|
||||||
'category',
|
'category',
|
||||||
@ -89,6 +91,7 @@ const List = () => {
|
|||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isFetching,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
@ -172,6 +175,8 @@ const List = () => {
|
|||||||
|
|
||||||
const pages = data?.pages ?? []
|
const pages = data?.pages ?? []
|
||||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||||
|
// Show skeleton during initial load or when refetching with no previous data
|
||||||
|
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -205,23 +210,34 @@ const List = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasAnyApp
|
<div className={cn(
|
||||||
? (
|
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
|
||||||
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6">
|
!hasAnyApp && 'overflow-hidden',
|
||||||
{isCurrentWorkspaceEditor
|
)}
|
||||||
&& <NewAppCard ref={newAppCardRef} onSuccess={refetch} selectedAppType={activeTab} />}
|
>
|
||||||
{pages.map(({ data: apps }) => apps.map(app => (
|
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
<NewAppCard
|
||||||
)))}
|
ref={newAppCardRef}
|
||||||
</div>
|
isLoading={isLoadingCurrentWorkspace}
|
||||||
)
|
onSuccess={refetch}
|
||||||
: (
|
selectedAppType={activeTab}
|
||||||
<div className="relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6">
|
className={cn(!hasAnyApp && 'z-10')}
|
||||||
{isCurrentWorkspaceEditor
|
/>
|
||||||
&& <NewAppCard ref={newAppCardRef} className="z-10" onSuccess={refetch} selectedAppType={activeTab} />}
|
)}
|
||||||
<Empty />
|
{(() => {
|
||||||
</div>
|
if (showSkeleton)
|
||||||
)}
|
return <AppCardSkeleton count={6} />
|
||||||
|
|
||||||
|
if (hasAnyApp) {
|
||||||
|
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||||
|
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// No apps - show empty state
|
||||||
|
return <Empty />
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isCurrentWorkspaceEditor && (
|
{isCurrentWorkspaceEditor && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
|||||||
|
|
||||||
export type CreateAppCardProps = {
|
export type CreateAppCardProps = {
|
||||||
className?: string
|
className?: string
|
||||||
|
isLoading?: boolean
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
ref: React.RefObject<HTMLDivElement | null>
|
ref: React.RefObject<HTMLDivElement | null>
|
||||||
selectedAppType?: string
|
selectedAppType?: string
|
||||||
@ -33,6 +34,7 @@ export type CreateAppCardProps = {
|
|||||||
const CreateAppCard = ({
|
const CreateAppCard = ({
|
||||||
ref,
|
ref,
|
||||||
className,
|
className,
|
||||||
|
isLoading = false,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
selectedAppType,
|
selectedAppType,
|
||||||
}: CreateAppCardProps) => {
|
}: CreateAppCardProps) => {
|
||||||
@ -56,7 +58,11 @@ const CreateAppCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg', className)}
|
className={cn(
|
||||||
|
'relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
|
||||||
|
isLoading && 'pointer-events-none opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="grow rounded-t-xl p-2">
|
<div className="grow rounded-t-xl p-2">
|
||||||
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('createApp', { ns: 'app' })}</div>
|
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('createApp', { ns: 'app' })}</div>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import type {
|
|||||||
} from '@/models/app'
|
} from '@/models/app'
|
||||||
import type { App, AppModeEnum } from '@/types/app'
|
import type { App, AppModeEnum } from '@/types/app'
|
||||||
import {
|
import {
|
||||||
|
keepPreviousData,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
@ -107,6 +108,7 @@ export const useInfiniteAppList = (params: AppListParams, options?: { enabled?:
|
|||||||
queryFn: ({ pageParam = normalizedParams.page }) => get<AppListResponse>('/apps', { params: { ...normalizedParams, page: pageParam } }),
|
queryFn: ({ pageParam = normalizedParams.page }) => get<AppListResponse>('/apps', { params: { ...normalizedParams, page: pageParam } }),
|
||||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
||||||
initialPageParam: normalizedParams.page,
|
initialPageParam: normalizedParams.page,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user