Merge branch 'main' into feat/summary-index

This commit is contained in:
zxhlyh
2026-01-19 10:21:40 +08:00
44 changed files with 5968 additions and 778 deletions

View File

@ -65,15 +65,17 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
<div className="text-xs text-text-secondary">
{t('overview.disableTooltip.triggerMode', { ns: 'appOverview', feature: featureName })}
</div>
<div
className="cursor-pointer text-xs font-medium text-text-accent hover:underline"
<a
href={triggerDocUrl}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer text-xs font-medium text-text-accent hover:underline"
onClick={(event) => {
event.stopPropagation()
window.open(triggerDocUrl, '_blank')
}}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</a>
</div>
), [t, triggerDocUrl])

View File

@ -3,9 +3,7 @@ import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode, SubjectType } from '@/models/access-control'
import { defaultSystemFeatures } from '@/types/feature'
import Toast from '../../base/toast'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
@ -105,22 +103,6 @@ const memberSubject: Subject = {
accountData: baseMember,
} as Subject
const resetAccessControlStore = () => {
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
}
const resetGlobalStore = () => {
useGlobalPublicStore.setState({
systemFeatures: defaultSystemFeatures,
})
}
beforeAll(() => {
class MockIntersectionObserver {
observe = vi.fn(() => undefined)
@ -132,9 +114,6 @@ beforeAll(() => {
})
beforeEach(() => {
vi.clearAllMocks()
resetAccessControlStore()
resetGlobalStore()
mockMutateAsync.mockResolvedValue(undefined)
mockUseUpdateAccessMode.mockReturnValue({
isPending: false,

View File

@ -189,6 +189,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
}
</div>
))}
{isFetchingNextPage && <Loading />}
</div>
</>
)}

View File

@ -21,15 +21,15 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
>
<SkeletonContainer className="h-full">
<SkeletonRow>
<SkeletonRectangle className="h-10 w-10 rounded-lg" />
<SkeletonRectangle className="h-10 w-10 animate-pulse 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" />
<SkeletonRectangle className="h-4 w-2/3 animate-pulse" />
<SkeletonRectangle className="h-3 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
<div className="mt-4 flex flex-col gap-2">
<SkeletonRectangle className="h-3 w-full" />
<SkeletonRectangle className="h-3 w-4/5" />
<SkeletonRectangle className="h-3 w-full animate-pulse" />
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
</div>
</SkeletonContainer>
</div>

View File

@ -248,6 +248,9 @@ const List = () => {
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isCurrentWorkspaceEditor && (

View File

@ -1,21 +1,25 @@
'use client'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import './style.css'
type ILoadingProps = {
type?: 'area' | 'app'
className?: string
}
const Loading = (
{ type = 'area' }: ILoadingProps = { type: 'area' },
) => {
const Loading = (props?: ILoadingProps) => {
const { type = 'area', className } = props || {}
const { t } = useTranslation()
return (
<div
className={`flex w-full items-center justify-center ${type === 'app' ? 'h-full' : ''}`}
className={cn(
'flex w-full items-center justify-center',
type === 'app' && 'h-full',
className,
)}
role="status"
aria-live="polite"
aria-label={t('loading', { ns: 'appApi' })}
@ -37,4 +41,5 @@ const Loading = (
</div>
)
}
export default Loading

View File

@ -20,6 +20,7 @@ const SearchInput: FC<SearchInputProps> = ({
white,
}) => {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const [focus, setFocus] = useState<boolean>(false)
const isComposing = useRef<boolean>(false)
const [compositionValue, setCompositionValue] = useState<string>('')
@ -36,6 +37,7 @@ const SearchInput: FC<SearchInputProps> = ({
<RiSearchLine className="h-4 w-4 text-components-input-text-placeholder" aria-hidden="true" />
</div>
<input
ref={inputRef}
type="text"
name="query"
className={cn(
@ -65,14 +67,17 @@ const SearchInput: FC<SearchInputProps> = ({
autoComplete="off"
/>
{value && (
<div
className="group/clear flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center"
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0"
onClick={() => {
onChange('')
inputRef.current?.focus()
}}
>
<RiCloseCircleFill className="h-4 w-4 text-text-quaternary group-hover/clear:text-text-tertiary" />
</div>
</button>
)}
</div>
)

View File

@ -447,6 +447,10 @@ const Completed: FC<ICompletedProps> = ({
setFullScreen(!fullScreen)
}, [fullScreen])
const toggleCollapsed = useCallback(() => {
setIsCollapsed(prev => !prev)
}, [])
const viewNewlyAddedChunk = useCallback(async () => {
const totalPages = segmentListData?.total_pages || 0
const total = segmentListData?.total || 0
@ -583,15 +587,16 @@ const Completed: FC<ICompletedProps> = ({
return selectedStatus ? 1 : 0
}, [selectedStatus])
const contextValue = useMemo<SegmentListContextValue>(() => ({
isCollapsed,
fullScreen,
toggleFullScreen,
currSegment,
currChildChunk,
}), [isCollapsed, fullScreen, toggleFullScreen, currSegment, currChildChunk])
return (
<SegmentListContext.Provider value={{
isCollapsed,
fullScreen,
toggleFullScreen,
currSegment,
currChildChunk,
}}
>
<SegmentListContext.Provider value={contextValue}>
{/* Menu Bar */}
{!isFullDocMode && (
<div className={s.docSearchWrapper}>
@ -623,7 +628,7 @@ const Completed: FC<ICompletedProps> = ({
onClear={() => handleInputChange('')}
/>
<Divider type="vertical" className="mx-3 h-3.5" />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={() => setIsCollapsed(!isCollapsed)} />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={toggleCollapsed} />
</div>
)}
{/* Segment list */}

View File

@ -1,4 +1,5 @@
import type { FC } from 'react'
import type { SegmentListContextValue } from '..'
import * as React from 'react'
import { Markdown } from '@/app/components/base/markdown'
import { cn } from '@/utils/classnames'
@ -14,13 +15,15 @@ type ChunkContentProps = {
className?: string
}
const selectIsCollapsed = (s: SegmentListContextValue) => s.isCollapsed
const ChunkContent: FC<ChunkContentProps> = ({
detail,
isFullDocMode,
className,
}) => {
const { answer, content, sign_content } = detail
const isCollapsed = useSegmentListContext(s => s.isCollapsed)
const isCollapsed = useSegmentListContext(selectIsCollapsed)
if (answer) {
return (

View File

@ -2,6 +2,7 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import DatasetCard from './dataset-card'
@ -25,6 +26,7 @@ const Datasets = ({
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useDatasetList({
initialPage: 1,
tag_ids: tags,
@ -60,6 +62,7 @@ const Datasets = ({
{datasetList?.pages.map(({ data: datasets }) => datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} />),
))}
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</nav>
</>

View File

@ -33,6 +33,7 @@ const AppNav = () => {
data: appsData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteAppList({
page: 1,
@ -111,6 +112,7 @@ const AppNav = () => {
createText={t('menus.newApp', { ns: 'common' })}
onCreate={openModal}
onLoadMore={handleLoadMore}
isLoadingMore={isFetchingNextPage}
/>
<CreateAppModal
show={showNewAppDialog}

View File

@ -23,6 +23,7 @@ const DatasetNav = () => {
data: datasetList,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useDatasetList({
initialPage: 1,
limit: 30,
@ -93,6 +94,7 @@ const DatasetNav = () => {
createText={t('menus.newDataset', { ns: 'common' })}
onCreate={() => router.push(createRoute)}
onLoadMore={handleLoadMore}
isLoadingMore={isFetchingNextPage}
/>
)
}

View File

@ -30,6 +30,7 @@ const Nav = ({
createText,
onCreate,
onLoadMore,
isLoadingMore,
isApp,
}: INavProps) => {
const setAppDetail = useAppStore(state => state.setAppDetail)
@ -81,6 +82,7 @@ const Nav = ({
createText={createText}
onCreate={onCreate}
onLoadMore={onLoadMore}
isLoadingMore={isLoadingMore}
/>
</>
)

View File

@ -14,6 +14,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { cn } from '@/utils/classnames'
@ -34,9 +35,10 @@ export type INavSelectorProps = {
isApp?: boolean
onCreate: (state: string) => void
onLoadMore?: () => void
isLoadingMore?: boolean
}
const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onLoadMore }: INavSelectorProps) => {
const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onLoadMore, isLoadingMore }: INavSelectorProps) => {
const { t } = useTranslation()
const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
@ -106,6 +108,11 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
</MenuItem>
))
}
{isLoadingMore && (
<div className="flex justify-center py-2">
<Loading />
</div>
)}
</div>
{!isApp && isCurrentWorkspaceEditor && (
<MenuItem as="div" className="w-full p-1">

View File

@ -19,6 +19,7 @@ const ListWrapper = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
isLoading,
isFetchingNextPage,
page,
} = useMarketplaceData()
@ -53,6 +54,11 @@ const ListWrapper = ({
/>
)
}
{
isFetchingNextPage && (
<Loading className="my-3" />
)
}
</div>
)
}

View File

@ -33,7 +33,7 @@ export function useMarketplaceData() {
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
const pluginsQuery = useMarketplacePlugins(queryParams)
const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery
const handlePageChange = useCallback(() => {
if (hasNextPage && !isFetching)
@ -50,5 +50,6 @@ export function useMarketplaceData() {
pluginsTotal: pluginsQuery.data?.pages[0]?.total,
page: pluginsQuery.data?.pages.length || 1,
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
isFetchingNextPage,
}
}

View File

@ -144,17 +144,6 @@ describe('constant.ts - Type Definitions', () => {
// ==================== store.ts Tests ====================
describe('store.ts - Zustand Store', () => {
beforeEach(() => {
// Reset store to initial state
const { setState } = useStore
setState({
tagList: [],
categoryList: [],
showTagManagementModal: false,
showCategoryManagementModal: false,
})
})
describe('Initial State', () => {
it('should have empty tagList initially', () => {
const { result } = renderHook(() => useStore(state => state.tagList))

View File

@ -5,11 +5,11 @@ import { useDebounceFn } from 'ahooks'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { useGetLanguage } from '@/context/i18n'
import { renderI18nObject } from '@/i18n-config'
import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import Loading from '../../base/loading'
import { PluginSource } from '../types'
import { usePluginPageContext } from './context'
import Empty from './empty'
@ -107,12 +107,17 @@ const PluginsPanel = () => {
<div className="w-full">
<List pluginList={filteredList || []} />
</div>
{!isLastPage && !isFetching && (
<Button onClick={loadNextPage}>
{t('common.loadMore', { ns: 'workflow' })}
</Button>
{!isLastPage && (
<div className="flex justify-center py-4">
{isFetching
? <Loading className="size-8" />
: (
<Button onClick={loadNextPage}>
{t('common.loadMore', { ns: 'workflow' })}
</Button>
)}
</div>
)}
{isFetching && <div className="system-md-semibold text-text-secondary">{t('detail.loading', { ns: 'appLog' })}</div>}
</div>
)
: (

View File

@ -134,13 +134,6 @@ describe('BUILTIN_TOOLS_ARRAY', () => {
// Store Tests
// ================================
describe('useReadmePanelStore', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset store state before each test
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail()
})
describe('Initial State', () => {
it('should have undefined currentPluginDetail initially', () => {
const { currentPluginDetail } = useReadmePanelStore.getState()
@ -228,13 +221,6 @@ describe('useReadmePanelStore', () => {
// ReadmeEntrance Component Tests
// ================================
describe('ReadmeEntrance', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset store state
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail()
})
// ================================
// Rendering Tests
// ================================
@ -417,11 +403,6 @@ describe('ReadmeEntrance', () => {
// ================================
describe('ReadmePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset store state
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail()
// Reset mock
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,

View File

@ -5,6 +5,7 @@ import type { NodeOutPutVar, Variable } from '@/app/components/workflow/types'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { cn } from '@/utils/classnames'
@ -147,7 +148,7 @@ const CodeEditor: FC<Props> = ({
onMount={onEditorMounted}
placeholder={t('common.jinjaEditorPlaceholder', { ns: 'workflow' })!}
/>
{isShowVarPicker && (
{isShowVarPicker && createPortal(
<div
ref={popupRef}
className="w-[228px] space-y-1 rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg"
@ -164,7 +165,8 @@ const CodeEditor: FC<Props> = ({
onChange={handleSelectVar}
isSupportFileVar={false}
/>
</div>
</div>,
document.body,
)}
</div>
)