diff --git a/docs/README.md b/docs/README.md
index 46f64c6d8..f9c9061ac 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -70,6 +70,7 @@
- [全局布局API](./api/frontend-api/global-layout-api.md)
- [物料API](./api/frontend-api/material-api.md)
- [设置面板API](./api/frontend-api/settings-panel-api.md)
+ - [预览API](./api/frontend-api/preview-api.md)
- 后端API
- [AI功能接口](./api/backend-api/ai-function-api.md)
- [应用管理](./api/backend-api/app-management.md)
diff --git a/docs/api/frontend-api/preview-api.md b/docs/api/frontend-api/preview-api.md
new file mode 100644
index 000000000..600abf2ed
--- /dev/null
+++ b/docs/api/frontend-api/preview-api.md
@@ -0,0 +1,65 @@
+# 页面预览相关配置项
+
+
+## 配置预览页面的跳转 url
+
+默认跳转逻辑(不配置):
+- dev 本地开发:跳转到 preview.html。比如跳转到: `http://localhost:8090/preview.html?...`
+- 生产环境:跳转到 /preview,比如跳转到: `https://opentiny.design/preview?...`
+
+如果二次开发平台想要跳转到不同的路由,则可以进行配置:
+
+### 直接配置 url
+
+适用场景:仅修改跳转 url,不修改 query 查询字符串部分,比如:
+
+```javascript
+import { Preview } from '@opentiny/tiny-engine'
+export default {
+ toolbars: [
+ [Preview, { options: { ...Preview.options, previewUrl: import.meta.env.MODE.includes('prod') ? 'http://tiny-engine-preview.com/customPreview' : '' } }]
+ ]
+}
+```
+
+配置完成之后,在生产环境,TinyEngine 会增加必要的 query部分,然后跳转到配置的 url,比如:`http://tiny-engine-preview.com/customPreview?tenant=1&id=1&...`。
+
+### 使用配置函数
+
+适用场景:需要增加自定义修改 query。可定制性高
+
+例如:
+```javascript
+import { Preview } from '@opentiny/tiny-engine'
+export default {
+ toolbars: [
+ [
+ Preview,
+ {
+ options: {
+ ...Preview.options,
+ previewUrl: (originUrl, query) => {
+ // 这里我们增加了自定义的 query: `test=1`
+ return `http://tiny-engine-preview.com/customPreview?test=1&${query}`
+ }
+ }
+ }
+ ]
+ ]
+}
+```
+
+## 热更新可配置开关
+
+我们在 v2.5 版本中增加预览页面自动刷新的支持,如果您的业务中不需要预览页面自动刷新的热更新功能,可以在注册表中配置 `previewHotReload` 关掉:
+
+```javascript
+// preview.js
+
+initPreview({
+ registry: {
+ config: { id: 'engine.config', theme: 'light', previewHotReload: false },
+ // ... other config
+ }
+})
+```
diff --git a/docs/catalog.json b/docs/catalog.json
index 2e73140f4..3d8a3febc 100644
--- a/docs/catalog.json
+++ b/docs/catalog.json
@@ -125,7 +125,8 @@
{ "title": "画布API", "name": "canvas-api.md" },
{ "title": "全局布局API", "name": "global-layout-api.md" },
{ "title": "物料API", "name": "material-api.md" },
- { "title": "设置面板API", "name": "settings-panel-api.md" }
+ { "title": "设置面板API", "name": "settings-panel-api.md" },
+ { "title": "预览API", "name": "preview-api.md" }
]
},
{
diff --git a/packages/common/js/preview.js b/packages/common/js/preview.js
index 9b04a081d..6a62a749d 100644
--- a/packages/common/js/preview.js
+++ b/packages/common/js/preview.js
@@ -10,53 +10,295 @@
*
*/
-import { constants } from '@opentiny/tiny-engine-utils'
+import { useThrottleFn } from '@vueuse/core'
+import {
+ useMaterial,
+ useResource,
+ useMessage,
+ useCanvas,
+ usePage,
+ useBlock,
+ getMetaApi,
+ META_SERVICE,
+ getMergeMeta
+} from '@opentiny/tiny-engine-meta-register'
+import { utils } from '@opentiny/tiny-engine-utils'
import { isDevelopEnv } from './environments'
-import { useMaterial, useResource } from '@opentiny/tiny-engine-meta-register'
-// prefer old unicode hacks for backward compatibility
-const { COMPONENT_NAME } = constants
+const { deepClone } = utils
-export const utoa = (string) => btoa(unescape(encodeURIComponent(string)))
-
-export const atou = (base64) => decodeURIComponent(escape(atob(base64)))
-
-const open = (params = {}) => {
- const paramsMap = new URLSearchParams(location.search)
- params.app = paramsMap.get('id')
- params.tenant = paramsMap.get('tenant')
+// 保存预览窗口引用
+let previewWindow = null
+const getScriptAndStyleDeps = () => {
const { scripts, styles } = useMaterial().getCanvasDeps()
const utilsDeps = useResource().getUtilsDeps()
- params.scripts = [...scripts, ...utilsDeps].reduce((res, item) => {
+ const scriptsDeps = [...scripts, ...utilsDeps].reduce((res, item) => {
res[item.package] = item.script
return res
}, {})
- params.styles = [...styles]
+ const stylesDeps = [...styles]
+
+ return {
+ scripts: scriptsDeps,
+ styles: stylesDeps
+ }
+}
+
+const getSchemaParams = async () => {
+ const { isBlock, getPageSchema, getCurrentPage, getSchema } = useCanvas()
+ const isBlockPreview = isBlock()
+ const { scripts, styles } = getScriptAndStyleDeps()
+
+ if (isBlockPreview) {
+ const { getCurrentBlock } = useBlock()
+ const block = getCurrentBlock()
+
+ const latestPage = {
+ ...block,
+ page_content: getSchema()
+ }
+
+ return deepClone({
+ currentPage: latestPage,
+ ancestors: [],
+ scripts,
+ styles
+ })
+ }
+
+ const pageSchema = getPageSchema()
+ const currentPage = getCurrentPage()
+ const { getFamily } = usePage()
+ const latestPage = {
+ ...currentPage,
+ page_content: pageSchema
+ }
+
+ const ancestors = await getFamily(latestPage)
+
+ return deepClone({
+ currentPage: latestPage,
+ ancestors,
+ scripts,
+ styles
+ })
+}
+
+// 当 schema 变化时发送更新
+const sendSchemaUpdate = (data) => {
+ previewWindow.postMessage(
+ {
+ source: 'designer',
+ type: 'schema',
+ data
+ },
+ previewWindow.origin || window.location.origin
+ )
+}
+
+let hasSchemaChangeListener = false
+
+const cleanupSchemaChangeListener = () => {
+ const { unsubscribe } = useMessage()
+ unsubscribe({
+ topic: 'schemaChange',
+ subscriber: 'preview-communication'
+ })
+ unsubscribe({
+ topic: 'schemaImport',
+ subscriber: 'preview-communication'
+ })
+ unsubscribe({
+ topic: 'pageOrBlockInit',
+ subscriber: 'preview-communication'
+ })
+ hasSchemaChangeListener = false
+}
+
+const handleSchemaChange = async () => {
+ // 如果预览窗口不存在或已关闭,则取消订阅
+ if (!previewWindow || previewWindow.closed) {
+ cleanupSchemaChangeListener()
+ previewWindow = null
+ return
+ }
+
+ const params = await getSchemaParams()
+ sendSchemaUpdate(params)
+}
+
+// 设置监听 schemaChange 事件,自动发送更新到预览页面
+export const setupSchemaChangeListener = () => {
+ // 如果已经存在监听,则取消之前的监听
+ if (hasSchemaChangeListener) {
+ return
+ }
+
+ const { subscribe } = useMessage()
+
+ subscribe({
+ topic: 'schemaChange',
+ subscriber: 'preview-communication',
+ // 防抖更新,防止因为属性变化频繁触发
+ callback: useThrottleFn(handleSchemaChange, 1000, true)
+ })
+
+ subscribe({
+ topic: 'schemaImport',
+ subscriber: 'preview-communication',
+ callback: useThrottleFn(handleSchemaChange, 1000, true)
+ })
+
+ subscribe({
+ topic: 'pageOrBlockInit',
+ subscriber: 'preview-communication',
+ callback: handleSchemaChange
+ })
+
+ hasSchemaChangeListener = true
+}
+// 监听来自预览页面的消息
+const setupMessageListener = () => {
+ window.addEventListener('message', async (event) => {
+ const parsedOrigin = new URL(event.origin)
+ const parsedHost = new URL(window.location.href)
+ // 确保消息来源安全
+ if (parsedOrigin.origin === parsedHost.origin || parsedOrigin.host === parsedHost.host) {
+ const { event: eventType, source } = event.data || {}
+ // 通过 heartbeat 消息来重新建立连接,避免刷新页面后 previewWindow 为 null
+ if (source === 'preview' && eventType === 'connect' && !previewWindow) {
+ previewWindow = event.source
+ setupSchemaChangeListener()
+ }
+
+ if (source === 'preview' && eventType === 'onMounted' && previewWindow) {
+ const params = await getSchemaParams()
+ sendSchemaUpdate(params)
+ }
+ }
+ })
+
+ // 创建 BroadcastChannel 实例用于通信
+ const previewChannel = new BroadcastChannel('tiny-engine-preview-channel')
+
+ // 可能是刷新,需要重新建立连接
+ previewChannel.postMessage({
+ event: 'connect',
+ source: 'designer'
+ })
+
+ previewChannel.close()
+}
+
+// 初始化消息监听
+setupMessageListener()
+
+const handleHistoryPreview = (params, url) => {
+ let historyPreviewWindow = null
+ const handlePreviewReady = (event) => {
+ if (event.origin === window.location.origin || event.origin.includes(window.location.hostname)) {
+ const { event: eventType, source } = event.data || {}
+ if (source === 'preview' && eventType === 'onMounted' && historyPreviewWindow) {
+ const { scripts, styles, ancestors = [], ...rest } = params
+
+ historyPreviewWindow.postMessage(
+ {
+ source: 'designer',
+ type: 'schema',
+ data: deepClone({
+ currentPage: rest,
+ ancestors,
+ scripts,
+ styles
+ })
+ },
+ previewWindow.origin || window.location.origin
+ )
+
+ // 历史页面不需要实时更新预览,发送完消息后移除监听
+ window.removeEventListener('message', handlePreviewReady)
+ }
+ }
+ }
+
+ window.addEventListener('message', handlePreviewReady)
+
+ historyPreviewWindow = window.open(url, '_blank')
+}
+
+const getQueryParams = (params = {}, isHistory = false) => {
+ const paramsMap = new URLSearchParams(location.search)
+ const tenant = paramsMap.get('tenant') || ''
+ const pageId = paramsMap.get('pageid')
+ const blockId = paramsMap.get('blockid')
+ const theme = getMetaApi(META_SERVICE.ThemeSwitch)?.getThemeState()?.theme
+ const framework = getMergeMeta('engine.config')?.dslMode
+ const platform = getMergeMeta('engine.config')?.platformId
+ const { scripts, styles } = getScriptAndStyleDeps()
+
+ let query = `tenant=${tenant}&id=${paramsMap.get('id')}&theme=${theme}&framework=${framework}`
+
+ query += `&platform=${platform}&scripts=${JSON.stringify(scripts)}&styles=${JSON.stringify(styles)}`
+
+ if (pageId) {
+ query += `&pageid=${pageId}`
+ }
+
+ if (blockId) {
+ query += `&blockid=${blockId}`
+ }
+
+ if (isHistory) {
+ query += `&history=${params.history}`
+ }
+
+ return query
+}
+
+const open = (params = {}, isHistory = false) => {
const href = window.location.href.split('?')[0] || './'
- const tenant = new URLSearchParams(location.search).get('tenant') || ''
+ const { scripts, styles } = getScriptAndStyleDeps()
+ const query = getQueryParams(params, isHistory)
+
let openUrl = ''
- const hashString = utoa(JSON.stringify(params))
- openUrl = isDevelopEnv
- ? `./preview.html?tenant=${tenant}#${hashString}`
- : `${href.endsWith('/') ? href : `${href}/`}preview?tenant=${tenant}#${hashString}`
+ // 从预览组件配置获取自定义URL
+ const customPreviewUrl = getMergeMeta('engine.toolbars.preview')?.options?.previewUrl
+ const defaultPreviewUrl = isDevelopEnv ? `./preview.html` : `${href.endsWith('/') ? href : `${href}/`}preview`
- const aTag = document.createElement('a')
- aTag.href = openUrl
- aTag.target = '_blank'
- aTag.click()
-}
+ if (customPreviewUrl) {
+ // 如果配置了自定义预览URL,则使用自定义URL
+ openUrl =
+ typeof customPreviewUrl === 'function'
+ ? customPreviewUrl(defaultPreviewUrl, query)
+ : `${customPreviewUrl}?${query}`
+ } else {
+ // 否则使用默认生成的URL
+ openUrl = `${defaultPreviewUrl}?${query}`
+ }
+
+ if (isHistory) {
+ handleHistoryPreview({ ...params, scripts, styles }, openUrl)
+ return
+ }
+
+ if (previewWindow && !previewWindow.closed) {
+ // 如果预览窗口存在,则聚焦预览窗口
+ previewWindow.focus()
+ return
+ }
+
+ // 打开新窗口并保存引用
+ previewWindow = window.open(openUrl, '_blank')
-export const previewPage = (params = {}) => {
- params.type = COMPONENT_NAME.Page
- open(params)
+ // 设置 schemaChange 事件监听
+ setupSchemaChangeListener()
}
-export const previewBlock = (params = {}) => {
- params.type = COMPONENT_NAME.Block
- open(params)
+export const previewPage = (params = {}, isHistory = false) => {
+ open(params, isHistory)
}
diff --git a/packages/common/package.json b/packages/common/package.json
index 6985ae98e..c8b795e9b 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -39,6 +39,7 @@
"@opentiny/tiny-engine-meta-register": "workspace:*",
"@opentiny/tiny-engine-utils": "workspace:*",
"@vue/shared": "^3.3.4",
+ "@vueuse/core": "^9.6.0",
"axios": "~0.28.0",
"css-tree": "^2.3.1",
"eslint-linter-browserify": "8.57.0",
@@ -50,7 +51,6 @@
"@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*",
"@vitejs/plugin-vue": "^5.1.2",
"@vitejs/plugin-vue-jsx": "^4.0.1",
- "@vueuse/core": "^9.6.0",
"glob": "^10.3.4",
"vite": "^5.4.2"
},
diff --git a/packages/design-core/package.json b/packages/design-core/package.json
index f8ca8ee27..98e296d95 100644
--- a/packages/design-core/package.json
+++ b/packages/design-core/package.json
@@ -103,6 +103,7 @@
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
+ "@types/babel__core": "^7.20.5",
"@types/node": "^18.0.0",
"@vitejs/plugin-vue": "^5.1.2",
"@vitejs/plugin-vue-jsx": "^4.0.1",
diff --git a/packages/design-core/src/preview/src/Toolbar.vue b/packages/design-core/src/preview/src/Toolbar.vue
index 3c5c7c105..b6d1e95c9 100644
--- a/packages/design-core/src/preview/src/Toolbar.vue
+++ b/packages/design-core/src/preview/src/Toolbar.vue
@@ -14,11 +14,13 @@
'
+ }
+
+ const transformedScript = transformSync(p1, {
+ babelrc: false,
+ plugins: [[vueJsx, { pragma: 'h' }]],
+ sourceMaps: false,
+ configFile: false
+ })
+
+ const res = `'
+
+ return `${res}${endTag}`
+ })
+
+ newFiles[panelName] = newPanelValue
+ }
+
+ // 根据新的参数更新预览
+ const updatePreview = async (params: { currentPage: IPage; ancestors: IPage[] }) => {
+ const { appData, metaData, importMapData } = await getBasicData(basicFiles)
+
+ previewState.currentPage = params.currentPage
+ previewState.ancestors = params.ancestors
+
+ // importMap 发生变化才更新 importMap
+ if (JSON.stringify(previewState.importMap) !== JSON.stringify(importMapData)) {
+ store.setImportMap(importMapData)
+ previewState.importMap = importMapData
+ }
+
+ const blockSet = new Set()
+
+ let blocks = []
+ const { getAllNestedBlocksSchema, generatePageCode } = getMetaApi('engine.service.generateCode')
+
+ if (params.ancestors?.length) {
+ const promises = params.ancestors.map((item) =>
+ getAllNestedBlocksSchema(item.page_content, fetchBlockSchema, blockSet)
+ )
+ blocks = (await Promise.all(promises)).flat()
+ }
+
+ const currentPageBlocks = await getAllNestedBlocksSchema(
+ params.currentPage?.page_content || {},
+ fetchBlockSchema,
+ blockSet
+ )
+ blocks = blocks.concat(currentPageBlocks)
+
+ const pageCode = [
+ ...getPageAncestryFiles(appData, params),
+ ...(blocks || []).map((blockSchema) => {
+ return {
+ panelName: `${blockSchema.fileName}.vue`,
+ panelValue: generatePageCode(blockSchema, appData?.componentsMap || [], { blockRelativePath: './' }) || '',
+ panelType: 'vue'
+ }
+ })
+ ]
+
+ const newFiles = store.getFiles()
+ const searchParams = new URLSearchParams(location.search)
+ const appJsCode = processAppJsCode(newFiles['app.js'], JSON.parse(searchParams.get('styles') || '[]'))
+
+ newFiles['app.js'] = appJsCode
+
+ pageCode.map(fixScriptLang).forEach((item) => assignFiles(item, newFiles))
+
+ const metaFiles = generateMetaFiles(metaData)
+ Object.assign(newFiles, metaFiles)
+
+ setFiles(newFiles)
+ }
+
+ const loadInitialData = async () => {
+ const { currentPage, ancestors } = await getPageOrBlockByApi()
+ previewState.currentPage = currentPage
+ previewState.ancestors = ancestors
+
+ if (currentPage) {
+ updatePreview({ currentPage, ancestors })
+ }
+ }
+
+ return {
+ loadInitialData,
+ updateUrl,
+ updatePreview
+ }
+}
diff --git a/packages/plugins/block/src/BlockSetting.vue b/packages/plugins/block/src/BlockSetting.vue
index c8dc7d382..2a022ecbe 100644
--- a/packages/plugins/block/src/BlockSetting.vue
+++ b/packages/plugins/block/src/BlockSetting.vue
@@ -87,16 +87,9 @@