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 @@