feat(web): add app bundle import/export UI support

This commit is contained in:
Harry
2026-01-23 01:09:05 +08:00
parent cbac914649
commit 71f811930f
6 changed files with 193 additions and 3 deletions

View File

@ -27,7 +27,7 @@ import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { copyApp, deleteApp, exportAppBundle, exportAppConfig, updateAppInfo } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
@ -193,6 +193,39 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
}
}
const onExportBundle = async (include = false) => {
try {
await exportAppBundle({
appID: app.id,
include,
})
}
catch {
notify({ type: 'error', message: t('exportBundleFailed', { ns: 'app' }) })
}
}
const [secretEnvListForBundle, setSecretEnvListForBundle] = useState<EnvironmentVariable[]>([])
const exportBundleCheck = async () => {
if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) {
onExportBundle()
return
}
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
if (list.length === 0) {
onExportBundle()
return
}
setSecretEnvListForBundle(list)
}
catch {
notify({ type: 'error', message: t('exportBundleFailed', { ns: 'app' }) })
}
}
const onSwitch = () => {
if (onRefresh)
onRefresh()
@ -228,6 +261,12 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
e.preventDefault()
exportCheck()
}
const onClickExportBundle = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
exportBundleCheck()
}
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
@ -278,6 +317,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExport}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</button>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExportBundle}>
<span className="system-sm-regular text-text-secondary">{t('exportBundle', { ns: 'app' })}</span>
</button>
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
<>
<Divider className="my-1" />
@ -514,6 +556,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
onClose={() => setSecretEnvList([])}
/>
)}
{secretEnvListForBundle.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvListForBundle}
onConfirm={onExportBundle}
onClose={() => setSecretEnvListForBundle([])}
/>
)}
{showAccessControl && (
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
)}

View File

@ -1,12 +1,15 @@
import { useEffect, useState } from 'react'
export type DroppedFileType = 'dsl' | 'bundle'
type DSLDragDropHookProps = {
onDSLFileDropped: (file: File) => void
onBundleFileDropped?: (file: File) => void
containerRef: React.RefObject<HTMLDivElement | null>
enabled?: boolean
}
export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true }: DSLDragDropHookProps) => {
export const useDSLDragDrop = ({ onDSLFileDropped, onBundleFileDropped, containerRef, enabled = true }: DSLDragDropHookProps) => {
const [dragging, setDragging] = useState(false)
const handleDragEnter = (e: DragEvent) => {
@ -41,8 +44,14 @@ export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true
return
const file = files[0]
if (file.name.toLowerCase().endsWith('.yaml') || file.name.toLowerCase().endsWith('.yml'))
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.yaml') || fileName.endsWith('.yml')) {
onDSLFileDropped(file)
}
else if (fileName.endsWith('.zip') && onBundleFileDropped) {
onBundleFileDropped(file)
}
}
useEffect(() => {

View File

@ -17,17 +17,22 @@ import {
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { ToastContext } from '@/app/components/base/toast'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { DSLImportStatus } from '@/models/app'
import { importAppBundle } from '@/service/apps'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
@ -61,8 +66,10 @@ const List: FC<Props> = ({
controlRefreshList = 0,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
const { push } = useRouter()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState(
@ -90,8 +97,41 @@ const List: FC<Props> = ({
setShowCreateFromDSLModal(true)
}, [])
const [importingBundle, setImportingBundle] = useState(false)
const handleBundleFileDropped = useCallback(async (file: File) => {
if (importingBundle)
return
setImportingBundle(true)
try {
const response = await importAppBundle({ file })
const { status, app_id, app_mode } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
})
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
if (app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
}
else {
notify({ type: 'error', message: t('importBundleFailed', { ns: 'app' }) })
}
}
catch (e) {
const error = e as Error
notify({ type: 'error', message: error.message || t('importBundleFailed', { ns: 'app' }) })
}
finally {
setImportingBundle(false)
}
}, [importingBundle, isCurrentWorkspaceEditor, notify, push, t])
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
onBundleFileDropped: handleBundleFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
})