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:
yyh 2026-01-06 20:19:22 +08:00 committed by GitHub
parent 7beed12eab
commit 44d7aaaf33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 84 additions and 19 deletions

View 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'

View File

@ -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

View File

@ -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>

View File

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