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

View File

@ -46,6 +46,8 @@
"editDone": "App info updated",
"editFailed": "Failed to update app info",
"export": "Export DSL",
"exportBundle": "Export Bundle",
"exportBundleFailed": "Export bundle failed.",
"exportFailed": "Export DSL failed.",
"gotoAnything.actions.accountDesc": "Navigate to account page",
"gotoAnything.actions.communityDesc": "Open Discord community",
@ -115,6 +117,7 @@
"iconPicker.emoji": "Emoji",
"iconPicker.image": "Image",
"iconPicker.ok": "OK",
"importBundleFailed": "Import bundle failed.",
"importDSL": "Import DSL file",
"importFromDSL": "Import from DSL",
"importFromDSLFile": "From DSL file",

View File

@ -46,6 +46,8 @@
"editDone": "应用信息已更新",
"editFailed": "更新应用信息失败",
"export": "导出 DSL",
"exportBundle": "导出 Bundle",
"exportBundleFailed": "导出 Bundle 失败",
"exportFailed": "导出 DSL 失败",
"gotoAnything.actions.accountDesc": "导航到账户页面",
"gotoAnything.actions.communityDesc": "打开 Discord 社区",
@ -115,6 +117,7 @@
"iconPicker.emoji": "表情符号",
"iconPicker.image": "图片",
"iconPicker.ok": "确认",
"importBundleFailed": "导入 Bundle 失败",
"importDSL": "导入 DSL 文件",
"importFromDSL": "导入 DSL",
"importFromDSLFile": "文件",

View File

@ -92,6 +92,44 @@ export const exportAppConfig = ({ appID, include = false, workflowID }: { appID:
return get<{ data: string }>(`apps/${appID}/export?${params.toString()}`)
}
export const exportAppBundle = async ({ appID, include = false, workflowID }: { appID: string, include?: boolean, workflowID?: string }): Promise<void> => {
const { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } = await import('@/config')
const Cookies = (await import('js-cookie')).default
const params = new URLSearchParams({
include_secret: include.toString(),
})
if (workflowID)
params.append('workflow_id', workflowID)
const url = `${API_PREFIX}/apps/${appID}/export-bundle?${params.toString()}`
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
},
})
if (!response.ok)
throw new Error('Export bundle failed')
const blob = await response.blob()
const contentDisposition = response.headers.get('content-disposition')
let filename = `app-${appID}.zip`
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/)
if (filenameMatch)
filename = filenameMatch[1]
}
const downloadUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = downloadUrl
a.download = filename
a.click()
URL.revokeObjectURL(downloadUrl)
}
export const importDSL = ({ mode, yaml_content, yaml_url, app_id, name, description, icon_type, icon, icon_background }: { mode: DSLImportMode, yaml_content?: string, yaml_url?: string, app_id?: string, name?: string, description?: string, icon_type?: AppIconType, icon?: string, icon_background?: string }): Promise<DSLImportResponse> => {
return post<DSLImportResponse>('apps/imports', { body: { mode, yaml_content, yaml_url, app_id, name, description, icon, icon_type, icon_background } })
}
@ -100,6 +138,54 @@ export const importDSLConfirm = ({ import_id }: { import_id: string }): Promise<
return post<DSLImportResponse>(`apps/imports/${import_id}/confirm`, { body: {} })
}
export const importAppBundle = async ({
file,
name,
description,
icon_type,
icon,
icon_background,
}: {
file: File
name?: string
description?: string
icon_type?: string
icon?: string
icon_background?: string
}): Promise<DSLImportResponse> => {
const { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } = await import('@/config')
const Cookies = (await import('js-cookie')).default
const formData = new FormData()
formData.append('file', file)
if (name)
formData.append('name', name)
if (description)
formData.append('description', description)
if (icon_type)
formData.append('icon_type', icon_type)
if (icon)
formData.append('icon', icon)
if (icon_background)
formData.append('icon_background', icon_background)
const response = await fetch(`${API_PREFIX}/apps/imports-bundle`, {
method: 'POST',
credentials: 'include',
headers: {
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
},
body: formData,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Import bundle failed')
}
return response.json()
}
export const switchApp = ({ appID, name, icon_type, icon, icon_background }: { appID: string, name: string, icon_type: AppIconType, icon: string, icon_background?: string | null }): Promise<{ new_app_id: string }> => {
return post<{ new_app_id: string }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon_type, icon, icon_background } })
}