mirror of
https://github.com/langgenius/dify.git
synced 2026-06-08 09:27:39 +08:00
feat: empty page
This commit is contained in:
3
MOCKS_TO_REMOVE_BEFORE_RELEASE.md
Normal file
3
MOCKS_TO_REMOVE_BEFORE_RELEASE.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Mocks to Remove Before Release
|
||||
|
||||
- `emptyAppList=true`: frontend URL preview flag for forcing the `/apps` page into the first-empty state. Remove the parser and rendering override before release.
|
||||
@ -64,6 +64,7 @@ const mockQueryState = {
|
||||
tagIDs: [] as string[],
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
emptyAppList: false,
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
@ -133,13 +134,14 @@ const defaultAppData = {
|
||||
total: 2,
|
||||
}],
|
||||
}
|
||||
let mockAppData = defaultAppData
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useInfiniteQuery: () => ({
|
||||
data: defaultAppData,
|
||||
data: mockAppData,
|
||||
isLoading: mockServiceState.isLoading,
|
||||
isFetching: mockServiceState.isFetching,
|
||||
isFetchingNextPage: mockServiceState.isFetchingNextPage,
|
||||
@ -281,6 +283,8 @@ describe('List', () => {
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockQueryState.emptyAppList = false
|
||||
mockAppData = defaultAppData
|
||||
mockUseWorkflowOnlineUsers.mockClear()
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
@ -346,6 +350,66 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render first empty state when there are no apps and no active filters', () => {
|
||||
mockAppData = { pages: [{ data: [], total: 0 }] }
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.firstEmpty.description'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('explore.learnDify.title'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.types.label')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('footer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render first empty state before the first app list page resolves', () => {
|
||||
mockAppData = { pages: [] }
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.label'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render first empty state when emptyAppList URL preview is enabled', () => {
|
||||
mockQueryState.emptyAppList = true
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.types.label')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('footer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the regular empty state for empty filtered results', () => {
|
||||
mockAppData = { pages: [{ data: [], total: 0 }] }
|
||||
mockQueryState.keywords = 'missing app'
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('empty-state'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.label'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open create flows from first empty state actions', () => {
|
||||
mockAppData = { pages: [{ data: [], total: 0 }] }
|
||||
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.startFromBlank/ }))
|
||||
expect(screen.getByTestId('create-app-modal'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.startFromTemplate/ }))
|
||||
expect(screen.getByTestId('template-dialog'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.importDSL/ }))
|
||||
expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass workflow app ids to online users hook', () => {
|
||||
renderList()
|
||||
|
||||
|
||||
93
web/app/components/apps/first-empty-state/index.tsx
Normal file
93
web/app/components/apps/first-empty-state/index.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LearnDify from '@/app/components/explore/learn-dify'
|
||||
|
||||
type EmptyCreateAction = {
|
||||
id: string
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onCreateBlank: () => void
|
||||
onCreateTemplate: () => void
|
||||
onImportDSL: () => void
|
||||
}
|
||||
|
||||
const FirstEmptyState: FC<Props> = ({
|
||||
onCreateBlank,
|
||||
onCreateTemplate,
|
||||
onImportDSL,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const actions: EmptyCreateAction[] = [
|
||||
{
|
||||
id: 'blank',
|
||||
icon: '🖌️',
|
||||
title: t('newApp.startFromBlank', { ns: 'app' }),
|
||||
description: t('firstEmpty.blankDescription', { ns: 'app' }),
|
||||
onClick: onCreateBlank,
|
||||
},
|
||||
{
|
||||
id: 'template',
|
||||
icon: '📑',
|
||||
title: t('newApp.startFromTemplate', { ns: 'app' }),
|
||||
description: t('firstEmpty.templateDescription', { ns: 'app' }),
|
||||
onClick: onCreateTemplate,
|
||||
},
|
||||
{
|
||||
id: 'dsl',
|
||||
icon: '📁',
|
||||
title: t('importDSL', { ns: 'app' }),
|
||||
description: t('firstEmpty.importDescription', { ns: 'app' }),
|
||||
onClick: onImportDSL,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col px-6">
|
||||
<div className="flex flex-1 items-center justify-center py-10">
|
||||
<section className="mx-auto flex w-full max-w-[968px] flex-col items-center text-center">
|
||||
<h2 className="text-2xl/8 font-semibold text-text-primary">{t('firstEmpty.title', { ns: 'app' })}</h2>
|
||||
<p className="mt-2 max-w-[560px] system-sm-regular text-text-tertiary">
|
||||
{t('firstEmpty.description', { ns: 'app' })}
|
||||
</p>
|
||||
<div className="mt-6 grid w-full grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{actions.map(action => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-[204px] flex-col rounded-xl border border-components-panel-border bg-components-panel-on-panel-item-bg p-6 text-left shadow-xs',
|
||||
'transition-colors hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<span className="flex size-12 items-center justify-center rounded-xl bg-background-section text-2xl/8">
|
||||
{action.icon}
|
||||
</span>
|
||||
<span className="mt-5 system-md-semibold text-text-primary">{action.title}</span>
|
||||
<span className="mt-2 system-sm-regular text-text-tertiary">{action.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-center gap-2 text-text-quaternary">
|
||||
<span className="i-ri-drag-drop-line size-4" />
|
||||
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-components-panel-border bg-components-panel-bg p-4 shadow-xs">
|
||||
<LearnDify className="px-0 pb-0" dismissible={false} itemLimit={3} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FirstEmptyState
|
||||
@ -21,6 +21,7 @@ describe('useAppsQueryState', () => {
|
||||
tagIDs: [],
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
emptyAppList: false,
|
||||
})
|
||||
expect(typeof result.current.setCategory).toBe('function')
|
||||
expect(typeof result.current.setKeywords).toBe('function')
|
||||
@ -30,7 +31,7 @@ describe('useAppsQueryState', () => {
|
||||
|
||||
it('should parse app list filters from URL', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true&emptyAppList=true',
|
||||
)
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
@ -38,6 +39,7 @@ describe('useAppsQueryState', () => {
|
||||
tagIDs: ['tag1', 'tag2'],
|
||||
keywords: 'search term',
|
||||
isCreatedByMe: true,
|
||||
emptyAppList: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ const appListQueryParsers = {
|
||||
isCreatedByMe: parseAsBoolean
|
||||
.withDefault(false)
|
||||
.withOptions({ history: 'push' }),
|
||||
emptyAppList: parseAsBoolean.withDefault(false),
|
||||
}
|
||||
|
||||
export function useAppsQueryState() {
|
||||
|
||||
@ -20,6 +20,7 @@ import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import AppListHeaderFilters from './app-list-header-filters'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import Empty from './empty'
|
||||
import FirstEmptyState from './first-empty-state'
|
||||
import Footer from './footer'
|
||||
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
@ -52,7 +53,7 @@ const List: FC<Props> = ({
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { category, tagIDs, keywords, isCreatedByMe },
|
||||
query: { category, tagIDs, keywords, isCreatedByMe, emptyAppList },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
@ -173,7 +174,7 @@ const List: FC<Props> = ({
|
||||
setCategory(nextValue)
|
||||
}, [setCategory])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const pages = useMemo(() => emptyAppList ? [{ data: [], total: 0 }] : data?.pages ?? [], [data?.pages, emptyAppList])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
|
||||
const workflowOnlineUserAppIds = useMemo(() => {
|
||||
@ -192,9 +193,12 @@ const List: FC<Props> = ({
|
||||
enabled: systemFeatures.enable_collaboration_mode,
|
||||
})
|
||||
|
||||
const hasResolvedFirstPage = pages.length > 0
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
const hasActiveFilters = category !== 'all' || tagIDs.length > 0 || keywords.trim().length > 0 || debouncedKeywords.trim().length > 0 || isCreatedByMe
|
||||
// Show skeleton during initial load or when refetching with no previous data
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const showSkeleton = !emptyAppList && (isLoading || (isFetching && pages.length === 0))
|
||||
const showFirstEmptyState = !showSkeleton && !hasAnyApp && isCurrentWorkspaceEditor && (emptyAppList || (hasResolvedFirstPage && !hasActiveFilters))
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -211,55 +215,67 @@ const List: FC<Props> = ({
|
||||
<p className="system-sm-regular text-text-tertiary">{t('studioDescription', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppListHeaderFilters
|
||||
category={category}
|
||||
tagIDs={tagIDs}
|
||||
keywords={keywords}
|
||||
isCreatedByMe={isCreatedByMe}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onTagIDsChange={setTagIDs}
|
||||
onKeywordsChange={setKeywords}
|
||||
onCreatedByMeChange={handleCreatedByMeChange}
|
||||
onCreateBlank={() => setShowNewAppModal(true)}
|
||||
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
|
||||
onImportDSL={() => setShowCreateFromDSLModal(true)}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
showCreateButton={isCurrentWorkspaceEditor}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-3 px-6 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',
|
||||
)}
|
||||
>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={category}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
{!showFirstEmptyState && (
|
||||
<AppListHeaderFilters
|
||||
category={category}
|
||||
tagIDs={tagIDs}
|
||||
keywords={keywords}
|
||||
isCreatedByMe={isCreatedByMe}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onTagIDsChange={setTagIDs}
|
||||
onKeywordsChange={setKeywords}
|
||||
onCreatedByMeChange={handleCreatedByMeChange}
|
||||
onCreateBlank={() => setShowNewAppModal(true)}
|
||||
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
|
||||
onImportDSL={() => setShowCreateFromDSLModal(true)}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
showCreateButton={isCurrentWorkspaceEditor}
|
||||
/>
|
||||
)}
|
||||
{showSkeleton
|
||||
? <AppCardSkeleton count={6} />
|
||||
: hasAnyApp
|
||||
? apps.map(app => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
|
||||
onRefresh={refetch}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
</div>
|
||||
{showFirstEmptyState
|
||||
? (
|
||||
<FirstEmptyState
|
||||
onCreateBlank={() => setShowNewAppModal(true)}
|
||||
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
|
||||
onImportDSL={() => setShowCreateFromDSLModal(true)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-3 px-6 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',
|
||||
)}
|
||||
>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={category}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)}
|
||||
{showSkeleton
|
||||
? <AppCardSkeleton count={6} />
|
||||
: hasAnyApp
|
||||
? apps.map(app => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
|
||||
onRefresh={refetch}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrentWorkspaceEditor && (
|
||||
{isCurrentWorkspaceEditor && !showFirstEmptyState && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
|
||||
role="region"
|
||||
@ -269,7 +285,7 @@ const List: FC<Props> = ({
|
||||
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{!systemFeatures.branding.enabled && (
|
||||
{!systemFeatures.branding.enabled && !showFirstEmptyState && (
|
||||
<Footer />
|
||||
)}
|
||||
<CheckModal />
|
||||
|
||||
@ -10,10 +10,14 @@ import { useLearnDifyHiddenState } from './storage'
|
||||
|
||||
type LearnDifyProps = {
|
||||
className?: string
|
||||
dismissible?: boolean
|
||||
itemLimit?: number
|
||||
}
|
||||
|
||||
const LearnDify = ({
|
||||
className,
|
||||
dismissible = true,
|
||||
itemLimit,
|
||||
}: LearnDifyProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [hidden, setHidden] = useLearnDifyHiddenState()
|
||||
@ -51,7 +55,9 @@ const LearnDify = ({
|
||||
}, 800)
|
||||
}
|
||||
|
||||
if (hidden)
|
||||
const visibleItems = itemLimit ? learnDifyItems.slice(0, itemLimit) : learnDifyItems
|
||||
|
||||
if (dismissible && hidden)
|
||||
return null
|
||||
|
||||
return (
|
||||
@ -71,9 +77,11 @@ const LearnDify = ({
|
||||
<h2 id="learn-dify-title" className="min-w-0 truncate system-xl-semibold text-text-primary">
|
||||
{t('learnDify.title', { ns: 'explore' })}
|
||||
</h2>
|
||||
<button type="button" className="shrink-0 system-sm-medium text-text-primary" onClick={handleHide}>
|
||||
{t('learnDify.hide', { ns: 'explore' })}
|
||||
</button>
|
||||
{dismissible && (
|
||||
<button type="button" className="shrink-0 system-sm-medium text-text-primary" onClick={handleHide}>
|
||||
{t('learnDify.hide', { ns: 'explore' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-4">
|
||||
<p className="min-w-0 truncate system-xs-regular text-text-tertiary">
|
||||
@ -83,7 +91,7 @@ const LearnDify = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{learnDifyItems.map(item => (
|
||||
{visibleItems.map(item => (
|
||||
<LearnDifyItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -49,6 +49,11 @@
|
||||
"editFailed": "Failed to update app info",
|
||||
"export": "Export DSL",
|
||||
"exportFailed": "Export DSL failed.",
|
||||
"firstEmpty.blankDescription": "Start with an empty canvas and build your app step by step.",
|
||||
"firstEmpty.description": "Turn an idea into a working AI app - start from scratch, a template, or import an existing one.",
|
||||
"firstEmpty.importDescription": "Already have a Dify app exported as DSL? Bring it in to continue where you left off.",
|
||||
"firstEmpty.templateDescription": "Pick a ready-made app and customize it. The fastest way to see Dify in action.",
|
||||
"firstEmpty.title": "Build your first App",
|
||||
"gotoAnything.actions.accountDesc": "Navigate to account page",
|
||||
"gotoAnything.actions.communityDesc": "Open Discord community",
|
||||
"gotoAnything.actions.docDesc": "Open help documentation",
|
||||
@ -120,7 +125,6 @@
|
||||
"iconPicker.ok": "OK",
|
||||
"importApp": "Import App",
|
||||
"importDSL": "Import DSL file",
|
||||
"importDSLDescription": "Drag DSL file to studio to import",
|
||||
"importFromDSL": "Import from DSL",
|
||||
"importFromDSLFile": "From DSL file",
|
||||
"importFromDSLUrl": "From URL",
|
||||
|
||||
@ -49,6 +49,11 @@
|
||||
"editFailed": "更新应用信息失败",
|
||||
"export": "导出 DSL",
|
||||
"exportFailed": "导出 DSL 失败",
|
||||
"firstEmpty.blankDescription": "从空白画布开始,一步步搭建你的应用。",
|
||||
"firstEmpty.description": "把想法变成可运行的 AI 应用,可从空白、模板开始,或导入已有应用。",
|
||||
"firstEmpty.importDescription": "已有导出的 Dify DSL 应用?导入后继续编辑。",
|
||||
"firstEmpty.templateDescription": "选择现成应用并按需调整,这是体验 Dify 最快的方式。",
|
||||
"firstEmpty.title": "创建你的第一个应用",
|
||||
"gotoAnything.actions.accountDesc": "导航到账户页面",
|
||||
"gotoAnything.actions.communityDesc": "打开 Discord 社区",
|
||||
"gotoAnything.actions.docDesc": "打开帮助文档",
|
||||
@ -120,7 +125,6 @@
|
||||
"iconPicker.ok": "确认",
|
||||
"importApp": "导入应用",
|
||||
"importDSL": "导入 DSL 文件",
|
||||
"importDSLDescription": "拖放 DSL 文件到 Studio 导入",
|
||||
"importFromDSL": "导入 DSL",
|
||||
"importFromDSLFile": "文件",
|
||||
"importFromDSLUrl": "URL",
|
||||
|
||||
Reference in New Issue
Block a user