<template>
<div class="toolbar-upload">
<tiny-popover :visible-arrow="false" trigger="manual" :open-delay="OPEN_DELAY.Default" v-model="poperVisible">
<template #reference>
<toolbar-base
content="导入"
:icon="options.icon?.default || options?.icon"
:options="options"
@click-api="clickPopover"
>
</toolbar-base>
</template>
<div class="toolbar-upload-option">
<div class="toolbar-upload-item" @click="() => triggerUpload('file')">Vue 文件</div>
<div class="toolbar-upload-item" @click="() => triggerUpload('directory')">项目目录</div>
<div class="toolbar-upload-item" @click="() => triggerUpload('zip')">项目压缩包</div>
</div>
<input ref="fileInputRef" type="file" accept=".vue" style="display: none" @change="handleFileChange" />
<input ref="directoryInputRef" type="file" webkitdirectory style="display: none" @change="handleFileChange" />
<input
ref="zipInputRef"
type="file"
accept=".zip,application/zip"
style="display: none"
@change="handleFileChange"
/>
</tiny-popover>
<!-- 覆盖选择对话框 -->
<overwrite-dialog
:visible="state.showOverwriteDialog"
:duplicates="state.duplicatePages.map((d) => ({ name: d.name }))"
@update:visible="(v) => (state.showOverwriteDialog = v)"
@confirm="handleOverwriteConfirm"
@cancel="handleOverwriteCancel"
/>
<import-notice-dialog
:visible="state.showImportNoticeDialog"
:upload-type="state.pendingUploadType"
@update:visible="(v) => (state.showImportNoticeDialog = v)"
@confirm="handleImportNoticeConfirm"
@cancel="handleImportNoticeCancel"
/>
</div>
</template>
<script lang="ts">
/* metaService: engine.toolbars.upload.Main */
import { reactive, ref } from 'vue'
import {
useNotify,
useResource,
usePage,
useMaterial,
useTranslate,
getMetaApi,
getMergeMeta,
META_SERVICE
} from '@opentiny/tiny-engine-meta-register'
import { ToolbarBase } from '@opentiny/tiny-engine-common'
import {
fetchPageList,
fetchBlockGroups,
createBlockGroup,
createBlock,
fetchBlockByLabel,
updateBlock,
deployBlock,
fetchUtilsResourceList,
createUtilsResource,
updateUtilsResource,
fetchDataSourceList,
createDataSource,
updateDataSource,
fetchResourceGroups,
createResourceGroup,
fetchResourceListByGroupId,
batchCreateResource,
updateAppConfig
} from './http'
import {
buildImportedAssetCreatePayload,
buildImportedAssetResourceName,
findImportedAssetResource,
getImportedAssetResourceUrl,
getImportedAssets,
normalizeImportedAssetResourceList,
splitImportedAssetCreatePayloads,
replaceImportedAssetPlaceholders
} from './assetImport'
import {
buildImportedBlockEvents,
resolveImportedBlockDefaultValue,
resolveImportedBlockPropType,
splitImportedBlockBindings
} from './blockImport'
import { hydrateImportedAppSchemaState, normalizeImportedAppSchema, normalizeImportedSchema } from './schemaImport'
import { VueToDslConverter } from '@opentiny/tiny-engine-vue-to-dsl'
import OverwriteDialog from './OverwriteDialog.vue'
import ImportNoticeDialog from './ImportNoticeDialog.vue'
import { TinyPopover } from '@opentiny/vue'
import { constants } from '@opentiny/tiny-engine-utils'
const { OPEN_DELAY } = constants
const IMPORTED_RESOURCE_GROUP_NAME = '项目导入资源'
const IMPORTED_RESOURCE_GROUP_DESCRIPTION = '项目导入时自动上传的图片资源'
// @ts-ignore
export default {
components: {
ToolbarBase,
OverwriteDialog,
ImportNoticeDialog,
TinyPopover
},
props: {
options: {
type: Object,
default: () => ({})
}
},
setup() {
const state = reactive({
showOverwriteDialog: false,
showImportNoticeDialog: false,
duplicatePages: [] as Array<{ name: string; ps: any; existing: any }>,
toCreatePages: [] as any[],
pendingImportedPages: [] as any[],
pendingImportedComponentsMap: [] as any[],
pendingAppSchema: null as any,
appId: '' as any,
pendingUploadType: 'directory' as 'file' | 'directory' | 'zip'
})
const poperVisible = ref(false)
const clickPopover = () => {
poperVisible.value = !poperVisible.value
}
const closePopover = () => {
poperVisible.value = false
}
// 文件上传引用
const fileInputRef = ref<HTMLInputElement | null>(null)
// 目录上传引用
const directoryInputRef = ref<HTMLInputElement | null>(null)
// zip压缩包上传引用
const zipInputRef = ref<HTMLInputElement | null>(null)
// 按页面ID切换到对应页面,确保 currentPage 与渲染一致
const switchToPageByName = async (name?: string) => {
if (!name) return
// 优先使用已缓存的 appId
const appId = state.appId || getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
try {
const list: any[] = await fetchPageList(appId)
const target = list?.find?.((p: any) => String(p?.name) === String(name))
if (target?.id) {
await usePage().switchPageWithConfirm?.(target.id, true)
}
} catch (e) {
// ignore
}
}
const mergeUtilsByName = (base: any[] = [], incoming: any[] = []) => {
const merged = new Map<string, any>()
;[...base, ...incoming].forEach((item) => {
if (!item?.name) return
merged.set(String(item.name), item)
})
return Array.from(merged.values())
}
const mergeComponentsMapByName = (base: any[] = [], incoming: any[] = []) => {
const merged = new Map<string, any>()
;[...base, ...incoming].forEach((item) => {
if (!item?.componentName) return
const key = String(item.componentName)
const existing = merged.get(key)
merged.set(key, existing ? { ...existing, ...item, componentName: key } : item)
})
return Array.from(merged.values())
}
const mergeI18nMessages = (base: Record<string, any> = {}, incoming: Record<string, any> = {}) => {
const flattenLocaleMessages = (value: any, prefix = '', collector: Record<string, any> = {}) => {
if (value === null || value === undefined) return collector
if (Array.isArray(value)) {
value.forEach((item, index) => {
const nextKey = prefix ? `${prefix}.${index}` : String(index)
if (item !== null && typeof item === 'object') {
flattenLocaleMessages(item, nextKey, collector)
} else {
collector[nextKey] = item
}
})
return collector
}
if (typeof value === 'object') {
Object.entries(value).forEach(([key, item]) => {
const nextKey = prefix ? `${prefix}.${key}` : key
if (item !== null && typeof item === 'object') {
flattenLocaleMessages(item, nextKey, collector)
} else {
collector[nextKey] = item
}
})
return collector
}
if (prefix) {
collector[prefix] = value
}
return collector
}
const mergedLocales = new Set([...Object.keys(base || {}), ...Object.keys(incoming || {})])
const result: Record<string, any> = {}
mergedLocales.forEach((locale) => {
const baseMessages = flattenLocaleMessages((base && base[locale]) || {})
const incomingMessages = flattenLocaleMessages((incoming && incoming[locale]) || {})
result[locale] = {
...baseMessages,
...incomingMessages
}
})
return result
}
const mergeDataSourceByName = (base: any[] = [], incoming: any[] = []) => {
const merged = new Map<string, any>()
;[...base, ...incoming].forEach((item) => {
if (!item) return
const key = String(item.name || item.id || '')
if (!key) return
merged.set(key, item)
})
return Array.from(merged.values())
}
const normalizeImportedDataSourceItem = (item: any) => {
if (!item || typeof item !== 'object') return item
const nextItem = { ...item }
const rawData = item.data && typeof item.data === 'object' ? { ...item.data } : {}
const hasOptions = rawData.options && typeof rawData.options === 'object'
const inferredType =
rawData.type ||
nextItem.type ||
(rawData.method || rawData.uri || rawData.params || rawData.isSync !== undefined ? 'remote' : 'array')
if (inferredType === 'remote') {
const options = {
...(hasOptions ? rawData.options : {}),
method: (hasOptions ? rawData.options?.method : undefined) || rawData.method || 'GET',
uri: (hasOptions ? rawData.options?.uri : undefined) || rawData.uri || '',
params: (hasOptions ? rawData.options?.params : undefined) || rawData.params || '',
isSync: (hasOptions ? rawData.options?.isSync : undefined) ?? rawData.isSync ?? true
}
delete rawData.method
delete rawData.uri
delete rawData.params
delete rawData.isSync
nextItem.data = {
...rawData,
type: 'remote',
options,
shouldFetch: rawData.shouldFetch || {
type: 'JSFunction',
value: 'function shouldFetch() { return true }'
},
willFetch: rawData.willFetch || {
type: 'JSFunction',
value: 'function willFetch(option) { return option }'
},
dataHandler: rawData.dataHandler || {
type: 'JSFunction',
value: 'function dataHandler(res) { return res }'
},
errorHandler: rawData.errorHandler || {
type: 'JSFunction',
value: 'function errorHandler(err) { return Promise.reject(err) }'
}
}
} else {
nextItem.data = {
...rawData,
type: rawData.type || inferredType
}
}
return nextItem
}
const normalizeImportedDataSourceConfig = (dataSource: any = { list: [] }) => {
const list = Array.isArray(dataSource?.list)
? dataSource.list.map((item: any) => normalizeImportedDataSourceItem(item))
: []
return {
...dataSource,
list
}
}
const mergeGlobalStateById = (base: any[] = [], incoming: any[] = []) => {
const merged = new Map<string, any>()
;[...base, ...incoming].forEach((item) => {
const id = String(item?.id || '')
if (!id) return
const existing = merged.get(id)
merged.set(
id,
existing
? {
...existing,
...item,
id,
state: item?.state ?? existing?.state ?? {},
getters: item?.getters ?? existing?.getters,
actions: item?.actions ?? existing?.actions
}
: item
)
})
return Array.from(merged.values())
}
const toPascalCase = (input = '') =>
String(input)
.split(/[-_\s]+/)
.filter(Boolean)
.map((item) => item.charAt(0).toUpperCase() + item.slice(1))
.join('')
const normalizeIconComponentName = (componentName = '') => {
const raw = String(componentName || '')
if (/^TinyIcon[A-Z0-9]/.test(raw)) {
return raw.replace(/^Tiny/, '')
}
const lower = raw.toLowerCase()
if (lower.startsWith('tiny-icon-')) {
return `Icon${toPascalCase(lower.replace(/^tiny-icon-/, ''))}`
}
return ''
}
const normalizeImportedSchemaIcons = (schema: any) => {
if (!schema || typeof schema !== 'object') return schema
const visited = new WeakSet<object>()
const shouldHandleAsSchemaNode = (value: any) => {
if (!value || typeof value !== 'object') return false
if (typeof value.componentName !== 'string') return false
return (
Object.prototype.hasOwnProperty.call(value, 'props') ||
Object.prototype.hasOwnProperty.call(value, 'children') ||
Object.prototype.hasOwnProperty.call(value, 'id') ||
Object.prototype.hasOwnProperty.call(value, 'componentType')
)
}
const walk = (value: any) => {
if (!value || typeof value !== 'object') return
if (visited.has(value)) return
visited.add(value)
if (Array.isArray(value)) {
value.forEach(walk)
return
}
if (shouldHandleAsSchemaNode(value)) {
const iconName = normalizeIconComponentName(value.componentName)
if (iconName) {
const nextProps = {
...(value.props || {}),
name: iconName
}
value.componentName = 'Icon'
value.props = nextProps
}
}
Object.values(value).forEach((item) => {
if (item && typeof item === 'object') {
walk(item)
}
})
}
walk(schema)
return schema
}
const createVueToDslConverter = () => {
const materials = useMaterial().getComponentList?.() || []
return new VueToDslConverter({
computed_flag: true,
materials
})
}
const normalizeImportedAppSchemaIcons = (appSchema: any) => {
const pages = Array.isArray(appSchema?.pageSchema) ? appSchema.pageSchema : []
const blocks = Array.isArray(appSchema?.blockSchemas) ? appSchema.blockSchemas : []
pages.forEach((pageSchema: any) => normalizeImportedSchemaIcons(pageSchema))
blocks.forEach((blockSchema: any) => normalizeImportedSchemaIcons(blockSchema))
return appSchema
}
const resolveImportedPageName = (ps: any, fallback = 'Page') =>
(ps?.meta?.name || ps?.fileName || fallback) as string
const buildSafeRoute = (rawName: string) =>
String(rawName || 'Page')
.replace(/\s+/g, '-')
.toLowerCase()
const normalizeImportedRoute = (route: any, rawName: string) => {
const normalized = String(route || '')
.trim()
.replace(/^\/+/, '')
return normalized || buildSafeRoute(rawName)
}
const syncComponentsDepsByMap = (componentsMap: any[] = []) => {
if (!Array.isArray(componentsMap) || !componentsMap.length) return
const { appSchemaState } = useResource()
const scriptsDeps = appSchemaState.materialsDeps?.scripts || []
componentsMap.forEach((item: any) => {
if (!item?.componentName || !item?.package) return
const pkg = String(item.package)
if (!pkg) return
const exportName = item.exportName || item.componentName
const destructuring = item.destructuring !== false
const existing = scriptsDeps.find((dep: any) => String(dep?.package || '') === pkg)
if (existing) {
existing.components = {
...(existing.components || {}),
[item.componentName]: { exportName, destructuring }
}
if (!existing.script && item.script) {
existing.script = item.script
}
return
}
scriptsDeps.push({
package: pkg,
script: item.script,
components: {
[item.componentName]: { exportName, destructuring }
}
})
})
useMaterial().updateCanvasDeps?.()
}
const syncImportedComponentsMap = (importedComponentsMap: any[] = []) => {
const normalized = Array.isArray(importedComponentsMap)
? importedComponentsMap.filter((item) => item?.componentName)
: []
if (!normalized.length) return
const { appSchemaState } = useResource()
const mergedComponentsMap = mergeComponentsMapByName(appSchemaState.componentsMap || [], normalized)
appSchemaState.componentsMap = mergedComponentsMap
syncComponentsDepsByMap(mergedComponentsMap)
try {
const cachedAppSchema = localStorage.getItem('TE_LOCAL_APPSCHEMA')
if (cachedAppSchema) {
const parsed = JSON.parse(cachedAppSchema)
parsed.componentsMap = mergeComponentsMapByName(parsed?.componentsMap || [], normalized)
localStorage.setItem('TE_LOCAL_APPSCHEMA', JSON.stringify(parsed))
}
} catch (e) {
// ignore persistence errors
}
}
const syncImportedUtils = async (appId: string, importedUtils: any[] = []) => {
const normalizedUtils = Array.isArray(importedUtils)
? importedUtils.filter((item) => item?.name && item?.type)
: []
const useUtilsApi: any = getMetaApi(META_SERVICE.UseUtils)
const mergedLocalUtils = mergeUtilsByName(useUtilsApi?.getUtils?.() || [], normalizedUtils)
useUtilsApi?.setUtils?.(mergedLocalUtils)
if (!normalizedUtils.length) {
return
}
try {
const existingRes: any = await fetchUtilsResourceList(appId)
const existingList = Array.isArray(existingRes) ? existingRes : existingRes?.data || []
const existingMap = new Map<string, any>()
existingList?.forEach?.((item: any) => {
if (!item?.name) return
existingMap.set(String(item.name), item)
})
await Promise.allSettled(
normalizedUtils.map((item: any) => {
const existing = existingMap.get(String(item.name))
const payload = {
...(existing || {}),
app: appId,
category: 'utils',
name: item.name,
type: item.type,
content: item.content || {}
}
return existing?.id ? updateUtilsResource(payload) : createUtilsResource(payload)
})
)
await useUtilsApi?.refreshUtils?.()
} catch (e) {
useUtilsApi?.setUtils?.(mergedLocalUtils)
}
}
const syncImportedI18n = async (appId: string, hostType: string, mergedI18n: Record<string, any> = {}) => {
const locales = Object.keys(mergedI18n || {})
if (!locales.length) return
const useTranslateApi = useTranslate()
try {
useTranslateApi.setLangs?.({})
await useTranslateApi.initI18n({ host: appId, hostType, init: true })
const entryKeys = new Set<string>()
locales.forEach((locale) => {
Object.keys(mergedI18n?.[locale] || {}).forEach((key) => entryKeys.add(key))
})
entryKeys.forEach((key) => {
const payload: Record<string, any> = { key, type: 'i18n' }
locales.forEach((locale) => {
payload[locale] = mergedI18n?.[locale]?.[key] || ''
})
useTranslateApi.ensureI18n?.(payload, true)
})
} catch (_error) {
// ignore persistence errors, keep current-session merged i18n
}
}
const syncImportedDataSources = async (appId: string, importedDataSource: any = { list: [] }) => {
const importedList = Array.isArray(importedDataSource?.list)
? importedDataSource.list.filter((item: any) => item?.name)
: []
const { appSchemaState } = useResource()
if (!importedList.length) {
return {
list: Array.isArray(appSchemaState.dataSource) ? appSchemaState.dataSource : [],
dataHandler: importedDataSource?.dataHandler || appSchemaState.dataHandler,
willFetch: importedDataSource?.willFetch || appSchemaState.willFetch,
errorHandler: importedDataSource?.errorHandler || appSchemaState.errorHandler
}
}
try {
const existingRes: any = await fetchDataSourceList(appId)
const existingList = Array.isArray(existingRes) ? existingRes : existingRes?.data || []
const existingMap = new Map<string, any>()
existingList.forEach((item: any) => {
if (!item?.name) return
existingMap.set(String(item.name), item)
})
await Promise.allSettled(
importedList.map((item: any) => {
const existing = existingMap.get(String(item.name))
const payload = {
name: item.name,
app: appId,
data: item.data || {}
}
return existing?.id ? updateDataSource(existing.id, payload) : createDataSource(payload)
})
)
const refreshedRes: any = await fetchDataSourceList(appId)
const refreshedList = Array.isArray(refreshedRes) ? refreshedRes : refreshedRes?.data || []
if (Array.isArray(appSchemaState.dataSource)) {
appSchemaState.dataSource.splice(0, appSchemaState.dataSource.length, ...refreshedList)
} else {
appSchemaState.dataSource = refreshedList
}
return {
list: refreshedList,
dataHandler: importedDataSource?.dataHandler || appSchemaState.dataHandler,
willFetch: importedDataSource?.willFetch || appSchemaState.willFetch,
errorHandler: importedDataSource?.errorHandler || appSchemaState.errorHandler
}
} catch (_error) {
return {
list: importedList,
dataHandler: importedDataSource?.dataHandler || appSchemaState.dataHandler,
willFetch: importedDataSource?.willFetch || appSchemaState.willFetch,
errorHandler: importedDataSource?.errorHandler || appSchemaState.errorHandler
}
}
}
const getNextBlockVersion = (block: any) => {
const backupList = Array.isArray(block?.histories) ? block.histories : []
let latestVersion = block?.current_version || '1.0.0'
let latestTime = 0
backupList.forEach((item: { created_at?: string | number | Date; version?: string }) => {
const itemTime = item?.created_at ? new Date(item.created_at).getTime() : 0
if (itemTime > latestTime && item?.version) {
latestTime = itemTime
latestVersion = item.version
}
})
return String(latestVersion || '1.0.0').replace(/\d+$/, (match) => String(Number(match) + 1))
}
const openUploadSelector = (type: 'file' | 'directory' | 'zip') => {
if (type === 'file') {
fileInputRef.value?.click()
} else if (type === 'directory') {
directoryInputRef.value?.click()
} else if (type === 'zip') {
zipInputRef.value?.click()
}
}
// 触发隐藏文件上传
const triggerUpload = (type: 'file' | 'directory' | 'zip') => {
closePopover()
if (type === 'directory' || type === 'zip') {
state.pendingUploadType = type
state.showImportNoticeDialog = true
return
}
openUploadSelector(type)
}
const handleImportNoticeConfirm = () => {
state.showImportNoticeDialog = false
openUploadSelector(state.pendingUploadType)
}
const handleImportNoticeCancel = () => {
state.showImportNoticeDialog = false
}
const applyImportedAppSchema = async (appSchema: any, appId: string, hostType: string) => {
const { appSchemaState } = useResource()
const importedUtils = Array.isArray(appSchema?.utils) ? appSchema.utils : []
const normalizedImportedDataSource = normalizeImportedDataSourceConfig(appSchema?.dataSource || { list: [] })
const importedComponentsMap = Array.isArray(appSchema?.componentsMap) ? appSchema.componentsMap : []
const importedI18n = appSchema?.i18n || {}
const mergedI18n = mergeI18nMessages(appSchemaState.langs?.messages || {}, importedI18n)
const locales = Object.keys(mergedI18n).length
? Object.keys(mergedI18n).map((key) => ({ lang: key, label: key }))
: [
{ lang: 'zh_CN', label: 'zh_CN' },
{ lang: 'en_US', label: 'en_US' }
]
const mergedUtils = mergeUtilsByName(appSchemaState.utils || [], importedUtils)
const mergedDataSource = mergeDataSourceByName(
appSchemaState.dataSource || [],
normalizedImportedDataSource.list || []
)
const mergedGlobalState = mergeGlobalStateById(appSchemaState.globalState || [], appSchema?.globalState || [])
appSchemaState.langs = {
locales,
messages: mergedI18n
}
appSchemaState.utils = mergedUtils
if (Array.isArray(appSchemaState.dataSource)) {
appSchemaState.dataSource.splice(0, appSchemaState.dataSource.length, ...mergedDataSource)
} else {
appSchemaState.dataSource = mergedDataSource
}
appSchemaState.globalState = mergedGlobalState
appSchemaState.componentsMap = mergeComponentsMapByName(appSchemaState.componentsMap || [], importedComponentsMap)
syncComponentsDepsByMap(appSchemaState.componentsMap || [])
try {
await updateAppConfig(appId, { global_state: mergedGlobalState })
} catch (error) {
useNotify({
type: 'warning',
title: '应用状态同步失败',
message: '已在当前编辑器会话中合并应用状态,但后端保存失败,请稍后重试'
})
}
await syncImportedUtils(appId, importedUtils)
await syncImportedI18n(appId, hostType, mergedI18n)
const syncedDataSource = await syncImportedDataSources(appId, {
...normalizedImportedDataSource,
list: mergedDataSource
})
const pages = Array.isArray(appSchema?.pageSchema) ? appSchema.pageSchema : []
try {
const componentsTree = pages.map((ps: any) => ({
name: ps?.meta?.name || ps?.fileName || 'Page',
meta: { ...(ps?.meta || {}), isPage: true, message: 'Page auto save', isBody: false, isHome: false },
page_content: ps
}))
if (!componentsTree.some((p: any) => p?.meta?.isHome) && componentsTree[0]) {
componentsTree[0].meta.isHome = true
}
const normalizedAppData = {
...appSchema,
componentsTree,
pageSchema: pages,
componentsMap: appSchemaState.componentsMap,
i18n: mergedI18n,
utils: mergedUtils,
dataSource: {
...normalizedImportedDataSource,
...syncedDataSource,
list: syncedDataSource?.list || mergedDataSource
},
globalState: mergedGlobalState,
meta: {
...(appSchema.meta || {}),
globalState: mergedGlobalState
}
}
localStorage.setItem('TE_LOCAL_APPSCHEMA', JSON.stringify(normalizedAppData))
} catch (e) {
// ignore persistence errors
}
await useTranslate().initI18n({ host: appId, hostType: hostType, init: true })
}
const createAndPublishBlocks = async (appSchema: any, appId: string) => {
const blocks = Array.isArray(appSchema?.blockSchemas) ? appSchema.blockSchemas : []
if (!blocks.length) {
return { total: 0, created: 0, updated: 0 }
}
if (blocks.length) {
try {
// 查询或创建区块分组
let groupId: string | null = null
try {
const groupsRes: any = await fetchBlockGroups({ app: appId })
const groups = Array.isArray(groupsRes) ? groupsRes : groupsRes?.data || []
if (groups.length > 0) {
// 使用第一个分组
groupId = groups[0].id
} else {
// 创建默认分组 "我的分组"
const createGroupRes: any = await createBlockGroup({
name: '我的分组',
app: appId
})
groupId = createGroupRes?.id
}
} catch (e) {
// 继续创建区块,即使分组创建失败
}
// 创建区块
// 先从页面 schema 中收集父组件传给各区块的 props
const pages = Array.isArray(appSchema?.pageSchema) ? appSchema.pageSchema : []
const parentPropsMap: Record<string, Record<string, any>> = {}
const parentEventsMap: Record<string, Record<string, any>> = {}
const collectParentProps = (node: any) => {
if (!node || typeof node !== 'object') return
if (node.componentType === 'Block' && node.componentName && node.props) {
const name = node.componentName
if (!parentPropsMap[name]) parentPropsMap[name] = {}
if (!parentEventsMap[name]) parentEventsMap[name] = {}
const { props: blockProps, events: blockEvents } = splitImportedBlockBindings(node.props)
Object.keys(blockProps).forEach((key) => {
if (key !== 'className' && key !== 'style' && key !== 'class') {
parentPropsMap[name][key] = blockProps[key]
}
})
Object.keys(blockEvents).forEach((key) => {
parentEventsMap[name][key] = blockEvents[key]
})
}
if (Array.isArray(node.children)) {
node.children.forEach(collectParentProps)
}
}
pages.forEach((ps: any) => {
if (ps?.children) ps.children.forEach(collectParentProps)
})
let createdCount = 0
let updatedCount = 0
await Promise.allSettled(
blocks.map(async (bs: any) => {
const blockLabel = bs.fileName || bs.meta?.name || 'Block'
// 类型对应的默认编辑器组件
const META_COMPONENTS: Record<string, string> = {
array: 'CodeConfigurator',
string: 'InputConfigurator',
number: 'NumberConfigurator',
object: 'CodeConfigurator',
boolean: 'SwitchConfigurator',
function: 'CodeConfigurator'
}
// 合并两个来源的 props:
// 1. 子组件自身声明的 props(bs.props)
// 2. 父组件传递的 props(parentPropsMap)
const declaredProps: Record<string, any> = {}
if (Array.isArray(bs.props)) {
bs.props.forEach((p: any) => {
declaredProps[p.name] = p
})
}
const parentProps = parentPropsMap[blockLabel] || {}
const parentEvents = parentEventsMap[blockLabel] || {}
// 以父组件传递的 props 为主,合并子组件声明的 props
const mergedPropNames = new Set([...Object.keys(declaredProps), ...Object.keys(parentProps)])
const blockProperties: any[] = []
mergedPropNames.forEach((propName) => {
const declared = declaredProps[propName]
const parentVal = parentProps[propName]
const propType = resolveImportedBlockPropType(declared?.type, parentVal)
const defaultValue = resolveImportedBlockDefaultValue(declared?.default, parentVal, propType)
blockProperties.push({
property: propName,
type: propType,
defaultValue: defaultValue,
label: {
text: {
zh_CN: propName
}
},
cols: 12,
rules: [],
accessor: {},
hidden: false,
required: declared?.required || false,
readOnly: false,
disabled: false,
widget: {
component: META_COMPONENTS[propType] || 'InputConfigurator',
props: {}
},
properties: [
{
label: {
zh_CN: '默认分组'
},
content: []
}
]
})
})
// 将子节点中引用 state 变量的地方转换为 this.props.xxx
const propNames = new Set(blockProperties.map((p: any) => p.property))
const rewriteChildrenProps = (node: any) => {
if (!node || typeof node !== 'object') return
if (node.props && typeof node.props === 'object') {
Object.keys(node.props).forEach((key) => {
const val = node.props[key]
// 将 state 引用转为 props 引用
if (typeof val === 'object' && val?.type === 'JSExpression' && typeof val.value === 'string') {
propNames.forEach((pn) => {
if (val.value.includes(`this.state.${pn}`)) {
val.value = val.value.replace(new RegExp(`this\\.state\\.${pn}`, 'g'), `this.props.${pn}`)
}
})
}
})
}
if (Array.isArray(node.children)) {
node.children.forEach(rewriteChildrenProps)
}
}
const children = JSON.parse(JSON.stringify(bs.children || []))
const blockState = JSON.parse(JSON.stringify(bs.state || {}))
const blockMethods = JSON.parse(JSON.stringify(bs.methods || {}))
const blockLifeCycles = JSON.parse(JSON.stringify(bs.lifeCycles || {}))
children.forEach(rewriteChildrenProps)
const blockParams: any = {
label: blockLabel,
name_cn: blockLabel,
public: 1,
framework: 'Vue',
created_app: appId,
content: {
componentName: 'Block',
fileName: blockLabel,
css: bs.css || '',
props: {},
children: children,
schema: {
properties: [
{
label: {
zh_CN: '基础信息'
},
description: {
zh_CN: '基础信息'
},
collapse: {
number: 6,
text: {
zh_CN: '显示更多'
}
},
content: blockProperties
}
],
events: buildImportedBlockEvents(bs.emits || [], parentEvents),
slots: {}
},
state: blockState,
methods: blockMethods,
lifeCycles: blockLifeCycles,
dataSource: bs.dataSource || {},
dependencies: bs.dependencies || { scripts: [], styles: [] },
id: 'body'
}
}
// 添加分组ID(如果成功获取或创建)
if (groupId) {
blockParams.groups = [groupId]
} else {
// 备选方案:使用空分类数组
blockParams.categories = []
}
const existingRes: any = await fetchBlockByLabel(blockLabel).catch(() => [])
const existingList = Array.isArray(existingRes) ? existingRes : existingRes?.data || []
const existing =
existingList.find?.(
(item: any) =>
String(item?.label) === String(blockLabel) &&
String(item?.created_app || item?.app || '') === String(appId)
) ||
existingList.find?.((item: any) => String(item?.label) === String(blockLabel)) ||
existingList?.[0]
let blockData: any = null
if (existing?.id) {
const existingGroups = Array.isArray(existing.groups)
? existing.groups.map((item: any) => item?.id || item).filter(Boolean)
: []
const existingCategories = Array.isArray(existing.categories)
? existing.categories.map((item: any) => item?.id || item).filter(Boolean)
: []
let relationParams: Record<string, any>
if (groupId) {
relationParams = { groups: [groupId] }
} else if (existingGroups.length) {
relationParams = { groups: existingGroups }
} else {
relationParams = {
categories: existingCategories.length ? existingCategories : blockParams.categories || []
}
}
const updateParams = {
name_cn: blockParams.name_cn,
label: blockParams.label,
content: blockParams.content,
screenshot: existing.screenshot,
public: existing.public ?? blockParams.public,
public_scope_tenants: existing.public_scope_tenants || [],
tags: existing.tags || [],
description: existing.description || '',
...relationParams
}
blockData = await updateBlock(existing.id, updateParams, appId)
if (blockData?.id) {
updatedCount += 1
}
} else {
blockData = await createBlock(blockParams)
if (blockData?.id) {
createdCount += 1
}
}
if (blockData?.id) {
const publishVersion = existing?.id
? getNextBlockVersion(existing)
: blockData?.current_version || '1.0.1'
const params = {
block: blockData,
is_compile: true,
deploy_info: '导入项目自动发布',
version: publishVersion,
needToSave: true
}
await deployBlock(params)
}
})
)
return {
total: blocks.length,
created: createdCount,
updated: updatedCount
}
} catch (e) {
// 区块创建失败不阻塞页面导入流程
}
}
return { total: blocks.length, created: 0, updated: 0 }
}
const ensureImportedResourceGroup = async () => {
const groupResponse: any = await fetchResourceGroups()
const groupList = Array.isArray(groupResponse) ? groupResponse : groupResponse?.data || []
const existingGroup = groupList.find((item: any) => String(item?.name || '') === IMPORTED_RESOURCE_GROUP_NAME)
if (existingGroup?.id) {
return existingGroup
}
return await createResourceGroup({
name: IMPORTED_RESOURCE_GROUP_NAME,
description: IMPORTED_RESOURCE_GROUP_DESCRIPTION
})
}
const syncImportedAssets = async (appSchema: any) => {
const importedAssets = getImportedAssets(appSchema)
if (!importedAssets.length) {
delete appSchema?.assets
return { total: 0, uploaded: 0, reused: 0 }
}
const { id: appId } = getMetaApi(META_SERVICE.GlobalService).getBaseInfo()
const platformId = getMergeMeta('engine.config')?.platformId
const resourceGroup = await ensureImportedResourceGroup()
const resourceResponse: any = await fetchResourceListByGroupId(resourceGroup.id)
const existingResources = normalizeImportedAssetResourceList(resourceResponse)
const existingByName = new Map<string, any>()
const urlMap = new Map<string, string>()
const createPayloads: any[] = []
existingResources.forEach((item: any) => {
if (!item?.name) return
existingByName.set(String(item.name), item)
})
importedAssets.forEach((asset: any) => {
const resourceName = buildImportedAssetResourceName(asset)
const existing = existingByName.get(resourceName)
const existingResourceUrl = getImportedAssetResourceUrl(existing)
if (existingResourceUrl) {
urlMap.set(String(asset.placeholder), String(existingResourceUrl))
return
}
createPayloads.push({
placeholder: asset.placeholder,
...buildImportedAssetCreatePayload(asset, resourceGroup.id, { appId, platformId })
})
})
if (createPayloads.length) {
const uploadPayloads = createPayloads.map((item: any) => ({
name: item.name,
description: item.description,
resourceGroupId: item.resourceGroupId,
resourceData: item.resourceData,
resourceUrl: item.resourceUrl,
category: item.category,
appId: item.appId,
platformId: item.platformId
}))
const uploadBatches = splitImportedAssetCreatePayloads(uploadPayloads)
const createdResources: any[] = []
for (const batch of uploadBatches) {
const createResponse: any = await batchCreateResource(batch)
createdResources.push(...normalizeImportedAssetResourceList(createResponse))
}
const createdByName = new Map<string, any>()
createdResources.forEach((item: any) => {
if (!item?.name) return
createdByName.set(String(item.name), item)
})
createPayloads.forEach((item: any) => {
const created = createdByName.get(String(item.name)) || findImportedAssetResource(createdResources, item)
const createdResourceUrl = getImportedAssetResourceUrl(created)
if (createdResourceUrl) {
urlMap.set(String(item.placeholder), String(createdResourceUrl))
}
})
for (let attempt = 0; attempt < 4; attempt += 1) {
const pendingPayloads = createPayloads.filter((item: any) => !urlMap.get(String(item.placeholder)))
if (!pendingPayloads.length) {
break
}
if (attempt > 0) {
await new Promise((resolve) => window.setTimeout(resolve, 400 * attempt))
}
const refreshedResponse: any = await fetchResourceListByGroupId(resourceGroup.id)
const refreshedResources = normalizeImportedAssetResourceList(refreshedResponse)
pendingPayloads.forEach((item: any) => {
const created = findImportedAssetResource(refreshedResources, item)
const createdResourceUrl = getImportedAssetResourceUrl(created)
if (createdResourceUrl) {
urlMap.set(String(item.placeholder), String(createdResourceUrl))
}
})
}
}
const unresolvedAsset = importedAssets.find((item: any) => !urlMap.get(String(item.placeholder)))
if (unresolvedAsset) {
throw new Error(
`图片资源上传失败:${unresolvedAsset.filePath || unresolvedAsset.name || unresolvedAsset.placeholder}`
)
}
replaceImportedAssetPlaceholders(appSchema, urlMap)
delete appSchema.assets
// Avoid re-fetching app schema here: it can overwrite freshly merged app state
// (for example globalState) before the current import flow finishes.
useMaterial().refreshMaterial?.()
return {
total: importedAssets.length,
uploaded: createPayloads.length,
reused: importedAssets.length - createPayloads.length
}
}
const processAppSchema = async (appSchema: any) => {
normalizeImportedAppSchemaIcons(appSchema)
normalizeImportedAppSchema(appSchema)
await hydrateImportedAppSchemaState(appSchema)
const { id: appId, type } = getMetaApi(META_SERVICE.GlobalService).getBaseInfo()
const pages = Array.isArray(appSchema?.pageSchema) ? appSchema.pageSchema : []
state.appId = appId
const buildCreateParams = (ps: any) => {
const rawName = resolveImportedPageName(ps)
return {
name: rawName,
route: normalizeImportedRoute(ps?.meta?.router, rawName),
group: 'staticPages',
parentId: '0',
isPage: true,
app: appId,
page_content: {
...ps,
fileName: ps?.fileName || rawName
},
message: 'Page auto save',
isBody: false,
isHome: false
}
}
if (pages.length) {
try {
const existingList: any[] = await fetchPageList(appId)
const mapByName = new Map<string, any>()
existingList?.forEach?.((p: any) => {
if (p?.name) mapByName.set(String(p.name), p)
})
const pagesToUpdate: Array<{ ps: any; existing: any; name: string }> = []
const pagesToCreate: any[] = []
const duplicateNames: string[] = []
for (const ps of pages) {
const rawName = resolveImportedPageName(ps)
const existing = mapByName.get(rawName)
if (existing) {
pagesToUpdate.push({ ps, existing, name: rawName })
duplicateNames.push(rawName)
} else {
pagesToCreate.push(ps)
}
}
if (duplicateNames.length) {
state.duplicatePages = pagesToUpdate
state.toCreatePages = pagesToCreate
state.pendingImportedPages = pages
state.pendingImportedComponentsMap = []
state.pendingAppSchema = appSchema
state.showOverwriteDialog = true
return
}
await syncImportedAssets(appSchema)
await Promise.allSettled(
pagesToCreate.map((ps: any) =>
getMetaApi(META_SERVICE.Http).post('/app-center/api/pages/create', buildCreateParams(ps))
)
)
const { pageSettingState } = usePage()
await pageSettingState.updateTreeData?.()
} catch (e) {
await syncImportedAssets(appSchema)
await Promise.allSettled(
pages.map((ps: any) =>
getMetaApi(META_SERVICE.Http).post('/app-center/api/pages/create', buildCreateParams(ps))
)
)
const { pageSettingState } = usePage()
await pageSettingState.updateTreeData?.()
}
}
if (!pages.length) {
await syncImportedAssets(appSchema)
}
await applyImportedAppSchema(appSchema, appId, type)
const blockResult = await createAndPublishBlocks(appSchema, appId)
const chosen = pages.find((p: any) => p?.meta?.isHome) || pages[0]
const blockMsg = blockResult?.total
? `,已处理 ${blockResult.total} 个区块${blockResult.updated ? `(更新 ${blockResult.updated})` : ''}`
: ''
if (!chosen) {
useNotify({ type: 'success', title: '导入成功', message: `已更新全局配置(未检测到页面)${blockMsg}` })
} else {
await switchToPageByName(chosen?.meta?.name || chosen?.fileName)
useNotify({
type: 'success',
title: '导入成功',
message: `已创建页面并加载:${chosen?.meta?.name || '页面'}${blockMsg}`
})
}
}
const processSingleFile = async (file: File, converter: VueToDslConverter) => {
const text = await file.text()
const result = await converter.convertFromString(text, file.name)
normalizeImportedSchemaIcons(result?.schema)
normalizeImportedSchema(result?.schema)
await hydrateImportedAppSchemaState({ pageSchema: [result?.schema], blockSchemas: [] })
// 解析单文件页面信息
const rawName = (result?.schema?.meta?.name || file.name).replace(/\.(vue|jsx|tsx)$/i, '')
const fileName = result?.schema?.fileName || rawName
const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
// 检查是否与现有页面重名
try {
const existingList: any[] = await fetchPageList(appId)
const existing = existingList?.find?.((p: any) => String(p?.name) === rawName)
// 将待导入的 schema 作为 ps(与 zip 流程保持一致的数据形态)
const ps: any = {
...result.schema,
meta: { ...(result?.schema?.meta || {}), name: rawName },
fileName
}
if (existing) {
// 重名:弹出覆盖对话框
state.appId = appId
state.duplicatePages = [{ ps, existing, name: rawName }]
state.toCreatePages = []
state.pendingImportedPages = [ps]
state.pendingImportedComponentsMap = result?.componentsMap || []
state.pendingAppSchema = null
state.showOverwriteDialog = true
} else {
// 不重名:直接创建
const createParams: any = {
name: rawName,
route: normalizeImportedRoute(result?.schema?.meta?.router, rawName),
group: 'staticPages',
parentId: '0',
isPage: true,
app: appId,
page_content: {
...result.schema,
fileName
},
message: 'Page auto save',
isBody: false,
isHome: false
}
await getMetaApi(META_SERVICE.Http).post('/app-center/api/pages/create', createParams)
const { pageSettingState } = usePage()
await pageSettingState.updateTreeData?.()
syncImportedComponentsMap(result?.componentsMap || [])
useNotify({ type: 'success', title: '导入成功', message: `已创建新页面:${rawName}` })
}
} catch (e: any) {
// 兜底:如果列表获取失败,按原逻辑创建
const createParams: any = {
name: rawName,
route: normalizeImportedRoute(result?.schema?.meta?.router, rawName),
group: 'staticPages',
parentId: '0',
isPage: true,
app: appId,
page_content: {
...result.schema,
fileName
},
message: 'Page auto save',
isBody: false,
isHome: false
}
await getMetaApi(META_SERVICE.Http).post('/app-center/api/pages/create', createParams)
const { pageSettingState } = usePage()
await pageSettingState.updateTreeData?.()
syncImportedComponentsMap(result?.componentsMap || [])
useNotify({ type: 'success', title: '导入成功', message: `已创建新页面:${rawName}` })
}
}
// 处理上传的 .vue 或 .zip 文件
const handleFileChange = async (e: Event) => {
const input = e.target as HTMLInputElement
const files = input?.files
if (!files || files.length === 0) return
try {
// 转换器实例
const converter = createVueToDslConverter()
// 检查是目录上传还是文件上传
const isDirectory = files[0].webkitRelativePath !== ''
if (isDirectory) {
const appSchema: any = await converter.convertAppFromDirectory(files)
// 后续处理与 zip 包导入类似
await processAppSchema(appSchema)
} else {
const file = files[0]
// zip:转换整个应用;vue:转换单文件
const isZip = /\.zip$/i.test(file.name)
if (isZip) {
const buffer = await file.arrayBuffer()
const appSchema: any = await converter.convertAppFromZip(buffer)
await processAppSchema(appSchema)
} else {
// 单个 vue 文件导入
await processSingleFile(file, converter)
}
}
} catch (error: any) {
// eslint-disable-next-line no-console
console.error(error)
useNotify({ type: 'error', title: '加载失败', message: error?.message || String(error) })
} finally {
// 清空 input 以便可重复选择同一文件
if (fileInputRef.value) fileInputRef.value.value = ''
if (directoryInputRef.value) directoryInputRef.value.value = ''
if (zipInputRef.value) zipInputRef.value.value = ''
}
}
// 覆盖选择:确认
const handleOverwriteConfirm = async (selectedNames: string[]) => {
const { pageSettingState } = usePage()
try {
const appId = state.appId
const { type } = getMetaApi(META_SERVICE.GlobalService).getBaseInfo()
const pendingAppSchema = state.pendingAppSchema
if (pendingAppSchema) {
await syncImportedAssets(pendingAppSchema)
}
const requests: Promise<any>[] = []
// 更新选中的重名项
for (const item of state.duplicatePages) {
if (!selectedNames.includes(item.name)) continue
const ps = item.ps
const existing = item.existing
const rawName = item.name
const updateParams: any = {
...existing,
name: rawName,
route: normalizeImportedRoute(ps?.meta?.router || existing?.route, rawName),
isPage: true,
app: appId,
page_content: { ...ps, fileName: ps?.fileName || rawName },
message: 'Page auto save',
isBody: false,
isHome: false
}
requests.push(getMetaApi(META_SERVICE.Http).post(`/app-center/api/pages/update/${existing.id}`, updateParams))
}
// 创建不重名项
for (const ps of state.toCreatePages) {
const rawName = resolveImportedPageName(ps)
requests.push(
getMetaApi(META_SERVICE.Http).post('/app-center/api/pages/create', {
name: rawName,
route: normalizeImportedRoute(ps?.meta?.router, rawName),
group: 'staticPages',
parentId: '0',
isPage: true,
app: appId,
page_content: { ...ps, fileName: ps?.fileName || rawName },
message: 'Page auto save',
isBody: false,
isHome: false
})
)
}
if (requests.length) {
await Promise.allSettled(requests)
}
const hasOps = requests.length > 0
if (hasOps) {
await pageSettingState.updateTreeData?.()
if (pendingAppSchema) {
await applyImportedAppSchema(pendingAppSchema, appId, type)
} else if (state.pendingImportedComponentsMap?.length) {
syncImportedComponentsMap(state.pendingImportedComponentsMap)
}
const blockResult = pendingAppSchema
? await createAndPublishBlocks(pendingAppSchema, appId)
: { total: 0, created: 0, updated: 0 }
// 用户选择后进行渲染
const pages = state.pendingImportedPages
const chosen = pages.find((p: any) => p?.meta?.isHome) || pages[0]
const blockMsg = blockResult?.total
? `,已处理 ${blockResult.total} 个区块${blockResult.updated ? `(更新 ${blockResult.updated})` : ''}`
: ''
if (chosen) {
await switchToPageByName(chosen?.meta?.name || chosen?.fileName)
useNotify({
type: 'success',
title: '导入成功',
message: `已创建/覆盖页面并加载:${chosen?.meta?.name || '页面'}${blockMsg}`
})
} else {
useNotify({
type: 'success',
title: '导入成功',
message: `已更新全局配置(未检测到页面)${blockMsg}`
})
}
} else {
// 无任何选择且无不重名项:直接跳过
useNotify({ type: 'info', title: '已跳过导入', message: '未选择覆盖任何页面' })
}
} finally {
state.showOverwriteDialog = false
state.duplicatePages = []
state.toCreatePages = []
state.pendingImportedPages = []
state.pendingImportedComponentsMap = []
state.pendingAppSchema = null
}
}
// 覆盖选择:取消
const handleOverwriteCancel = async () => {
state.showOverwriteDialog = false
state.duplicatePages = []
state.toCreatePages = []
state.pendingImportedPages = []
state.pendingImportedComponentsMap = []
state.pendingAppSchema = null
useNotify({ type: 'info', title: '已取消导入', message: '未创建或覆盖任何页面' })
}
return {
state,
OPEN_DELAY,
poperVisible,
clickPopover,
triggerUpload,
handleFileChange,
fileInputRef,
directoryInputRef,
zipInputRef,
handleImportNoticeConfirm,
handleImportNoticeCancel,
handleOverwriteConfirm,
handleOverwriteCancel
}
}
}
</script>
<style lang="less" scoped>
.toolbar-upload-option {
display: flex;
justify-content: center;
flex-direction: column;
.toolbar-upload-item {
cursor: pointer;
line-height: 28px;
margin: 0 -16px;
padding: 0 16px;
&:hover {
background-color: var(--te-toolbars-upload-bg-color-hover);
}
}
}
</style>