mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen'
|
|
import type { App } from '@/models/explore'
|
|
import type { TryAppSelection } from '@/types/try-app'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import {
|
|
keepPreviousData,
|
|
useInfiniteQuery,
|
|
useQuery,
|
|
useSuspenseQuery,
|
|
} from '@tanstack/react-query'
|
|
import { useDebounce } from 'ahooks'
|
|
import { useAtomValue } from 'jotai'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNeedRefreshAppList } from '@/app/components/apps/storage'
|
|
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
|
import { useProviderContext } from '@/context/provider-context'
|
|
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
|
import { CheckModal } from '@/hooks/use-pay'
|
|
import { consoleQuery } from '@/service/client'
|
|
import { normalizeAppPagination } from '@/service/use-apps'
|
|
import { AppModeEnum } from '@/types/app'
|
|
import { hasPermission } from '@/utils/permission'
|
|
import { AppCard } from './app-card'
|
|
import { AppCardSkeleton } from './app-card-skeleton'
|
|
import { AppListCreationModals } from './app-list-creation-modals'
|
|
import { AppListHeaderFilters } from './app-list-header-filters'
|
|
import { AppListTagManagementModal } from './app-list-tag-management-modal'
|
|
import { APP_LIST_GRID_CLASS_NAME, APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
|
import Empty from './empty'
|
|
import FirstEmptyState from './first-empty-state'
|
|
import { useAppsQueryState } from './hooks/use-apps-query-state'
|
|
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
|
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
|
|
import { StarredAppList } from './starred-app-list'
|
|
import { StudioListHeader } from './studio-list-header'
|
|
|
|
const STARRED_APP_LIMIT = 100
|
|
|
|
type AppListQuery = NonNullable<GetAppsData['query']>
|
|
type AppListSortBy = NonNullable<AppListQuery['sort_by']>
|
|
|
|
type Props = Readonly<{
|
|
controlRefreshList?: number
|
|
onCreateLearnDify?: (app: App) => void
|
|
onTryLearnDify?: (params: TryAppSelection) => void
|
|
}>
|
|
|
|
function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Props) {
|
|
const { t } = useTranslation()
|
|
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
|
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
|
const { onPlanInfoChanged } = useProviderContext()
|
|
|
|
// oxlint-disable-next-line eslint-react/use-state -- custom URL query hook, not React.useState
|
|
const {
|
|
query: { category, keywords, creatorIDs },
|
|
setCategory,
|
|
setKeywords,
|
|
setCreatorIDs,
|
|
} = useAppsQueryState()
|
|
const [tagIDs, setTagIDs] = useState<string[]>([])
|
|
const [sortBy, setSortBy] = useState<AppListSortBy>('last_modified')
|
|
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
|
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
|
|
const [showNewAppModal, setShowNewAppModal] = useState(false)
|
|
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
|
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
|
const [needsRefreshAppList, setNeedsRefreshAppList] = useNeedRefreshAppList()
|
|
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
|
|
|
|
const handleDSLFileDropped = useCallback(
|
|
(file: File) => {
|
|
if (!canCreateApp) return
|
|
|
|
setDroppedDSLFile(file)
|
|
setShowCreateFromDSLModal(true)
|
|
},
|
|
[canCreateApp],
|
|
)
|
|
|
|
const { dragging } = useDSLDragDrop({
|
|
onDSLFileDropped: handleDSLFileDropped,
|
|
containerRef,
|
|
enabled: canCreateApp,
|
|
})
|
|
|
|
const appListQuery = useMemo<AppListQuery>(
|
|
() => ({
|
|
page: 1,
|
|
limit: 30,
|
|
name: debouncedKeywords,
|
|
sort_by: sortBy,
|
|
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
|
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
|
|
...(category !== 'all' ? { mode: category } : {}),
|
|
}),
|
|
[category, creatorIDs, debouncedKeywords, sortBy, tagIDs],
|
|
)
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isFetching,
|
|
isFetchingNextPage,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
error,
|
|
refetch,
|
|
} = useInfiniteQuery({
|
|
...consoleQuery.apps.get.infiniteOptions({
|
|
input: (pageParam) => ({
|
|
query: {
|
|
...appListQuery,
|
|
page: Number(pageParam),
|
|
},
|
|
}),
|
|
getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined),
|
|
initialPageParam: 1,
|
|
placeholderData: keepPreviousData,
|
|
}),
|
|
select: (data) => ({
|
|
...data,
|
|
pages: data.pages.map(normalizeAppPagination),
|
|
}),
|
|
refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false,
|
|
})
|
|
|
|
const starredAppListQuery = useMemo<AppListQuery>(
|
|
() => ({
|
|
...appListQuery,
|
|
page: 1,
|
|
limit: STARRED_APP_LIMIT,
|
|
}),
|
|
[appListQuery],
|
|
)
|
|
|
|
const { data: starredAppList, refetch: refetchStarredAppList } = useQuery({
|
|
...consoleQuery.apps.starred.get.queryOptions({
|
|
input: {
|
|
query: starredAppListQuery,
|
|
},
|
|
select: normalizeAppPagination,
|
|
}),
|
|
})
|
|
|
|
const refreshAppLists = useCallback(() => {
|
|
void refetch()
|
|
void refetchStarredAppList()
|
|
}, [refetch, refetchStarredAppList])
|
|
|
|
useEffect(() => {
|
|
if (controlRefreshList > 0) refetch()
|
|
}, [controlRefreshList, refetch])
|
|
|
|
const anchorRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (needsRefreshAppList === '1') {
|
|
setNeedsRefreshAppList(null)
|
|
refetch()
|
|
}
|
|
}, [needsRefreshAppList, refetch, setNeedsRefreshAppList])
|
|
|
|
useEffect(() => {
|
|
const hasMore = hasNextPage ?? true
|
|
let observer: IntersectionObserver | undefined
|
|
|
|
if (error) {
|
|
if (observer) observer.disconnect()
|
|
return
|
|
}
|
|
|
|
if (anchorRef.current && containerRef.current) {
|
|
const containerHeight = containerRef.current.clientHeight
|
|
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
|
|
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
|
|
fetchNextPage()
|
|
},
|
|
{
|
|
root: containerRef.current,
|
|
rootMargin: `${dynamicMargin}px`,
|
|
threshold: 0.1,
|
|
},
|
|
)
|
|
observer.observe(anchorRef.current)
|
|
}
|
|
return () => observer?.disconnect()
|
|
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage])
|
|
|
|
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
|
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
|
const starredApps = useMemo(() => starredAppList?.data ?? [], [starredAppList?.data])
|
|
|
|
const workflowOnlineUserAppIds = useMemo(() => {
|
|
const appIds = new Set<string>()
|
|
apps.forEach((app) => {
|
|
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
|
|
appIds.add(app.id)
|
|
})
|
|
return Array.from(appIds)
|
|
}, [apps])
|
|
|
|
const { onlineUsersMap: workflowOnlineUsersMap } = useWorkflowOnlineUsers({
|
|
appIds: workflowOnlineUserAppIds,
|
|
enabled: systemFeatures.enable_collaboration_mode,
|
|
})
|
|
|
|
const hasResolvedFirstPage = pages.length > 0
|
|
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
|
const hasActiveFilters =
|
|
category !== 'all' ||
|
|
tagIDs.length > 0 ||
|
|
keywords.trim().length > 0 ||
|
|
debouncedKeywords.trim().length > 0 ||
|
|
creatorIDs.length > 0
|
|
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
|
const showFirstEmptyState =
|
|
!showSkeleton && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters
|
|
const openCreateBlankModal = useCallback(() => {
|
|
if (canCreateApp) setShowNewAppModal(true)
|
|
}, [canCreateApp])
|
|
const openCreateTemplateDialog = useCallback(() => {
|
|
if (canCreateApp) setShowNewAppTemplateDialog(true)
|
|
}, [canCreateApp])
|
|
const openCreateFromDSLModal = useCallback(() => {
|
|
if (canCreateApp) setShowCreateFromDSLModal(true)
|
|
}, [canCreateApp])
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={containerRef}
|
|
className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"
|
|
>
|
|
{dragging && (
|
|
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2"></div>
|
|
)}
|
|
|
|
<StudioListHeader
|
|
title={
|
|
<div className="flex items-center">
|
|
<h1 className="text-[18px]/[21.6px] font-semibold text-text-primary">
|
|
{t(($) => $['menus.apps'], { ns: 'common' })}
|
|
</h1>
|
|
</div>
|
|
}
|
|
>
|
|
<AppListHeaderFilters
|
|
category={category}
|
|
tagIDs={tagIDs}
|
|
keywords={keywords}
|
|
creatorIDs={creatorIDs}
|
|
sortBy={sortBy}
|
|
onCategoryChange={setCategory}
|
|
onTagIDsChange={setTagIDs}
|
|
onKeywordsChange={setKeywords}
|
|
onCreatorIDsChange={setCreatorIDs}
|
|
onSortByChange={setSortBy}
|
|
onCreateBlank={openCreateBlankModal}
|
|
onCreateTemplate={openCreateTemplateDialog}
|
|
onImportDSL={openCreateFromDSLModal}
|
|
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
|
showCreateButton={canCreateApp}
|
|
/>
|
|
</StudioListHeader>
|
|
{showFirstEmptyState ? (
|
|
<FirstEmptyState
|
|
onCreateBlank={openCreateBlankModal}
|
|
onCreateLearnDify={onCreateLearnDify}
|
|
onCreateTemplate={openCreateTemplateDialog}
|
|
onImportDSL={openCreateFromDSLModal}
|
|
onTryLearnDify={onTryLearnDify}
|
|
showLearnDify={systemFeatures.enable_learn_app}
|
|
/>
|
|
) : (
|
|
<>
|
|
{starredApps.length > 0 && (
|
|
<StarredAppList apps={starredApps} onRefresh={refreshAppLists} />
|
|
)}
|
|
<div
|
|
className={cn(
|
|
`relative grow content-start ${APP_LIST_GRID_CLASS_NAME}`,
|
|
!hasAnyApp && 'overflow-hidden',
|
|
)}
|
|
>
|
|
{showSkeleton ? (
|
|
<AppCardSkeleton count={6} />
|
|
) : hasAnyApp ? (
|
|
apps.map((app) => (
|
|
<AppCard
|
|
key={app.id}
|
|
app={app}
|
|
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
|
|
onRefresh={refreshAppLists}
|
|
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
|
/>
|
|
))
|
|
) : (
|
|
<Empty />
|
|
)}
|
|
{isFetchingNextPage && <AppCardSkeleton count={3} />}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{canCreateApp && !showFirstEmptyState && (
|
|
<div
|
|
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
|
|
role="region"
|
|
aria-label={t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })}
|
|
>
|
|
<span className="i-ri-drag-drop-line size-4" />
|
|
<span className="system-xs-regular">
|
|
{t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<CheckModal />
|
|
<div ref={anchorRef} className="h-0">
|
|
{' '}
|
|
</div>
|
|
<AppListTagManagementModal
|
|
show={showTagManagementModal}
|
|
onClose={() => setShowTagManagementModal(false)}
|
|
onTagsChange={refreshAppLists}
|
|
/>
|
|
</div>
|
|
|
|
<AppListCreationModals
|
|
canCreateApp={canCreateApp}
|
|
category={category}
|
|
droppedDSLFile={droppedDSLFile}
|
|
showCreateFromDSLModal={showCreateFromDSLModal}
|
|
showNewAppModal={showNewAppModal}
|
|
showNewAppTemplateDialog={showNewAppTemplateDialog}
|
|
onPlanInfoChanged={onPlanInfoChanged}
|
|
onRefetch={refreshAppLists}
|
|
onSetDroppedDSLFile={setDroppedDSLFile}
|
|
onSetShowCreateFromDSLModal={setShowCreateFromDSLModal}
|
|
onSetShowNewAppModal={setShowNewAppModal}
|
|
onSetShowNewAppTemplateDialog={setShowNewAppTemplateDialog}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default List
|