mirror of
https://github.com/langgenius/dify.git
synced 2026-06-01 06:28:14 +08:00
chore: app card ui
This commit is contained in:
@ -426,32 +426,32 @@ describe('AppCard', () => {
|
||||
})
|
||||
|
||||
describe('Access Mode Icons', () => {
|
||||
it('should show public icon for public access mode', () => {
|
||||
it('should not show public icon on the card', () => {
|
||||
const publicApp = { ...mockApp, access_mode: AccessMode.PUBLIC }
|
||||
const { container } = render(<AppCard app={publicApp} />)
|
||||
const tooltip = container.querySelector('[title="app.accessItemsDescription.anyone"]')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show lock icon for specific groups access mode', () => {
|
||||
it('should not show lock icon on the card', () => {
|
||||
const specificApp = { ...mockApp, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }
|
||||
const { container } = render(<AppCard app={specificApp} />)
|
||||
const tooltip = container.querySelector('[title="app.accessItemsDescription.specific"]')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show organization icon for organization access mode', () => {
|
||||
it('should not show organization icon on the card', () => {
|
||||
const orgApp = { ...mockApp, access_mode: AccessMode.ORGANIZATION }
|
||||
const { container } = render(<AppCard app={orgApp} />)
|
||||
const tooltip = container.querySelector('[title="app.accessItemsDescription.organization"]')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show external icon for external access mode', () => {
|
||||
it('should not show external icon on the card', () => {
|
||||
const externalApp = { ...mockApp, access_mode: AccessMode.EXTERNAL_MEMBERS }
|
||||
const { container } = render(<AppCard app={externalApp} />)
|
||||
const tooltip = container.querySelector('[title="app.accessItemsDescription.external"]')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -485,6 +485,8 @@ describe('AppCard', () => {
|
||||
render(<AppCard app={mockApp} />)
|
||||
const operationsTriggerWrapper = screen.getByTestId('dropdown-menu-trigger').closest('.absolute')
|
||||
|
||||
expect(operationsTriggerWrapper).toHaveClass('top-[-0.5px]')
|
||||
expect(operationsTriggerWrapper).toHaveClass('right-[-0.5px]')
|
||||
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:pointer-events-auto')
|
||||
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100')
|
||||
expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass('focus-visible:ring-1')
|
||||
|
||||
@ -12,12 +12,14 @@ type AppCardSkeletonProps = {
|
||||
* Matches the visual layout of AppCard component.
|
||||
*/
|
||||
export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) => {
|
||||
const skeletonKeys = Array.from({ length: count }, (_, index) => `app-card-skeleton-${index}`)
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
{skeletonKeys.map(key => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-[160px] rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4"
|
||||
key={key}
|
||||
className="h-[160px] overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs"
|
||||
>
|
||||
<SkeletonContainer className="h-full">
|
||||
<SkeletonRow>
|
||||
|
||||
@ -23,11 +23,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useId, useMemo, useState } from 'react'
|
||||
@ -41,7 +36,6 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { AppCardTags } from '@/features/tag-management/components/app-card-tags'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
@ -207,7 +201,7 @@ const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps>
|
||||
)
|
||||
}
|
||||
|
||||
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) => {
|
||||
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => { } }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const deleteAppNameInputId = useId()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -396,7 +390,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
|
||||
const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor
|
||||
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
|
||||
|
||||
const EditTimeText = useMemo(() => {
|
||||
const editTimeText = useMemo(() => {
|
||||
const timeText = formatTime({
|
||||
date: (app.updated_at || app.created_at) * 1000,
|
||||
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
|
||||
@ -404,6 +398,23 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
|
||||
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
|
||||
}, [app.updated_at, app.created_at, t])
|
||||
|
||||
const appModeLabel = useMemo(() => {
|
||||
switch (app.mode) {
|
||||
case AppModeEnum.CHAT:
|
||||
return t('types.chatbot', { ns: 'app' })
|
||||
case AppModeEnum.ADVANCED_CHAT:
|
||||
return t('types.advanced', { ns: 'app' })
|
||||
case AppModeEnum.AGENT_CHAT:
|
||||
return t('types.agent', { ns: 'app' })
|
||||
case AppModeEnum.COMPLETION:
|
||||
return t('types.completion', { ns: 'app' })
|
||||
case AppModeEnum.WORKFLOW:
|
||||
return t('types.workflow', { ns: 'app' })
|
||||
default:
|
||||
return app.mode
|
||||
}
|
||||
}, [app.mode, t])
|
||||
|
||||
const onlinePresenceUsers = useMemo(() => {
|
||||
return onlineUsers
|
||||
.map((user, index) => {
|
||||
@ -425,9 +436,9 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
|
||||
e.preventDefault()
|
||||
getRedirection(isCurrentWorkspaceEditor, app, push)
|
||||
}}
|
||||
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg"
|
||||
className="group relative col-span-1 inline-flex h-41.5 cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs transition-shadow duration-200 ease-in-out hover:shadow-lg"
|
||||
>
|
||||
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
|
||||
<div className="flex shrink-0 items-center gap-3 pt-4 pb-2 pl-4">
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
@ -438,152 +449,115 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
|
||||
/>
|
||||
<AppTypeIcon type={app.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm" className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="w-0 grow py-px">
|
||||
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
|
||||
<div className="flex w-0 grow flex-col gap-1 py-px">
|
||||
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
|
||||
<div className="truncate" title={app.name}>{app.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] leading-[18px] font-medium text-text-tertiary">
|
||||
<div className="truncate" title={app.author_name}>{app.author_name}</div>
|
||||
<div>·</div>
|
||||
<div className="truncate" title={EditTimeText}>{EditTimeText}</div>
|
||||
</div>
|
||||
<div className="truncate system-2xs-medium-uppercase text-text-tertiary" title={appModeLabel}>{appModeLabel}</div>
|
||||
</div>
|
||||
<div className="flex h-full shrink-0 flex-col items-end justify-between py-px">
|
||||
{onlinePresenceUsers.length > 0 && (
|
||||
{onlinePresenceUsers.length > 0 && (
|
||||
<div className="ml-3 flex h-10 shrink-0 flex-col items-end">
|
||||
<UserAvatarList users={onlinePresenceUsers} size="xxs" maxVisible={3} className="justify-end" />
|
||||
)}
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
{app.access_mode === AccessMode.PUBLIC && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-label={t('accessItemsDescription.anyone', { ns: 'app' })}
|
||||
render={<span title={t('accessItemsDescription.anyone', { ns: 'app' })} className="i-ri-global-line h-4 w-4 text-text-quaternary" />}
|
||||
/>
|
||||
<TooltipContent>{t('accessItemsDescription.anyone', { ns: 'app' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-label={t('accessItemsDescription.specific', { ns: 'app' })}
|
||||
render={<span title={t('accessItemsDescription.specific', { ns: 'app' })} className="i-ri-lock-line h-4 w-4 text-text-quaternary" />}
|
||||
/>
|
||||
<TooltipContent>{t('accessItemsDescription.specific', { ns: 'app' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{app.access_mode === AccessMode.ORGANIZATION && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-label={t('accessItemsDescription.organization', { ns: 'app' })}
|
||||
render={<span title={t('accessItemsDescription.organization', { ns: 'app' })} className="i-ri-building-line h-4 w-4 text-text-quaternary" />}
|
||||
/>
|
||||
<TooltipContent>{t('accessItemsDescription.organization', { ns: 'app' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{app.access_mode === AccessMode.EXTERNAL_MEMBERS && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-label={t('accessItemsDescription.external', { ns: 'app' })}
|
||||
render={<span title={t('accessItemsDescription.external', { ns: 'app' })} className="i-ri-verified-badge-line h-4 w-4 text-text-quaternary" />}
|
||||
/>
|
||||
<TooltipContent>{t('accessItemsDescription.external', { ns: 'app' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div className="shrink-0 px-4 py-1 system-xs-regular text-text-tertiary">
|
||||
<div
|
||||
className="line-clamp-2"
|
||||
className="line-clamp-2 min-h-8"
|
||||
title={app.description}
|
||||
>
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
|
||||
<div className="flex h-[26px] shrink-0 items-start px-3">
|
||||
{isCurrentWorkspaceEditor && (
|
||||
<>
|
||||
<div
|
||||
className={cn('flex w-0 grow items-center gap-1')}
|
||||
<div
|
||||
className="w-full min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<AppCardTags
|
||||
appId={app.id}
|
||||
tags={app.tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-3 pb-3 pl-4 system-xs-regular text-text-tertiary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
|
||||
<div className="truncate" title={app.author_name}>{app.author_name}</div>
|
||||
<div className="shrink-0">·</div>
|
||||
<div className="truncate" title={editTimeText}>{editTimeText}</div>
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-[-0.5px] right-[-0.5px] flex h-16 w-[120px] items-start justify-end bg-linear-to-r from-components-card-bg-alt-transparent to-components-card-bg-alt p-2 transition-opacity',
|
||||
isOperationsMenuOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 backdrop-blur-xs hover:bg-components-actionbar-bg focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
|
||||
isOperationsMenuOpen ? 'shadow-none' : 'shadow-lg',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="mr-[41px] min-w-0 grow overflow-hidden">
|
||||
<AppCardTags
|
||||
appId={app.id}
|
||||
tags={app.tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onRefresh}
|
||||
/>
|
||||
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover">
|
||||
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
|
||||
<span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
|
||||
isOperationsMenuOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName={operationsMenuWidthClassName}
|
||||
>
|
||||
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
|
||||
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
|
||||
{systemFeatures.webapp_auth.enabled
|
||||
? (
|
||||
<AppCardOperationsMenuContent
|
||||
app={app}
|
||||
shouldShowSwitchOption={shouldShowSwitchOption}
|
||||
shouldShowAccessControlOption={shouldShowAccessControlOption}
|
||||
onEdit={handleShowEditModal}
|
||||
onDuplicate={handleShowDuplicateModal}
|
||||
onExport={exportCheck}
|
||||
onSwitch={handleShowSwitchModal}
|
||||
onDelete={handleShowDeleteConfirm}
|
||||
onAccessControl={handleShowAccessControl}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AppCardOperationsMenu
|
||||
app={app}
|
||||
shouldShowSwitchOption={shouldShowSwitchOption}
|
||||
shouldShowOpenInExploreOption={!app.has_draft_trigger}
|
||||
shouldShowAccessControlOption={shouldShowAccessControlOption}
|
||||
onEdit={handleShowEditModal}
|
||||
onDuplicate={handleShowDuplicateModal}
|
||||
onExport={exportCheck}
|
||||
onSwitch={handleShowSwitchModal}
|
||||
onDelete={handleShowDeleteConfirm}
|
||||
onAccessControl={handleShowAccessControl}
|
||||
/>
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
|
||||
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName={operationsMenuWidthClassName}
|
||||
>
|
||||
{systemFeatures.webapp_auth.enabled
|
||||
? (
|
||||
<AppCardOperationsMenuContent
|
||||
app={app}
|
||||
shouldShowSwitchOption={shouldShowSwitchOption}
|
||||
shouldShowAccessControlOption={shouldShowAccessControlOption}
|
||||
onEdit={handleShowEditModal}
|
||||
onDuplicate={handleShowDuplicateModal}
|
||||
onExport={exportCheck}
|
||||
onSwitch={handleShowSwitchModal}
|
||||
onDelete={handleShowDeleteConfirm}
|
||||
onAccessControl={handleShowAccessControl}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AppCardOperationsMenu
|
||||
app={app}
|
||||
shouldShowSwitchOption={shouldShowSwitchOption}
|
||||
shouldShowOpenInExploreOption={!app.has_draft_trigger}
|
||||
shouldShowAccessControlOption={shouldShowAccessControlOption}
|
||||
onEdit={handleShowEditModal}
|
||||
onDuplicate={handleShowDuplicateModal}
|
||||
onExport={exportCheck}
|
||||
onSwitch={handleShowSwitchModal}
|
||||
onDelete={handleShowDeleteConfirm}
|
||||
onAccessControl={handleShowAccessControl}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showEditModal && (
|
||||
<EditAppModal
|
||||
|
||||
@ -1 +1,123 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
export const APP_LIST_SEARCH_DEBOUNCE_MS = 500
|
||||
|
||||
const mockAppBase = {
|
||||
icon_type: 'emoji',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
enable_site: true,
|
||||
enable_api: true,
|
||||
api_rpm: 0,
|
||||
api_rph: 0,
|
||||
is_demo: false,
|
||||
model_config: {} as App['model_config'],
|
||||
app_model_config: {} as App['app_model_config'],
|
||||
site: {} as App['site'],
|
||||
api_base_url: '',
|
||||
tags: [],
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
max_active_requests: null,
|
||||
has_draft_trigger: false,
|
||||
} satisfies Partial<App>
|
||||
|
||||
export const MOCK_APP_LIST: App[] = [
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-content-brief',
|
||||
name: 'Content Brief Builder',
|
||||
description: 'Drafts campaign briefs, audience notes, and channel-specific launch copy for marketing teams.',
|
||||
author_name: 'Joel',
|
||||
icon: '📝',
|
||||
icon_background: '#EEF4FF',
|
||||
mode: AppModeEnum.CHAT,
|
||||
created_at: 1778640000,
|
||||
updated_at: 1778640000,
|
||||
},
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-sales-research',
|
||||
name: 'Sales Research Copilot',
|
||||
description: 'Summarizes accounts, finds buying signals, and prepares concise call notes before customer meetings.',
|
||||
author_name: 'Evan',
|
||||
icon: '🔎',
|
||||
icon_background: '#ECFDF3',
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
created_at: 1778636400,
|
||||
updated_at: 1778636400,
|
||||
},
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-contract-review',
|
||||
name: 'Contract Review Flow',
|
||||
description: 'Checks uploaded agreements for unusual clauses, missing terms, and follow-up questions.',
|
||||
author_name: 'Sarah',
|
||||
icon: '📄',
|
||||
icon_background: '#FDF2FA',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
created_at: 1778632800,
|
||||
updated_at: 1778632800,
|
||||
},
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-support-router',
|
||||
name: 'Support Ticket Router',
|
||||
description: 'Classifies incoming support requests and routes them to the right queue with priority labels.',
|
||||
author_name: 'Maya',
|
||||
icon: '🎧',
|
||||
icon_background: '#E0F2FE',
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
created_at: 1778629200,
|
||||
updated_at: 1778629200,
|
||||
},
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-report-summarizer',
|
||||
name: 'Report Summarizer',
|
||||
description: 'Turns long operational reports into concise executive summaries with highlights and risks.',
|
||||
author_name: 'Dify',
|
||||
icon: '📊',
|
||||
icon_background: '#FFF7ED',
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
created_at: 1778625600,
|
||||
updated_at: 1778625600,
|
||||
},
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-product-feedback',
|
||||
name: 'Product Feedback Miner',
|
||||
description: 'Clusters customer feedback, extracts recurring requests, and drafts product insight digests.',
|
||||
author_name: 'Nora',
|
||||
icon: '💬',
|
||||
icon_background: '#F4F3FF',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
created_at: 1778622000,
|
||||
updated_at: 1778622000,
|
||||
},
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-data-cleanup',
|
||||
name: 'Data Cleanup Assistant',
|
||||
description: 'Normalizes messy CSV columns, suggests validation rules, and explains transformation steps.',
|
||||
author_name: 'Leo',
|
||||
icon: '🧹',
|
||||
icon_background: '#F0FDF4',
|
||||
mode: AppModeEnum.CHAT,
|
||||
created_at: 1778618400,
|
||||
updated_at: 1778618400,
|
||||
},
|
||||
{
|
||||
...mockAppBase,
|
||||
id: 'mock-app-onboarding-guide',
|
||||
name: 'Onboarding Guide',
|
||||
description: 'Creates role-specific onboarding checklists and answers common first-week questions.',
|
||||
author_name: 'Ivy',
|
||||
icon: '🧭',
|
||||
icon_background: '#FFFBEB',
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
created_at: 1778614800,
|
||||
updated_at: 1778614800,
|
||||
},
|
||||
]
|
||||
|
||||
@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { IS_DEV, NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
@ -20,7 +20,7 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS, MOCK_APP_LIST } from './constants'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
@ -163,7 +163,29 @@ const List: FC<Props> = ({
|
||||
}, [isCreatedByMe, setIsCreatedByMe])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
const mockApps = useMemo(() => {
|
||||
if (!IS_DEV)
|
||||
return []
|
||||
if (tagIDs.length)
|
||||
return []
|
||||
|
||||
const normalizedKeywords = debouncedKeywords.trim().toLowerCase()
|
||||
|
||||
return MOCK_APP_LIST.filter((app) => {
|
||||
if (category !== 'all' && app.mode !== category)
|
||||
return false
|
||||
if (!normalizedKeywords)
|
||||
return true
|
||||
|
||||
return [app.name, app.description, app.author_name].some(value =>
|
||||
value.toLowerCase().includes(normalizedKeywords),
|
||||
)
|
||||
})
|
||||
}, [category, debouncedKeywords, tagIDs.length])
|
||||
const apps = useMemo(() => [
|
||||
...pages.flatMap(({ data: pageApps }) => pageApps),
|
||||
...mockApps,
|
||||
], [mockApps, pages])
|
||||
|
||||
const workflowOnlineUserAppIds = useMemo(() => {
|
||||
const appIds = new Set<string>()
|
||||
@ -181,7 +203,7 @@ const List: FC<Props> = ({
|
||||
enabled: systemFeatures.enable_collaboration_mode,
|
||||
})
|
||||
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0 || mockApps.length > 0
|
||||
// Show skeleton during initial load or when refetching with no previous data
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
|
||||
@ -221,7 +243,7 @@ const List: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
'relative grid grow grid-cols-1 content-start gap-3 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
!hasAnyApp && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -68,7 +68,7 @@ const CreateAppCard = ({
|
||||
<div
|
||||
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 transition-opacity',
|
||||
'relative col-span-1 inline-flex h-41.5 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,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user