Browse Source

feat(frontend): workflow import dsl from url (#6286)

zxhlyh 9 months ago
parent
commit
9a536979ab

+ 26 - 4
web/app/(commonLayout)/apps/NewAppCard.tsx

@@ -1,10 +1,14 @@
 'use client'
 
-import { forwardRef, useState } from 'react'
+import { forwardRef, useMemo, useState } from 'react'
+import {
+  useRouter,
+  useSearchParams,
+} from 'next/navigation'
 import { useTranslation } from 'react-i18next'
 import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
 import CreateAppModal from '@/app/components/app/create-app-modal'
-import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
+import CreateFromDSLModal, { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
 import { useProviderContext } from '@/context/provider-context'
 import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
 
@@ -16,10 +20,21 @@ export type CreateAppCardProps = {
 const CreateAppCard = forwardRef<HTMLAnchorElement, CreateAppCardProps>(({ onSuccess }, ref) => {
   const { t } = useTranslation()
   const { onPlanInfoChanged } = useProviderContext()
+  const searchParams = useSearchParams()
+  const { replace } = useRouter()
+  const dslUrl = searchParams.get('remoteInstallUrl') || undefined
 
   const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
   const [showNewAppModal, setShowNewAppModal] = useState(false)
-  const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
+  const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(!!dslUrl)
+
+  const activeTab = useMemo(() => {
+    if (dslUrl)
+      return CreateFromDSLModalTab.FROM_URL
+
+    return undefined
+  }, [dslUrl])
+
   return (
     <a
       ref={ref}
@@ -65,7 +80,14 @@ const CreateAppCard = forwardRef<HTMLAnchorElement, CreateAppCardProps>(({ onSuc
       />
       <CreateFromDSLModal
         show={showCreateFromDSLModal}
-        onClose={() => setShowCreateFromDSLModal(false)}
+        onClose={() => {
+          setShowCreateFromDSLModal(false)
+
+          if (dslUrl)
+            replace('/')
+        }}
+        activeTab={activeTab}
+        dslUrl={dslUrl}
         onSuccess={() => {
           onPlanInfoChanged()
           if (onSuccess)

+ 114 - 19
web/app/components/app/create-from-dsl-modal/index.tsx

@@ -1,7 +1,7 @@
 'use client'
 
 import type { MouseEventHandler } from 'react'
-import { useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useContext } from 'use-context-selector'
 import { useTranslation } from 'react-i18next'
@@ -10,25 +10,38 @@ import Uploader from './uploader'
 import Button from '@/app/components/base/button'
 import Modal from '@/app/components/base/modal'
 import { ToastContext } from '@/app/components/base/toast'
-import { importApp } from '@/service/apps'
+import {
+  importApp,
+  importAppFromUrl,
+} from '@/service/apps'
 import { useAppContext } from '@/context/app-context'
 import { useProviderContext } from '@/context/provider-context'
 import AppsFull from '@/app/components/billing/apps-full-in-dialog'
 import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
 import { getRedirection } from '@/utils/app-redirection'
+import cn from '@/utils/classnames'
 
 type CreateFromDSLModalProps = {
   show: boolean
   onSuccess?: () => void
   onClose: () => void
+  activeTab?: string
+  dslUrl?: string
 }
 
-const CreateFromDSLModal = ({ show, onSuccess, onClose }: CreateFromDSLModalProps) => {
+export enum CreateFromDSLModalTab {
+  FROM_FILE = 'from-file',
+  FROM_URL = 'from-url',
+}
+
+const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '' }: CreateFromDSLModalProps) => {
   const { push } = useRouter()
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
   const [currentFile, setDSLFile] = useState<File>()
   const [fileContent, setFileContent] = useState<string>()
+  const [currentTab, setCurrentTab] = useState(activeTab)
+  const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
 
   const readFile = (file: File) => {
     const reader = new FileReader()
@@ -53,15 +66,26 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose }: CreateFromDSLModalProp
 
   const isCreatingRef = useRef(false)
   const onCreate: MouseEventHandler = async () => {
+    if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
+      return
+    if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
+      return
     if (isCreatingRef.current)
       return
     isCreatingRef.current = true
-    if (!currentFile)
-      return
     try {
-      const app = await importApp({
-        data: fileContent || '',
-      })
+      let app
+
+      if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
+        app = await importApp({
+          data: fileContent || '',
+        })
+      }
+      if (currentTab === CreateFromDSLModalTab.FROM_URL) {
+        app = await importAppFromUrl({
+          url: dslUrlValue || '',
+        })
+      }
       if (onSuccess)
         onSuccess()
       if (onClose)
@@ -76,24 +100,95 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose }: CreateFromDSLModalProp
     isCreatingRef.current = false
   }
 
+  const tabs = [
+    {
+      key: CreateFromDSLModalTab.FROM_FILE,
+      label: t('app.importFromDSLFile'),
+    },
+    {
+      key: CreateFromDSLModalTab.FROM_URL,
+      label: t('app.importFromDSLUrl'),
+    },
+  ]
+
+  const buttonDisabled = useMemo(() => {
+    if (isAppsFull)
+      return true
+    if (currentTab === CreateFromDSLModalTab.FROM_FILE)
+      return !currentFile
+    if (currentTab === CreateFromDSLModalTab.FROM_URL)
+      return !dslUrlValue
+    return false
+  }, [isAppsFull, currentTab, currentFile, dslUrlValue])
+
   return (
     <Modal
-      className='px-8 py-6 max-w-[520px] w-[520px] rounded-xl'
+      className='p-0 w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
       isShow={show}
       onClose={() => { }}
     >
-      <div className='relative pb-2 text-xl font-medium leading-[30px] text-gray-900'>{t('app.createFromConfigFile')}</div>
-      <div className='absolute right-4 top-4 p-2 cursor-pointer' onClick={onClose}>
-        <RiCloseLine className='w-4 h-4 text-gray-500' />
+      <div className='flex items-center justify-between pt-6 pl-6 pr-5 pb-3 text-text-primary title-2xl-semi-bold'>
+        {t('app.importFromDSL')}
+        <div
+          className='flex items-center w-8 h-8 cursor-pointer'
+          onClick={() => onClose()}
+        >
+          <RiCloseLine className='w-5 h-5 text-text-tertiary' />
+        </div>
+      </div>
+      <div className='flex items-center px-6 h-9 space-x-6 system-md-semibold text-text-tertiary border-b border-divider-subtle'>
+        {
+          tabs.map(tab => (
+            <div
+              key={tab.key}
+              className={cn(
+                'relative flex items-center h-full cursor-pointer',
+                currentTab === tab.key && 'text-text-primary',
+              )}
+              onClick={() => setCurrentTab(tab.key)}
+            >
+              {tab.label}
+              {
+                currentTab === tab.key && (
+                  <div className='absolute bottom-0 w-full h-[2px] bg-util-colors-blue-brand-blue-brand-600'></div>
+                )
+              }
+            </div>
+          ))
+        }
+      </div>
+      <div className='px-6 py-4'>
+        {
+          currentTab === CreateFromDSLModalTab.FROM_FILE && (
+            <Uploader
+              className='mt-0'
+              file={currentFile}
+              updateFile={handleFile}
+            />
+          )
+        }
+        {
+          currentTab === CreateFromDSLModalTab.FROM_URL && (
+            <div>
+              <div className='mb-1 system-md-semibold leading6'>DSL URL</div>
+              <input
+                placeholder={t('app.importFromDSLUrlPlaceholder') || ''}
+                className='px-2 w-full h-8 border border-components-input-border-active bg-components-input-bg-active rounded-lg outline-none appearance-none placeholder:text-components-input-text-placeholder system-sm-regular'
+                value={dslUrlValue}
+                onChange={e => setDslUrlValue(e.target.value)}
+              />
+            </div>
+          )
+        }
       </div>
-      <Uploader
-        file={currentFile}
-        updateFile={handleFile}
-      />
-      {isAppsFull && <AppsFull loc='app-create-dsl' />}
-      <div className='pt-6 flex justify-end'>
+      {isAppsFull && (
+        <div className='px-6'>
+          <AppsFull className='mt-0' loc='app-create-dsl' />
+        </div>
+      )}
+      <div className='flex justify-end px-6 py-5'>
         <Button className='mr-2' onClick={onClose}>{t('app.newApp.Cancel')}</Button>
-        <Button disabled={isAppsFull || !currentFile} variant="primary" onClick={onCreate}>{t('app.newApp.Create')}</Button>
+        <Button disabled={buttonDisabled} variant="primary" onClick={onCreate}>{t('app.newApp.Create')}</Button>
       </div>
     </Modal>
   )

+ 6 - 2
web/app/components/billing/apps-full-in-dialog/index.tsx

@@ -8,14 +8,18 @@ import s from './style.module.css'
 import cn from '@/utils/classnames'
 import GridMask from '@/app/components/base/grid-mask'
 
-const AppsFull: FC<{ loc: string }> = ({
+const AppsFull: FC<{ loc: string; className?: string }> = ({
   loc,
+  className,
 }) => {
   const { t } = useTranslation()
 
   return (
     <GridMask wrapperClassName='rounded-lg' canvasClassName='rounded-lg' gradientClassName='rounded-lg'>
-      <div className='mt-6 px-3.5 py-4 border-2 border-solid border-transparent rounded-lg shadow-md flex flex-col transition-all duration-200 ease-in-out cursor-pointer'>
+      <div className={cn(
+        'mt-6 px-3.5 py-4 border-2 border-solid border-transparent rounded-lg shadow-md flex flex-col transition-all duration-200 ease-in-out cursor-pointer',
+        className,
+      )}>
         <div className='flex justify-between items-center'>
           <div className={cn(s.textGradient, 'leading-[24px] text-base font-semibold')}>
             <div>{t('billing.apps.fullTipLine1')}</div>

+ 4 - 0
web/i18n/en-US/app.ts

@@ -13,6 +13,10 @@ const translation = {
   exportFailed: 'Export DSL failed.',
   importDSL: 'Import DSL file',
   createFromConfigFile: 'Create from DSL file',
+  importFromDSL: 'Import from DSL',
+  importFromDSLFile: 'From DSL file',
+  importFromDSLUrl: 'From URL',
+  importFromDSLUrlPlaceholder: 'Paste DSL link here',
   deleteAppConfirmTitle: 'Delete this app?',
   deleteAppConfirmContent:
     'Deleting the app is irreversible. Users will no longer be able to access your app, and all prompt configurations and logs will be permanently deleted.',

+ 4 - 0
web/i18n/zh-Hans/app.ts

@@ -13,6 +13,10 @@ const translation = {
   exportFailed: '导出 DSL 失败',
   importDSL: '导入 DSL 文件',
   createFromConfigFile: '通过 DSL 文件创建',
+  importFromDSL: '导入 DSL',
+  importFromDSLFile: '文件',
+  importFromDSLUrl: 'URL',
+  importFromDSLUrlPlaceholder: '输入 DSL 文件的 URL',
   deleteAppConfirmTitle: '确认删除应用?',
   deleteAppConfirmContent:
     '删除应用将无法撤销。用户将不能访问你的应用,所有 Prompt 编排配置和日志均将一并被删除。',

+ 4 - 0
web/service/apps.ts

@@ -37,6 +37,10 @@ export const importApp: Fetcher<AppDetailResponse, { data: string; name?: string
   return post<AppDetailResponse>('apps/import', { body: { data, name, description, icon, icon_background } })
 }
 
+export const importAppFromUrl: Fetcher<AppDetailResponse, { url: string; name?: string; description?: string; icon?: string; icon_background?: string }> = ({ url, name, description, icon, icon_background }) => {
+  return post<AppDetailResponse>('apps/import/url', { body: { url, name, description, icon, icon_background } })
+}
+
 export const switchApp: Fetcher<{ new_app_id: string }, { appID: string; name: string; icon: string; icon_background: string }> = ({ appID, name, icon, icon_background }) => {
   return post<{ new_app_id: string }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon, icon_background } })
 }