diff --git a/designer-demo/package.json b/designer-demo/package.json index f1fcb867e9..248aab5d45 100644 --- a/designer-demo/package.json +++ b/designer-demo/package.json @@ -13,6 +13,7 @@ "dependencies": { "@opentiny/tiny-engine": "workspace:^", "@opentiny/tiny-engine-meta-register": "workspace:^", + "@opentiny/tiny-engine-runtime-renderer": "workspace:*", "@opentiny/tiny-engine-utils": "workspace:*", "@opentiny/vue": "~3.20.0", "@opentiny/vue-design-smb": "~3.20.0", diff --git a/designer-demo/public/opentiny-tinyengine-logo.svg b/designer-demo/public/opentiny-tinyengine-logo.svg new file mode 100644 index 0000000000..1f186f1639 --- /dev/null +++ b/designer-demo/public/opentiny-tinyengine-logo.svg @@ -0,0 +1,18 @@ + + + 编组 10备份 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/designer-demo/runtime.html b/designer-demo/runtime.html new file mode 100644 index 0000000000..cc2000a149 --- /dev/null +++ b/designer-demo/runtime.html @@ -0,0 +1,13 @@ + + + + + + + Runtime Render + + +
+ + + diff --git a/designer-demo/src/runtime.js b/designer-demo/src/runtime.js new file mode 100644 index 0000000000..89b947a911 --- /dev/null +++ b/designer-demo/src/runtime.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ +import { initRuntimeRenderer } from '@opentiny/tiny-engine-runtime-renderer' + +async function startApp() { + try { + await initRuntimeRenderer() + } + catch (error) { + //eslint-disable-next-line no-console + console.error('Failed to initialize runtime renderer:',error) + } +} + +startApp() diff --git a/docs/solutions/imgs/runtime-designer-display.png b/docs/solutions/imgs/runtime-designer-display.png new file mode 100644 index 0000000000..9d06427226 Binary files /dev/null and b/docs/solutions/imgs/runtime-designer-display.png differ diff --git a/docs/solutions/imgs/runtime-entry.png b/docs/solutions/imgs/runtime-entry.png new file mode 100644 index 0000000000..31e926bef2 Binary files /dev/null and b/docs/solutions/imgs/runtime-entry.png differ diff --git a/docs/solutions/imgs/runtime-import-map.png b/docs/solutions/imgs/runtime-import-map.png new file mode 100644 index 0000000000..bc2c8c2e9f Binary files /dev/null and b/docs/solutions/imgs/runtime-import-map.png differ diff --git a/docs/solutions/imgs/runtime-runtime-display.gif b/docs/solutions/imgs/runtime-runtime-display.gif new file mode 100644 index 0000000000..c107c299f5 Binary files /dev/null and b/docs/solutions/imgs/runtime-runtime-display.gif differ diff --git a/docs/solutions/imgs/runtime-runtime-display.png b/docs/solutions/imgs/runtime-runtime-display.png new file mode 100644 index 0000000000..ce966369a5 Binary files /dev/null and b/docs/solutions/imgs/runtime-runtime-display.png differ diff --git a/docs/solutions/runtime-rendering-solution.md b/docs/solutions/runtime-rendering-solution.md new file mode 100644 index 0000000000..d79e2fa86e --- /dev/null +++ b/docs/solutions/runtime-rendering-solution.md @@ -0,0 +1,69 @@ +# 运行时渲染器使用说明 + +--- + +## 前言 +运行时渲染器用于在浏览器中直接渲染低代码 Schema,提供与“出码”并行的即时运行路径,可在设计阶段获得接近真实的交互与数据效果。 + +## 快速开始 + +### 环境准备 +- 确保已拉取包含 runtime-renderer 包的新版本代码。 +- 在项目根目录执行: + - `pnpm install` 安装依赖 + - `pnpm run dev` 启动项目 + 或参考前后端联调[文档](https://opentiny.design/tiny-engine#/help-center/course/dev/debugging-of-java-backend)或[视频](https://www.bilibili.com/video/BV1TpZ5YqEKZ/?share_source=copy_web&vd_source=bed5a07195ea4a97bd9d6ccea9d8e3e3)来启动JAVA后端联调,获得更好的开发体验 + +### 启动运行时渲染器 +- 在设计器界面,点击顶部工具栏的“运行时渲染”图标(见下图),系统会在新窗口中打开运行时页面。 +![入口图标](./imgs/runtime-entry.png) +- 默认行为: + - 若当前正在编辑某页面,将自动路由至该页面; + - 若当前未编辑页面(如正在编辑区块),将自动跳转到首页。 +- 在项目启动的情况下,直接在浏览器中输入正确的url也可以访问应用页面,无需点击图标入口 + +### 运行效果说明 +下图为同一页面在设计器与运行时渲染器中的对比效果: + +- 设计器效果 +![设计器中效果](./imgs/runtime-designer-display.png) + +- 运行时渲染器效果 +![运行时渲染效果](./imgs/runtime-runtime-display.gif) + +### URL 与路由说明 + +- 查询参数 + - id:应用标识 + - tenant:租户标识 + - platform:平台标识 +- 哈希路由 + - 若当前正在编辑某页面,将自动路由至该页面,基于页面树中每个节点的 route 段,按祖先链拼接为 `#///`。 + - 若当前未编辑页面(如正在编辑区块),默认跳转应用首页。 + +- 入口地址 + - 开发环境:`/runtime.html` + - 生产环境:`/runtime` + +- 访问示例 + - Dev: `http://localhost:8090/runtime.html?id=1&tenant=1&platform=1#/home` + - Prod: `https://your-host/runtime?id=1&tenant=1&platform=1#/home` + +### 物料与依赖导入说明 + +当前运行时通过 bundle.json 读取物料包的 package 信息,支持从中读取用户添加的第三方物料依赖信息,无需额外手动导入。在资源管理中添加过cdn链接的npm包也无需额外引入。 + +如果第三方CDN包含子依赖,则需要手动在以下文件中补充 CDN 映射,支持完整cdn链接和包含占位符的格式: + +- 文件路径:`packages/runtime-renderer/src/app-function/import-map.json` + +示例:物料中使用 HUICharts 且其内部依赖 `echarts`,需为 `echarts` 在runtime-renderer 的 import-map.json 中添加映射: +```json +// filepath: packages/runtime-renderer/src/app-function/import-map.json +{ + "imports": { + "echarts": "${VITE_CDN_DOMAIN}/echarts${versionDelimiter}5.4.1${fileDelimiter}/dist/echarts.esm.js" + }, + "importStyles": {} +} +``` \ No newline at end of file diff --git a/packages/build/vite-config/src/canvas-dev-external.js b/packages/build/vite-config/src/canvas-dev-external.js index 464f11d7f6..86db5844fc 100644 --- a/packages/build/vite-config/src/canvas-dev-external.js +++ b/packages/build/vite-config/src/canvas-dev-external.js @@ -1,37 +1,38 @@ import vitePluginExternalize from 'vite-plugin-externalize-dependencies' import { genImportMapPlugin } from './vite-plugins/genImportMapOnly.js' -export function canvasDevExternal(override = {}) { - const prefix = '/node_modules/@opentiny/tiny-engine' - // 以下内容由于区块WebComponent加载需要补充 - const blockRequire = { - externals: [/^@opentiny\/vue$/, /^@opentiny\/vue-icon$/], +export const prefix = '/node_modules/@opentiny/tiny-engine' +export const dependencies = { + base: { imports: { - '@opentiny/vue': `${prefix}/node_modules/@opentiny/vue-runtime/dist3/tiny-vue-pc.mjs`, - '@opentiny/vue-icon': `${prefix}/node_modules/@opentiny/vue-runtime/dist3/tiny-vue-icon.mjs` + vue: `${prefix}/node_modules/vue/dist/vue.runtime.esm-browser.js`, + 'vue-i18n': `${prefix}/node_modules/vue-i18n/dist/vue-i18n.esm-browser.js` }, - importStyles: [`${prefix}/node_modules/@opentiny/vue-theme/index.css`] - } - // 以下内容由于物料协议不支持声明子依赖而@opentiny/vue需要依赖所以需要补充 - const tinyVueRequire = { + externals: [/^vue$/, /^vue-i18n$/] + }, + ui: { imports: { + '@opentiny/vue': `${prefix}/node_modules/@opentiny/vue-runtime/dist3/tiny-vue-pc.mjs`, + '@opentiny/vue-icon': `${prefix}/node_modules/@opentiny/vue-runtime/dist3/tiny-vue-icon.mjs`, '@opentiny/vue-common': `${prefix}/node_modules/@opentiny/vue-runtime/dist3/tiny-vue-common.mjs`, '@opentiny/vue-locale': `${prefix}/node_modules/@opentiny/vue-runtime/dist3/tiny-vue-locale.mjs` - } + }, + externals: [/^@opentiny\/vue$/, /^@opentiny\/vue-icon$/, /^@opentiny\/vue-common$/, /^@opentiny\/vue-locale$/], + importStyles: [`${prefix}/node_modules/@opentiny/vue-theme/index.css`] } +} +export function canvasDevExternal(override = {}) { return [ - vitePluginExternalize({ externals: [/^vue$/, /^vue-i18n$/, ...blockRequire.externals] }), + vitePluginExternalize({ externals: [...dependencies.base.externals, ...dependencies.ui.externals] }), genImportMapPlugin( { imports: { - vue: `${prefix}/node_modules/vue/dist/vue.runtime.esm-browser.js`, - 'vue-i18n': `${prefix}/node_modules/vue-i18n/dist/vue-i18n.esm-browser.js`, - ...blockRequire.imports, - ...tinyVueRequire.imports, + ...dependencies.base.imports, + ...dependencies.ui.imports, ...override } }, - [...blockRequire.importStyles] + [...dependencies.ui.importStyles] ) ] } diff --git a/packages/build/vite-config/src/default-config.js b/packages/build/vite-config/src/default-config.js index f713702581..010bf4e148 100644 --- a/packages/build/vite-config/src/default-config.js +++ b/packages/build/vite-config/src/default-config.js @@ -12,8 +12,9 @@ import generateComment from '@opentiny/tiny-engine-vite-plugin-meta-comments' import { getBaseUrlFromCli, copyBundleDeps, importMapLocalPlugin } from './localCdnFile/index.js' import { devAliasPlugin } from './vite-plugins/devAliasPlugin.js' import { htmlUpgradeHttpsPlugin } from './vite-plugins/upgradeHttpsPlugin.js' -import { canvasDevExternal } from './canvas-dev-external.js' import { treeShakingPlugin } from './vite-plugins/treeShakingPlugin.js' +import { canvasDevExternal } from './canvas-dev-external.js' +import { runtimeExternal } from './runtime-external.js' const monacoEditorPlugin = monacoEditorPluginCjs.default const nodeGlobalsPolyfillPlugin = nodeGlobalsPolyfillPluginCjs.default @@ -104,7 +105,8 @@ const getDefaultConfig = (engineConfig) => { plugins: [nodePolyfill({ include: null })], // 使用@rollup/plugin-inject的默认值{include: null}, 即在所有代码中生效 input: { index: path.resolve(process.cwd(), './index.html'), - preview: path.resolve(process.cwd(), './preview.html') + preview: path.resolve(process.cwd(), './preview.html'), + runtime: path.resolve(process.cwd(), './runtime.html') }, output: { manualChunks: (id) => { @@ -187,5 +189,9 @@ export function useTinyEngineBaseConfig(engineConfig) { config.plugins.push(canvasDevExternal()) } + if (engineConfig.useSourceAlias && command !== 'serve') { + config.plugins.push(runtimeExternal()) + } + return config } diff --git a/packages/build/vite-config/src/runtime-external.js b/packages/build/vite-config/src/runtime-external.js new file mode 100644 index 0000000000..d051137f3a --- /dev/null +++ b/packages/build/vite-config/src/runtime-external.js @@ -0,0 +1,84 @@ +import { dependencies } from './canvas-dev-external.js' + +/** + * 嵌入 + + diff --git a/packages/runtime-renderer/src/components/BlockLoading.vue b/packages/runtime-renderer/src/components/BlockLoading.vue new file mode 100644 index 0000000000..b74bebdc27 --- /dev/null +++ b/packages/runtime-renderer/src/components/BlockLoading.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/runtime-renderer/src/components/Loading.vue b/packages/runtime-renderer/src/components/Loading.vue new file mode 100644 index 0000000000..488b977c28 --- /dev/null +++ b/packages/runtime-renderer/src/components/Loading.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/runtime-renderer/src/components/NotFound.vue b/packages/runtime-renderer/src/components/NotFound.vue new file mode 100644 index 0000000000..2ce993ed27 --- /dev/null +++ b/packages/runtime-renderer/src/components/NotFound.vue @@ -0,0 +1,163 @@ + + + + + + diff --git a/packages/runtime-renderer/src/components/PageRenderer.ts b/packages/runtime-renderer/src/components/PageRenderer.ts new file mode 100644 index 0000000000..172f65907e --- /dev/null +++ b/packages/runtime-renderer/src/components/PageRenderer.ts @@ -0,0 +1,13 @@ +import { defineComponent, h } from 'vue' +import RenderMain from '../renderer/RenderMain' +import { CanvasRouterView } from '../renderer/builtin' + +export function withPageRenderer(props: any) { + const Component = props.isPage ? RenderMain : CanvasRouterView + return defineComponent({ + name: 'PageRendererHOC', + render() { + return h(Component, { pageId: props.pageId, key: props.pageId }) + } + }) +} diff --git a/packages/runtime-renderer/src/composables/service.ts b/packages/runtime-renderer/src/composables/service.ts new file mode 100644 index 0000000000..dcbf04e6b4 --- /dev/null +++ b/packages/runtime-renderer/src/composables/service.ts @@ -0,0 +1,73 @@ +import type { BlockItem, IBlockItem } from '../types' + +export async function fetchAppSchema(id: string | number) { + let appSchema = {} + try { + const res: any = await fetch(`/app-center/v1/api/apps/schema/${id}`).then((res) => res.json()) + appSchema = res?.data || {} + } catch (error) { + // eslint-disable-next-line no-console + console.error('获取应用Schema信息错误:', error) + } + return appSchema +} + +export async function fetchAppPackages(pkgUrl: string) { + let packages = [] + try { + const bundleJson = await fetch(pkgUrl).then((res) => res.json()) + packages = bundleJson?.data?.materials?.packages || [] + } catch (error) { + // eslint-disable-next-line no-console + console.error('获取应用物料包错误:', error) + } + return packages +} + +export async function fetchAppPages(id: string | number) { + let pages = [] + try { + const res: any = await fetch(`/app-center/api/pages/list/${id}`).then((res) => res.json()) + pages = res?.data || [] + } catch (error) { + // eslint-disable-next-line no-console + console.error('获取应用页面错误:', error) + } + return pages +} +export async function fetchAllBlocks() { + const blocksMap: Record = {} + try { + const res: any = await fetch('/material-center/api/blocks').then((res) => res.json()) + const blocks: BlockItem[] = res?.data || [] + blocks.forEach((block) => { + if (block.content) { + blocksMap[block.label] = { + schema: block.content, + meta: { + id: block.id, + label: block.label, + framework: block.framework, + version: block.version + } + } + } + }) + } catch (error) { + // eslint-disable-next-line no-console + console.error('获取所有区块错误:', error) + } + return blocksMap +} + +export async function fetchBlockByName(name: string) { + let block = {} + try { + const res: any = await fetch(`/material-center/api/block?label=${name}`).then((res) => res.json()) + block = res?.data?.[0] || {} + } catch (error) { + // eslint-disable-next-line no-console + console.error(`获取区块[${name}]错误:`, error) + } + return block +} diff --git a/packages/runtime-renderer/src/composables/useAppSchema.ts b/packages/runtime-renderer/src/composables/useAppSchema.ts new file mode 100644 index 0000000000..5331aa71db --- /dev/null +++ b/packages/runtime-renderer/src/composables/useAppSchema.ts @@ -0,0 +1,147 @@ +import { ref, computed, readonly } from 'vue' +import i18n from '@opentiny/tiny-engine-common/js/i18n' +import type { IAppSchema, Util, I18nConfig, ComponentMap, PackageConfig } from '../types/index.ts' +import { addTagTask, getComponents, initDataSource, initImportMap, initUtils } from '../renderer/app-function/index.ts' +import { fetchAppSchema, fetchAppPackages, fetchAppPages, fetchBlockByName } from './service.ts' +import config from '../../config.ts' + +const appSchema = ref(null) +const isLoading = ref(false) +const error = ref(null) +window.TinyLowcodeComponent = {} +window.TinyComponentLibs = {} + +export function useAppSchema() { + const initComponentsMap = async (componentsMap: ComponentMap[]) => { + const packages = appSchema.value?.packages || [] + // 获取组件依赖 + const componentsDeps: any = packages.map((pkg: PackageConfig) => ({ + ...pkg, + components: componentsMap.filter((comp) => comp.package === pkg.package) + })) + const styles = packages.map((pkg) => pkg.css).filter((css) => css) as string[] + await Promise.all([ + ...componentsDeps.map(getComponents), + ...styles.map((link) => + addTagTask({ + href: link, + tag: 'link', + type: config.enableTailwindCSS ? 'text/tailwindcss' : 'text/css' + }) + ) + ]) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('组件或资源加载失败:', err) + }) + .finally(() => {}) + } + + // 初始化工具函数 + const initializeUtils = async (utils: Util[]) => { + try { + await initUtils(utils) + } catch (error) { + // eslint-disable-next-line no-console + console.error('工具函数初始化失败:', error) + } + } + + const initializeI18n = (i18nConfig: I18nConfig) => { + if (!i18nConfig) return + Object.entries(i18nConfig).forEach(([loc, msgs]) => { + i18n.global.mergeLocaleMessage(loc, msgs as any) + }) + } + + // 注入全局CSS + const initGlobalCSS = async (css: string) => { + if (!css) return + await addTagTask({ + tag: 'style', + textContent: css, + id: 'app-global-css', + type: config.enableTailwindCSS ? 'text/tailwindcss' : 'text/css' + }) + } + + // 初始化应用配置 + const setAppConfig = async (schema: IAppSchema) => { + if (!schema?.pages) return + // 初始化 importMap + await initImportMap() + // 初始化除tinyVue之外的nativeComponents + await initComponentsMap(schema.componentsMap || []) + // 初始化工具函数 + await initializeUtils(schema?.utils) + // 初始化国际化 + initializeI18n(schema?.i18n) + // 初始化数据源 + initDataSource(schema?.dataSource) + // 注入全局CSS + initGlobalCSS(schema?.css) + } + + // 初始化应用数据 + const initAppData = async (id: string) => { + await Promise.all([fetchAppSchema(id), fetchAppPages(id), fetchAppPackages(config.material[0])]).then((rss) => { + const [schema, pages, packages] = rss + appSchema.value = { ...appSchema.value!, ...schema, pages, packages, blocks: {} } + }) + await setAppConfig(appSchema.value!) + } + + // 获取页面列表 + const pages = computed(() => { + return appSchema.value?.pages || [] + }) + // 根据ID获取页面 + const getPageById = (id: string) => { + if (!pages.value) return null + return pages.value.find((page) => page.id === id) || null + } + + // 获取数据源配置 + const dataSourceConfig = computed(() => { + return appSchema.value?.dataSource || {} + }) + + // 获取全局状态配置 + const globalStates = computed(() => { + return appSchema.value?.meta?.globalState || [] + }) + + // 获取包依赖 + const packages = computed(() => { + return appSchema.value?.packages || [] + }) + + // 检查应用是否已加载 + const isAppLoaded = computed(() => { + return !!appSchema.value + }) + + const i18nConfig = computed(() => { + return appSchema.value?.i18n || {} + }) + + return { + // 状态 + appSchema: readonly(appSchema), + isLoading: readonly(isLoading), + error: readonly(error), + + // 计算属性 + pages, + dataSourceConfig, + globalStates, + packages, + isAppLoaded, + i18nConfig, + + // 方法 + initAppData, + getPageById, + fetchBlockByName + } +} diff --git a/packages/runtime-renderer/src/renderer/RenderMain.ts b/packages/runtime-renderer/src/renderer/RenderMain.ts new file mode 100644 index 0000000000..5572fd59c7 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/RenderMain.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ +import { defaultRenderer } from './render.ts' +import { useContextPage } from './context/index.ts' +import { useLowcode } from './context/useLowcode.ts' +import { useAppSchema } from '../composables/useAppSchema.ts' +import type { PageContent as Schema } from '../types/index.ts' +import { computed, provide, reactive, watch, defineComponent } from 'vue' +import { I18nInjectionKey } from '@opentiny/tiny-engine-common/js/i18n' + +interface Props { + pageId: string +} + +export default defineComponent({ + name: 'RenderMain', + props: { + pageId: { + type: [String, Number], + default: '0' + } + }, + setup(props: Props, ctx: any) { + const { getPageById } = useAppSchema() + // 通过 pageId 获取最新的页面对象 + const currentSchema = computed(() => { + const page = getPageById(props.pageId) + const pageContent = page?.page_content + if (!pageContent) return null + return JSON.parse(JSON.stringify(pageContent)) + }) + const pageSchema = reactive({} as Schema) + // TODO 暂时置空解决区块编译后获取报错问题 + provide('page-ancestors', []) + // 提供翻译及区块Lowcode函数上下文 + const { TinyI18nHost } = useLowcode() + provide(I18nInjectionKey, TinyI18nHost) + // 提供页面级上下文 + const { state, methods, context, initContext } = useContextPage() + provide('pageContext', context) + const initPage = async (newSchema: Schema) => { + initContext({ schema: newSchema, props: props, ctx }, () => { + Object.assign(pageSchema, newSchema) + }) + } + // 监听 schema 变化 + watch( + () => currentSchema.value, + (schema) => { + if (schema && Object.keys(schema).length !== 0) { + initPage(JSON.parse(JSON.stringify(schema))) + } + }, + { immediate: true } + ) + return { + pageSchema, + methods, + state + } + }, + render(): any { + return defaultRenderer(this.pageSchema as any) + } +}) diff --git a/packages/runtime-renderer/src/renderer/app-function/constant.ts b/packages/runtime-renderer/src/renderer/app-function/constant.ts new file mode 100644 index 0000000000..c389b4e20b --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/constant.ts @@ -0,0 +1,4 @@ +export const NODE_UID = 'data-uid' +export const NODE_TAG = 'data-tag' +export const NODE_LOOP = 'loop-id' +export const NODE_INACTIVE_UID = 'data-ia-uid' diff --git a/packages/runtime-renderer/src/renderer/app-function/dataSource/http.js b/packages/runtime-renderer/src/renderer/app-function/dataSource/http.js new file mode 100644 index 0000000000..624fdd3327 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/dataSource/http.js @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import Axios from 'axios' + +const config = { withCredentials: false } + +const axios = (config) => { + const instance = Axios.create(config) + const defaults = {} + return { + request(config) { + return instance(config) + }, + defaults(key, value) { + if (key && typeof key === 'string') { + if (typeof value === 'undefined') { + return instance.defaults[key] + } + instance.defaults[key] = value + defaults[key] = value + } else { + return instance.defaults + } + }, + defaultSettings() { + return defaults + }, + interceptors: { + request: { + use(fnHandle, fnError) { + return instance.interceptors.request.use(fnHandle, fnError) + }, + eject(id) { + return instance.interceptors.request.eject(id) + } + }, + response: { + use(fnHandle, fnError) { + return instance.interceptors.response.use(fnHandle, fnError) + }, + eject(id) { + return instance.interceptors.response.eject(id) + } + } + }, + CancelToken: Axios.CancelToken, + isCancel: Axios.isCancel + } +} + +export default ({ globalWillFetch, globalDataHandle, globalErrorHandler, willFetch, dataHandler, errorHandler }) => { + const http = axios(config) + // axios对于request拦截器是后注册先执行 + http.interceptors.request.use(willFetch, errorHandler) + http.interceptors.request.use(globalWillFetch, globalErrorHandler) + http.interceptors.response.use(dataHandler, errorHandler) + http.interceptors.response.use(globalDataHandle, globalErrorHandler) + return http +} diff --git a/packages/runtime-renderer/src/renderer/app-function/dataSource/index.ts b/packages/runtime-renderer/src/renderer/app-function/dataSource/index.ts new file mode 100644 index 0000000000..6f1e941d1d --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/dataSource/index.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import useHttp from './http.js' +const dataSourceMap: Record = {} + +const createFn = (fnStr: string) => { + return (...args: any) => { + const fn = new Function(`return ${fnStr}`)() + return fn.apply(this, args) + } +} + +export const initDataSource = (dataSources: any) => { + const globalWillFetch = dataSources.willFetch ? createFn(dataSources.willFetch.value) : (opt: any) => opt + const globalDataHandle = dataSources.dataHandler ? createFn(dataSources.dataHandler.value) : (res: any) => res + const globalErrorHandler = dataSources.errorHandler + ? createFn(dataSources.errorHandler.value) + : (err: any) => Promise.reject(err) + const execProxy = (config: any) => { + // TODO 通过全局配置代理, 通过代理服务器的方式获取接口数据,解决跨域问题 + const appId = new URLSearchParams(location.search).get('id') + const { proxy = {} } = dataSources || {} + if (proxy) { + const isProxy = Object.keys(proxy).reduce((acc, cur) => acc || config.url.startsWith(cur), false) + if (isProxy) { + config.url = `/proxy/api${config.url}` + config.headers = { ...config.headers, proxy_app_id: appId || 1 } + } + } + } + + const load = + (http: any, options: any, dataSource: any, shouldFetch: any) => (params: any, path: any, customConfig: any) => { + // 如果没有配置远程请求,则直接返回静态数据,返回前可能会有全局数据处理 + if (!options) { + return Promise.resolve(globalDataHandle(dataSource.config.data)) + } + + if (!shouldFetch()) { + return Promise.resolve(undefined) + } + + dataSource.status = 'loading' + + const { method, uri: url, params: defaultParams, timeout, headers } = options + const config = { method, url, headers, timeout, ...customConfig } + + const data = params || defaultParams + + config.url = path ? `${config.url}/${path}` : config.url + + execProxy(config) + + if (['get', 'delete'].includes(method.toLowerCase())) { + config.params = data + } else { + config.data = data + } + + return http.request(config) + } + + if (Array.isArray(dataSources.list)) { + dataSources.list?.forEach((conf: any) => { + const config = { name: conf.name, ...(conf.data || {}) } + const dataSource: any = { config } + dataSourceMap[config.name] = dataSource + const shouldFetch = config.shouldFetch?.value ? createFn(config.shouldFetch.value) : () => true + const willFetch = config.willFetch?.value ? createFn(config.willFetch.value) : (opt: any) => opt + const dataHandler = (res: any) => { + const data = config.dataHandler?.value ? createFn(config.dataHandler.value)(res) : res + dataSource.status = 'loaded' + dataSource.data = data + return data + } + const errorHandler = (error: any) => { + const err = config.errorHandler?.value ? createFn(config.errorHandler.value)(error) : error + dataSource.status = 'error' + dataSource.error = err + } + const http = useHttp({ + globalWillFetch, + globalDataHandle, + globalErrorHandler, + willFetch, + dataHandler, + errorHandler + }) + + dataSource.status = 'init' + dataSource.load = load(http, config.options, dataSource, shouldFetch) + }) + } +} + +export const getDataSource = () => dataSourceMap + +export default dataSourceMap diff --git a/packages/runtime-renderer/src/renderer/app-function/importMap.ts b/packages/runtime-renderer/src/renderer/app-function/importMap.ts new file mode 100644 index 0000000000..cebd669165 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/importMap.ts @@ -0,0 +1,158 @@ +import { importMapConfig } from '@opentiny/tiny-engine-common/js/importMap' +import config, { useEnv } from '../../../config.ts' + +const { + VITE_CDN_TYPE, + VITE_CDN_DOMAIN, + BASE_URL, + VITE_LOCAL_IMPORT_MAPS, + VITE_LOCAL_IMPORT_PATH = 'local-cdn-static' +} = useEnv() + +const getImportUrl = (pkgName: string) => { + // 自定义的 importMap + const customImportMap = config?.importMap as any + const sysImportMap = importMapConfig as any + const isLocalBundle = VITE_LOCAL_IMPORT_MAPS === 'true' + const versionDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/' : '@' + const fileDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/files' : '' + const cdnDomain = isLocalBundle ? BASE_URL + VITE_LOCAL_IMPORT_PATH : VITE_CDN_DOMAIN + + if (customImportMap?.imports?.[pkgName]) { + return customImportMap.imports[pkgName] + .replace('${VITE_CDN_DOMAIN}', cdnDomain) + .replace('${versionDelimiter}', versionDelimiter) + .replace('${fileDelimiter}', fileDelimiter) + } + + if (sysImportMap?.imports?.[pkgName]) { + return sysImportMap?.imports?.[pkgName] + .replace('${VITE_CDN_DOMAIN}', cdnDomain) + .replace('${versionDelimiter}', versionDelimiter) + .replace('${fileDelimiter}', fileDelimiter) + } +} + +// 获取样式文件的URL,后续去除物料内置逻辑之后,需要用户自行引入,相关逻辑也需要同步删除 +const getImportStyleUrl = (pkgName: string) => { + const isLocalBundle = VITE_LOCAL_IMPORT_MAPS === 'true' + const versionDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/' : '@' + const fileDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/files' : '' + const cdnDomain = isLocalBundle ? BASE_URL + VITE_LOCAL_IMPORT_PATH : VITE_CDN_DOMAIN + const sysImportMap = importMapConfig as any + + if (sysImportMap.importStyles[pkgName]) { + return sysImportMap.importStyles[pkgName] + .replace('${VITE_CDN_DOMAIN}', cdnDomain) + .replace('${versionDelimiter}', versionDelimiter) + .replace('${fileDelimiter}', fileDelimiter) + } +} + +export function getImportMapData(canvasDeps = { scripts: [], styles: [] }) { + // 以下内容由于区块WebComponent加载需要补充 + const blockRequire = { + imports: { + // TODO: 后续版本发通知,不再内置物料,需要用户自行引入 + '@opentiny/vue': getImportUrl('@opentiny/vue'), + '@opentiny/vue-icon': getImportUrl('@opentiny/vue-icon'), + '@opentiny/tiny-engine-builtin-component': getImportUrl('@opentiny/tiny-engine-builtin-component') + }, + importStyles: [getImportStyleUrl('@opentiny/vue-theme')] + } + + // 以下内容由于物料协议不支持声明子依赖而@opentiny/vue需要依赖所以需要补充 + // TODO: 后续版本发通知,不再内置物料,需要用户自行引入 + const tinyVueRequire = { + imports: { + '@opentiny/vue-common': getImportUrl('@opentiny/vue-common'), + '@opentiny/vue-locale': getImportUrl('@opentiny/vue-locale'), + echarts: getImportUrl('echarts') + } + } + + const materialsAndUtilsRequire = canvasDeps.scripts.reduce((imports, { package: pkg, script }) => { + if (pkg && script) { + imports[pkg] = script + } + + return imports + }, {}) + + const importMap = { + imports: { + vue: getImportUrl('vue'), + 'vue-i18n': getImportUrl('vue-i18n'), + ...blockRequire.imports, + ...tinyVueRequire.imports, + ...materialsAndUtilsRequire + } + } + + const importStyles = [...blockRequire.importStyles, ...canvasDeps.styles] + const tailwindURL = getImportUrl('@tailwindcss/browser') + const importScripts = config?.enableTailwindCSS && tailwindURL ? [tailwindURL] : [] + + return { + importMap, + importStyles, + importScripts + } +} + +interface ITagProps { + tag: string + [key: string]: string +} + +export const IMPORT_MAP_ELEMENT_ID = 'tiny-engine-runtime-import-map' + +export function addTagTask(props: ITagProps) { + return new Promise((resolve, reject) => { + const { tag, onload, ...others } = props + let el: any = document.head.querySelector(`${tag}#${props.id}`) + const isCreate = !el + if (!el) { + el = document.createElement(tag) as any + } + for (const key in others) { + el[key] = others[key] as string + } + if (isCreate) { + document.head.appendChild(el) + } + const success = () => resolve(true) + const error = () => reject(new Error(`添加并加载${tag}失败: ${props}`)) + if (onload) { + el.onload = success + el.onerror = error + } else { + setTimeout(() => success()) + } + }) +} + +export async function initImportMap() { + const { importMap, importStyles, importScripts } = getImportMapData() + const tasks = [] + const task = addTagTask({ + id: IMPORT_MAP_ELEMENT_ID, + tag: 'script', + type: 'importmap', + textContent: JSON.stringify(importMap, null, 2) + }) + tasks.push(task) + importStyles.forEach((url) => { + const task = addTagTask({ + tag: 'link', + href: url, + type: config.enableTailwindCSS ? 'text/tailwindcss' : 'text/css' + }) + tasks.push(task) + }) + importScripts.forEach((url) => { + const task = addTagTask({ tag: 'script', type: 'module', src: url }) + tasks.push(task) + }) + await Promise.all(tasks) +} diff --git a/packages/runtime-renderer/src/renderer/app-function/index.ts b/packages/runtime-renderer/src/renderer/app-function/index.ts new file mode 100644 index 0000000000..6ffc9cde0a --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/index.ts @@ -0,0 +1,7 @@ +export * from './dataSource/index.ts' +export * from './loadCompLib.ts' +export * from './utils.ts' +export * from './importMap.ts' +export * from './store.ts' +export * from './router.ts' +export * from './constant.ts' diff --git a/packages/runtime-renderer/src/renderer/app-function/loadCompLib.ts b/packages/runtime-renderer/src/renderer/app-function/loadCompLib.ts new file mode 100644 index 0000000000..6591557598 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/loadCompLib.ts @@ -0,0 +1,63 @@ +// 定义组件配置接口 +interface ComponentConfig { + destructuring?: boolean + exportName?: string +} + +// 定义组件依赖接口 +interface ComponentDependency { + package?: string + script?: string + components: Record +} + +// 定义动态导入参数接口 +interface DynamicImportParams { + package: string + script?: string +} + +/** + * 动态导入获取组件库模块 + * @param {DynamicImportParams} param 模块参数,包含pkg模块名称和script模块的cdn地址 + * @returns {Promise} 返回组件库模块 + */ +const dynamicImportComponentLib = async ({ package: pkg, script }: DynamicImportParams): Promise => { + if (window.TinyComponentLibs[pkg]) { + return window.TinyComponentLibs[pkg] + } + try { + // 尝试直接导入模块 + const modules = await import(/* @vite-ignore */ pkg) + window.TinyComponentLibs[pkg] = modules + } catch (_err) { + if (script) { + try { + // 拉取远程脚本 + const modules = await import(/* @vite-ignore */ script) + window.TinyComponentLibs[pkg] = modules + } catch (error) { + // eslint-disable-next-line no-console + console.error(`组件库安装失败: ${pkg}`, error) + } + } + } + return window.TinyComponentLibs[pkg] +} + +/** + * 获取组件对象并缓存,组件渲染时使用 + * @param {ComponentDependency} param 组件的依赖配置对象 + * @returns {Promise} 无返回值的Promise + */ +export const getComponents = async ({ package: pkg, script, components }: ComponentDependency): Promise => { + if (!pkg) return + const modules = await dynamicImportComponentLib({ package: pkg, script }) + for (const i in components) { + const item = components[i] as any + if (!window.TinyLowcodeComponent[item.componentName || item.exportName]) { + window.TinyLowcodeComponent[item.componentName] = + item?.destructuring && item?.exportName ? modules[item.exportName] : modules?.default + } + } +} diff --git a/packages/runtime-renderer/src/renderer/app-function/router.ts b/packages/runtime-renderer/src/renderer/app-function/router.ts new file mode 100644 index 0000000000..fce61f2bd5 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/router.ts @@ -0,0 +1,101 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import { useAppSchema } from '../../composables/useAppSchema.ts' +import type { IRouteConfig } from '../../types/index.ts' +import { withPageRenderer } from '../../components/PageRenderer.ts' +import type { PageMeta } from '../../types/index.ts' + +// 定义页面结构类型 +interface PageSchema { + id: string + name: string + route: string + parentId: string + isPage: boolean + isHome: boolean + isDefault: boolean + depth: number + children?: PageSchema[] + meta: PageMeta +} + +// 异步初始化路由配置 +function createRouterConfig() { + const { pages } = useAppSchema() + // 通过pages生成路由配置 + const generateRoutesByPages = (pages: Array): Array => { + // 建立路由-页面id映射 + const pageRouteMap = new Map() + pages.forEach((page: PageSchema) => { + const pageIdStr = String(page.id) + // JAVA后端中page.id字段为number类型,前端mockServer中为string类型 + // 为了同时兼容JAVA后端和mockserver,显示转换作为键的pageId为字符串 + pageRouteMap.set(pageIdStr, { + path: `${page.route}`, + name: pageIdStr, + component: withPageRenderer({ pageId: page.id, isPage: page?.isPage }), + children: [], + meta: { + pageId: pageIdStr, + parentId: page.parentId, + pageName: page.name, + isHome: page.isHome, + depth: page.depth, + isDefault: page.isDefault, + hasDefault: false, + hasChildren: false, + defaultPath: '' + } + }) + }) + + // 建立树状路由关系 + const routeConfs = [] as Array + let redirectRoute = {} as IRouteConfig + pageRouteMap.forEach((config, _id) => { + const pRouteConf = pageRouteMap.get((config?.meta?.parentId || '0') as string) + if (pRouteConf) { + // 存在父级路由则添加至父级路由 + pRouteConf.children = [...(pRouteConf?.children || []), config] + pRouteConf.meta = { ...pRouteConf.meta, hasChildren: true } + // 处理默认子路由 + if (config?.meta?.isDefault) { + const parentPath = pRouteConf.path.startsWith('/') ? pRouteConf.path : `/${pRouteConf.path}` + pRouteConf.redirect = `${parentPath}/${config.path}`.replace(/\/+/g, '/') + pRouteConf.meta = { ...pRouteConf.meta, hasDefault: true } + } + } else { + // 无父级路由均为根路由 + config.path = `/${config.path}` + routeConfs.push(config) + } + // 处理首页路由 + if (config?.meta?.isHome) { + const getUrl = (conf: IRouteConfig): string => { + const parent = pageRouteMap.get((conf?.meta?.parentId || '0') as string) + return parent && conf ? `${getUrl(parent)}/${conf.path}` : conf ? `/${conf.path.replace(/^\/+/, '')}` : '' + } + redirectRoute = { path: '/', redirect: getUrl(config) } + routeConfs.push(redirectRoute) + } + }) + return routeConfs + } + + const routes = generateRoutesByPages(pages.value || []) + + routes.push({ + path: '/:pathMatch(.*)*', + component: () => import('../../components/NotFound.vue') + }) + + return routes +} + +export function createAppRouter() { + const routes = createRouterConfig() + const router = createRouter({ + history: createWebHashHistory('/runtime.html'), + routes + }) + return router +} diff --git a/packages/runtime-renderer/src/renderer/app-function/store.ts b/packages/runtime-renderer/src/renderer/app-function/store.ts new file mode 100644 index 0000000000..9c1dce9c4d --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/store.ts @@ -0,0 +1,59 @@ +import { createPinia, defineStore } from 'pinia' +import { shallowReactive } from 'vue' +import { useAppSchema } from '../../composables/useAppSchema' +import { parseJSFunction } from '../data-function' +const stores = shallowReactive>({}) +export const generateStoresConfig = () => { + const { globalStates } = useAppSchema() + if (globalStates.value.length === 0) return [] + return globalStates.value.map((store) => ({ + id: store.id, + state: JSON.parse(JSON.stringify(store.state)), + actions: Object.fromEntries( + Object.keys(store.actions || {}).map((key) => { + // 使用 parseJSFunction ,但是上下文由pinia内部绑定 + const fn = parseJSFunction(store.actions[key]) + if (!fn) { + // eslint-disable-next-line no-console + console.error(`Failed to parse action: ${key} in store: ${store.id}`) + return [key, () => {}] // fallback to noop + } + return [key, fn] + }) + ), + getters: Object.fromEntries( + Object.keys(store.getters || {}).map((key) => { + // 同样处理 getters + const fn = parseJSFunction(store.getters[key]) + if (!fn) { + // eslint-disable-next-line no-console + console.error(`Failed to parse getter: ${key} in store: ${store.id}`) + return [key, () => undefined] // fallback + } + return [key, fn] + }) + ) + })) +} + +export const createAppStores = () => { + const pinia = createPinia() + const storesConfig = generateStoresConfig() + storesConfig.forEach((config) => { + // 使用 defineStore 创建 Pinia store + const useStore = defineStore(config.id, { + state: () => config.state, + + getters: config.getters, + + actions: config.actions + }) + // 使用useStore创建 store 实例并绑定到 pinia + stores[config.id] = useStore(pinia) + }) + return pinia +} + +export function getStore() { + return stores +} diff --git a/packages/runtime-renderer/src/renderer/app-function/utils.ts b/packages/runtime-renderer/src/renderer/app-function/utils.ts new file mode 100644 index 0000000000..03162a638b --- /dev/null +++ b/packages/runtime-renderer/src/renderer/app-function/utils.ts @@ -0,0 +1,84 @@ +import type { Util } from '../../types/index.ts' +import { parseJSFunction } from '../data-function/index.ts' + +interface npmContent { + package?: string + version?: string + exportName?: string + subName?: string + destructuring?: boolean + cdnLink?: string +} + +interface fnContent { + type: string + value: string +} + +const npmCache = new Map() +const utilValues = new Map() +let initialized = false +let loading = false + +async function loadNpmUtil(util: Util) { + const c = util.content as npmContent + if (!c.package) return + if (utilValues.has(util.name)) return + + const key = `${c.package}@${c.version || ''}` + let modules: any = npmCache.get(key) + if (!modules) { + const url = c.cdnLink || c.package + modules = await import(/* @vite-ignore */ url) + npmCache.set(key, modules) + } + let exported: any + if (c.destructuring) { + exported = c.exportName ? modules[c.exportName] : modules.default || modules + } else { + exported = (c.exportName && modules[c.exportName]) || modules.default || modules + } + if (c.subName && exported) exported = exported[c.subName] + utilValues.set(util.name, exported) +} + +export async function initUtils(utils: Util[] = []) { + if (initialized || loading) { + return + } + loading = true + try { + const npmUtils = utils.filter((util) => util.type === 'npm') + const functionUtils = utils.filter((util) => util.type === 'function') + + // 并行加载npm包 + await Promise.all( + npmUtils.map(async (util) => { + try { + await loadNpmUtil(util) + } catch (error) { + // eslint-disable-next-line no-console + console.error(`加载 npm 包 ${util.name} 错误:`, error) + } + }) + ) + + // 处理funtion类型的utils + for (const util of functionUtils) { + try { + const content = util.content as fnContent + utilValues.set(util.name, parseJSFunction(content)) + } catch (error) { + // eslint-disable-next-line no-console + console.error(`加载函数 ${util.name} 错误:`, error) + } + } + } finally { + initialized = true + loading = false + } +} + +export function getUtilsAll() { + return Object.fromEntries(utilValues.entries()) +} diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasBox.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasBox.vue new file mode 100644 index 0000000000..b4414c5cd5 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasBox.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasCollection.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasCollection.vue new file mode 100644 index 0000000000..7fd1eda149 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasCollection.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasIcon.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasIcon.vue new file mode 100644 index 0000000000..42567318bf --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasIcon.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasImg.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasImg.vue new file mode 100644 index 0000000000..2581381c60 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasImg.vue @@ -0,0 +1,18 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasPlaceholder.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasPlaceholder.vue new file mode 100644 index 0000000000..7700b8755f --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasPlaceholder.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasRouterLink.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasRouterLink.vue new file mode 100644 index 0000000000..408b1b743a --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasRouterLink.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasRouterView.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasRouterView.vue new file mode 100644 index 0000000000..1f6363dc9b --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasRouterView.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasSlot.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasSlot.vue new file mode 100644 index 0000000000..2c54559765 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasSlot.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/CanvasText.vue b/packages/runtime-renderer/src/renderer/builtin/CanvasText.vue new file mode 100644 index 0000000000..ec9de29928 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/CanvasText.vue @@ -0,0 +1,18 @@ + + + diff --git a/packages/runtime-renderer/src/renderer/builtin/index.ts b/packages/runtime-renderer/src/renderer/builtin/index.ts new file mode 100644 index 0000000000..66da676e64 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/builtin/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ +export { default as CanvasBox } from './CanvasBox.vue' +export { default as CanvasText } from './CanvasText.vue' +export { default as CanvasIcon } from './CanvasIcon.vue' +export { default as CanvasSlot } from './CanvasSlot.vue' +export { default as CanvasImg } from './CanvasImg.vue' +export { default as CanvasPlaceholder } from './CanvasPlaceholder.vue' +export { default as CanvasRouterLink } from './CanvasRouterLink.vue' +export { default as CanvasRouterView } from './CanvasRouterView.vue' +export { default as CanvasCollection } from './CanvasCollection.vue' diff --git a/packages/runtime-renderer/src/renderer/context/index.ts b/packages/runtime-renderer/src/renderer/context/index.ts new file mode 100644 index 0000000000..e16a5aa061 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/index.ts @@ -0,0 +1,7 @@ +export * from './useContext' +export * from './useStore' +export * from './useMethods' +export * from './useRefs' +export * from './useState' +export * from './useDataSource' +export * from './useUtils' diff --git a/packages/runtime-renderer/src/renderer/context/useContext.ts b/packages/runtime-renderer/src/renderer/context/useContext.ts new file mode 100644 index 0000000000..fc54907046 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useContext.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ +import * as vue from 'vue' +import { shallowReactive, type ShallowReactive } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useStore } from './useStore.ts' +import { useUtils } from './useUtils.ts' +import { useRefs } from './useRefs.ts' +import { useState } from './useState.ts' +import { useMethods } from './useMethods.ts' +import { useDataSource } from './useDataSource.ts' +import { getDeletedKeys } from '../data-function/index.ts' +import { normalizeScopeKey, setPageCss } from '../page-function/index.ts' +import TinyI18nHost from '@opentiny/tiny-engine-common/js/i18n' +import type { PageContent as Schema } from '../../types/index.ts' + +interface Context { + [key: string]: any +} + +interface UseContextReturn { + context: ShallowReactive + setContext: (ctx: Context) => void + appendContext: (ctx: Context) => void + getContext: () => ShallowReactive +} + +export function useContext(): UseContextReturn { + const context = shallowReactive({}) + + const setContext = (ctx: Context) => { + const deletedKeys = getDeletedKeys(context, ctx) + deletedKeys?.forEach((key) => delete context[key]) + Object.assign(context, ctx) + } + + const appendContext = (ctx: Context) => { + setContext({ ...context, ...ctx }) + } + + const getContext = () => context + + return { + context, + setContext, + getContext, + appendContext + } +} + +interface InitContextProps { + schema: Schema + props: any + ctx: any + isBlock?: boolean +} + +export function useContextPage() { + const { context, setContext, appendContext } = useContext() + const route = useRoute() + const router = useRouter() + const { $, $ref } = useRefs() + const { stores } = useStore() + const { utils } = useUtils() + const { dataSourceMap } = useDataSource() + const { state, setState } = useState({}, context) + const { methods, setMethods } = useMethods({}, context) + const { t, locale } = TinyI18nHost.global + const initContext = ({ schema, props, isBlock, ctx }: InitContextProps, callback?: (...args: any[]) => void) => { + if (!schema) return + const cssScopeId = normalizeScopeKey(props.pageId, isBlock) + setContext({ + ...vue, + context: ctx, + t, + $, + $ref, + route, + router, + props, + state, + utils, + stores, + dataSourceMap, + i18n: { get: () => t }, + // setState: { get: () => setState }, + getLocale: { get: () => locale?.value }, + setLocale: { get: () => (val: string) => (locale.value = val) }, + location: { get: () => window.location }, + history: { get: () => window.history }, + getCssScopeId: () => cssScopeId + }) + setState(schema.state) + setMethods(schema.methods) + appendContext(methods) + setPageCss(schema.css || '', cssScopeId) + callback?.() + } + return { + state, + utils, + stores, + context, + methods, + initContext, + setContext, + appendContext, + setState, + setMethods, + $, + $ref + } +} diff --git a/packages/runtime-renderer/src/renderer/context/useDataSource.ts b/packages/runtime-renderer/src/renderer/context/useDataSource.ts new file mode 100644 index 0000000000..3df8d9ce06 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useDataSource.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ +import { getDataSource } from '../app-function' +export function useDataSource() { + return { + dataSourceMap: getDataSource() + } +} +export { getDataSource } diff --git a/packages/runtime-renderer/src/renderer/context/useLowcode.ts b/packages/runtime-renderer/src/renderer/context/useLowcode.ts new file mode 100644 index 0000000000..8d67287f43 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useLowcode.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { useRouter, useRoute } from 'vue-router' +import { getStore, getDataSource, getUtilsAll } from './index' +import { getCurrentInstance, nextTick, provide, inject } from 'vue' +import TinyI18nHost, { I18nInjectionKey } from '@opentiny/tiny-engine-common/js/i18n' + +export const lowcodeWrap = (props: any, context: any) => { + const global: any = {} + const instance = getCurrentInstance() as any + const router = useRouter() + const route = useRoute() + const i18nhost = inject(I18nInjectionKey) as any + const { t, locale } = i18nhost.global + const emit = context.emit + const ref = (ref: string) => instance?.refs?.[ref] + + const setState = (newState: any, callback: any) => { + Object.assign(global.state, newState) + nextTick(() => callback.apply(global)) + } + + const getLocale = () => locale.value + const setLocale = (val: string) => { + locale.value = val + } + + const location = () => window.location + const history = () => window.history + + Object.defineProperties(global, { + i18n: { get: () => t }, + emit: { get: () => emit }, + props: { get: () => props }, + route: { get: () => route }, + router: { get: () => router }, + setState: { get: () => setState }, + getLocale: { get: () => getLocale }, + setLocale: { get: () => setLocale }, + utils: { get: () => getUtilsAll() }, + dataSourceMap: { get: () => getDataSource() }, + location: { get: location }, + history: { get: history }, + bridge: { get: () => {} }, + $: { get: () => ref } + }) + + const wrap = (fn: any) => { + if (typeof fn === 'function') { + return (...args: any[]) => fn.apply(global, args) + } + + Object.entries(fn).forEach(([name, value]) => { + Object.defineProperty(global, name, { + get: () => value + }) + }) + + fn.t = t + + return fn + } + + return wrap +} + +export const lowcode = () => { + const i18n = inject(I18nInjectionKey) as any + + provide(I18nInjectionKey, i18n) + + return { t: i18n.global.t, lowcodeWrap, stores: getStore() } +} + +export const useLowcode = () => { + const i18nHost = TinyI18nHost as any + i18nHost.lowcode = lowcode + return { + TinyI18nHost: i18nHost + } +} diff --git a/packages/runtime-renderer/src/renderer/context/useMethods.ts b/packages/runtime-renderer/src/renderer/context/useMethods.ts new file mode 100644 index 0000000000..43a2d71a9a --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useMethods.ts @@ -0,0 +1,22 @@ +import { shallowReactive } from 'vue' +import { parseData } from '../data-function/index' +import type { IFuntion } from '../../types/index' +export function useMethods(scope: any = {}, context: any = {}) { + const methods = shallowReactive>({}) + const setMethods = (methodsObj: Record) => { + for (const key in methodsObj) { + const method = methodsObj[key] + methods[key] = parseData(method, scope, context) as IFuntion + } + } + const delMethods = (key: string) => { + delete methods[key] + } + const getMethods = () => methods + return { + methods, + setMethods, + delMethods, + getMethods + } +} diff --git a/packages/runtime-renderer/src/renderer/context/useRefs.ts b/packages/runtime-renderer/src/renderer/context/useRefs.ts new file mode 100644 index 0000000000..86af6ebb7f --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useRefs.ts @@ -0,0 +1,10 @@ +import { shallowReactive } from 'vue' +export function useRefs() { + const refsMap = shallowReactive>({}) + return { + $: (refName: string) => refsMap[refName], + $ref: (refName: string, value: any) => { + refsMap[refName] = value + } + } +} diff --git a/packages/runtime-renderer/src/renderer/context/useState.ts b/packages/runtime-renderer/src/renderer/context/useState.ts new file mode 100644 index 0000000000..efa2edc093 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useState.ts @@ -0,0 +1,37 @@ +import { reactive } from 'vue' +import { getDeletedKeys } from '../data-function' +import { useAccessorMap } from '../page-function/accessor' +import { isStateAccessor, parseData } from '../data-function/index' + +export function useState(scope: any = {}, context: any = {}) { + // 改成使用 reactive, 处理state.xxx.xxx双向绑定 + const state = reactive>({}) + const { generateStateAccessors } = useAccessorMap(context) + + const setState = (data: Record) => { + if (typeof data !== 'object' || data === null) { + return + } + // 同步删除的 key + const deletedKeys = getDeletedKeys(state, data) + deletedKeys?.forEach((key) => delete state[key]) + Object.assign(state, parseData(data, scope, context) || {}) + // 在状态变量合并之后,执行访问器中watchEffect,为了可以在访问器函数中可以访问其他state变量 + Object.entries(data || {})?.forEach(([key, stateData]: [string, any]) => { + if (isStateAccessor(stateData)) { + const accessor = stateData.accessor + if (accessor?.getter?.value) { + generateStateAccessors('getter', accessor, key) + } + + if (accessor?.setter?.value) { + generateStateAccessors('setter', accessor, key) + } + } + }) + } + return { + state, + setState + } +} diff --git a/packages/runtime-renderer/src/renderer/context/useStore.ts b/packages/runtime-renderer/src/renderer/context/useStore.ts new file mode 100644 index 0000000000..bbedfd78c6 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useStore.ts @@ -0,0 +1,8 @@ +import { getStore } from '../app-function' + +export function useStore() { + return { + stores: getStore() + } +} +export { getStore } diff --git a/packages/runtime-renderer/src/renderer/context/useUtils.ts b/packages/runtime-renderer/src/renderer/context/useUtils.ts new file mode 100644 index 0000000000..111c31b17a --- /dev/null +++ b/packages/runtime-renderer/src/renderer/context/useUtils.ts @@ -0,0 +1,9 @@ +import { getUtilsAll } from '../app-function' + +export function useUtils() { + return { + utils: getUtilsAll() + } +} + +export { getUtilsAll } diff --git a/packages/runtime-renderer/src/renderer/data-function/index.ts b/packages/runtime-renderer/src/renderer/data-function/index.ts new file mode 100644 index 0000000000..c976eb70ba --- /dev/null +++ b/packages/runtime-renderer/src/renderer/data-function/index.ts @@ -0,0 +1,2 @@ +export * from './parser' +export * from './utils' diff --git a/packages/runtime-renderer/src/renderer/data-function/parser.ts b/packages/runtime-renderer/src/renderer/data-function/parser.ts new file mode 100644 index 0000000000..9755f710d0 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/data-function/parser.ts @@ -0,0 +1,361 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import * as vue from 'vue' +import babelPluginJSX from '@vue/babel-plugin-jsx' +import { transformSync } from '@babel/core' +import { Notify } from '@opentiny/vue' +import { renderDefault } from '../render' +import { getComponent, getIcon } from '../material-function' +import i18nHost from '@opentiny/tiny-engine-common/js/i18n' + +interface ITypeParserDef { + type: (data: any) => boolean + parseFunc: (data: unknown, scope: Record, ctx: Record) => unknown +} + +const parseList: Array = [] + +const isI18nData = (data: { type: string }) => { + return data && data.type === 'i18n' +} + +const isJSSlot = (data: { type: string }) => { + return data && data.type === 'JSSlot' +} + +const isJSExpression = (data: { type: string }) => { + return data && data.type === 'JSExpression' +} + +const isJSFunction = (data: { type: string }) => { + return data && data.type === 'JSFunction' +} + +const isJSResource = (data: { type: string }) => { + return data && data.type === 'JSResource' +} + +const isString = (data: any) => { + return typeof data === 'string' +} + +const isArray = (data: any) => { + return Array.isArray(data) +} + +const isFunction = (data: any) => { + return typeof data === 'function' +} + +const isIcon = (data: { componentName: string }) => { + return data?.componentName === 'Icon' +} + +const isObject = (data: any) => { + return typeof data === 'object' +} + +// 判断是否是状态访问器 +export const isStateAccessor = (stateData: { accessor: { getter: { type: string }; setter: { type: string } } }) => + stateData?.accessor?.getter?.type === 'JSFunction' || stateData?.accessor?.setter?.type === 'JSFunction' + +// 规避创建function eslint报错 +export const newFn = (...args: any) => new Function(...args) + +const transformJSX = (code: any) => { + const res = transformSync(code, { + plugins: [ + [ + babelPluginJSX, + { + pragma: 'h' + } + ] + ] + }) + return (res?.code || '') + .replace(/import \{.+\} from "vue";/, '') + .replace(/h\(_?resolveComponent\((.*?)\)/g, `h(this.getComponent($1)`) + .replace(/_?resolveComponent/g, 'h') + .replace(/_?createTextVNode\((.*?)\)/g, '$1') + .trim() +} + +const curriedFn = (innerFn: any, params: any) => { + return (...args: any[]) => innerFn(...args, ...params) +} + +const parseExpression = (data: any, scope: any, ctx: any, isJsx = false) => { + try { + if (data.value.indexOf('this.i18n') > -1) { + ctx.i18n = i18nHost.global.t + } else if (data.value.indexOf('t(') > -1) { + ctx.t = i18nHost.global.t + } + + const fnContext = { ...ctx, ...scope, slotScope: scope } + const expression = isJsx ? transformJSX(data.value) : data.value + const rs = newFn('$scope', `with($scope || {}) { return ${expression} }`).call(ctx, fnContext) + if (data.params && data.params.length) { + const params = data.params.map((param: string) => fnContext[param]) + return curriedFn(rs, params) + } else { + return rs + } + } catch (err) { + // 解析抛出异常,则再尝试解析 JSX 语法。如果解析 JSX 语法仍然出现错误,isJsx 变量会确保不会再次递归执行解析 + if (!isJsx) { + return parseExpression(data, scope, ctx, true) + } + // eslint-disable-next-line no-console + console.error('parseExpression error', data, scope) + return undefined + } +} + +const parseI18n = (i18n: any, scope: any, ctx: any) => { + return parseExpression( + { + type: 'JSExpression', + value: `this.i18n('${i18n.key}', ${JSON.stringify(i18n.params)})` + }, + scope, + { i18n: i18nHost.global.t, ...ctx } + ) +} + +// 解析函数字符串结构 +const parseFunctionString = (fnStr: string) => { + const fnRegexp = /(async)?.*?(\w+) *\(([\s\S]*?)\) *\{([\s\S]*)\}/ + const result = fnRegexp.exec(fnStr) + if (result) { + return { + type: result[1] || '', + name: result[2], + params: result[3] + .split(',') + .map((item) => item.trim()) + .filter((item) => Boolean(item)), + body: result[4] + } + } + return null +} + +// 解析JSX字符串为可执行函数 +const parseJSXFunction = (data: any, _scope: null, ctx: any) => { + try { + const newValue = transformJSX(data.value) + const fnInfo = parseFunctionString(newValue) + if (!fnInfo) throw Error('函数解析失败,请检查格式。示例:function fnName() { }') + + return newFn(...fnInfo.params, fnInfo.body).bind({ + ...ctx, + getComponent + }) + } catch (error) { + Notify({ + type: 'warning', + title: '函数声明解析报错', + message: error?.message || '函数声明解析报错,请检查语法' + }) + + return newFn() + } +} + +export const generateFn = (innerFn: any, context: any) => { + return (...args: any[]) => { + // 如果有数据源标识,则表格的fetchData返回数据源的静态数据 + let result: any + // 这里是为了兼容用户写法报错导致画布异常,但无法捕获promise内部的异常 + try { + result = innerFn.call(context, ...args) + } catch (error) { + Notify({ + type: 'warning', + title: `函数:${innerFn.name}执行报错`, + message: error?.message || `函数:${innerFn.name}执行报错,请检查语法` + }) + } + // 这里注意如果innerFn返回的是一个promise则需要捕获异常,重新返回默认一条空数据 + if (result?.then && typeof result.then === 'function') { + result = new Promise((resolve) => { + result.then(resolve).catch((error: { message: any }) => { + Notify({ + type: 'warning', + title: '异步函数执行报错', + message: error?.message || '异步函数执行报错,请检查语法' + }) + // 这里需要至少返回一条空数据,方便用户使用表格默认插槽 + resolve({ + result: [{}], + page: { total: 1 } + }) + }) + }) + } + return result + } +} + +const parseJSFunction = (data: any, _scope: any, ctx: any) => { + try { + const innerFn = newFn('$vue', `with($vue || {}) { return ${data.value} }`).call(ctx, { vue }) + return generateFn(innerFn, ctx) + } catch (error) { + return parseJSXFunction(data, null, ctx) + } +} + +const parseJSSlot = (data: any, _scope: Record, _ctx: any) => { + return ($scope: Record) => renderDefault(data.value, { ..._scope, ...$scope }, data) +} + +const parseIcon = (data: any, _scope: any, _ctx: any) => { + return getIcon(data.props.name) +} + +const parseData = (data: any, scope: any, ctx: any) => { + const typeParser = parseList.find((item) => item.type(data)) + return typeParser ? typeParser.parseFunc(data, scope, ctx) : data +} + +const parseStateAccessor = (data: any, _scope: any, ctx: any) => { + return parseData(data.defaultValue, null, ctx) +} + +const parseObjectData = (data: any, scope: any, ctx: any) => { + if (!data) { + return data + } + + // 如果是状态访问器,则直接解析默认值 + if (isStateAccessor(data)) { + return parseData(data.defaultValue, scope, ctx) + } + + // 解析通过属性传递icon图标组件 + if (data.componentName === 'Icon') { + return getIcon(data.props.name) + } + + const res: any = {} + Object.entries(data).forEach(([key, value]: [string, any]) => { + // 如果是插槽则需要进行特殊处理 + if (key === 'slot' && value?.name) { + res[key] = value.name + // 特殊处理下ref + } else if (key === 'ref' && value) { + res[key] = (el: any) => ctx?.$ref(value, el) + } else { + res[key] = parseData(value, scope, ctx) + } + }) + + // 处理 v-model 双向绑定 + const propsEntries = Object.entries(data) + const modelValue = propsEntries.find(([_key, value]) => value?.type === 'JSExpression' && value?.model === true) + const hasUpdateModelValue = propsEntries.find( + ([key]) => /^on[A-Z]/.test(key) && key.startsWith(`onUpdate:${modelValue?.[0]}`) + ) + + if (modelValue && !hasUpdateModelValue) { + // 添加 onUpdate:modelKey 事件 + res[`onUpdate:${modelValue?.[0]}`] = parseData( + { + type: 'JSFunction', + value: `(value) => ${modelValue[1].value}=value` + }, + scope, + ctx + ) + } + + return res +} + +const parseString = (data: any) => { + return data.trim() +} + +const parseArray = (data: any, scope: any, ctx: any) => { + return data.map((item: any) => parseData(item, scope, ctx)) +} + +const parseFunction = (data: any, _scope: any, ctx: any) => { + return data.bind(ctx) +} + +const parseCondition = (condition: any, scope: any, ctx: any) => { + // eslint-disable-next-line no-eq-null + return condition == null ? true : parseData(condition, scope, ctx) +} + +const parseLoopArgs = (loop?: { item: unknown; index: number; loopArgs?: string[] }) => { + if (!loop) { + return undefined + } + const { item, index, loopArgs = [] } = loop + const body = `return {${loopArgs[0] || 'item'}: item, ${loopArgs[1] || 'index'} : index }` + return newFn('item, index', body)(item, index) +} + +parseList.push( + { + type: isJSExpression, + parseFunc: parseExpression + }, + { + type: isI18nData, + parseFunc: parseI18n + }, + { + type: isJSFunction, + parseFunc: parseJSFunction + }, + { + type: isJSResource, + parseFunc: parseExpression + }, + { + type: isJSSlot, + parseFunc: parseJSSlot + }, + { + type: isIcon, + parseFunc: parseIcon + }, + { + type: isStateAccessor, + parseFunc: parseStateAccessor + }, + { + type: isString, + parseFunc: parseString + }, + { + type: isArray, + parseFunc: parseArray + }, + { + type: isFunction, + parseFunc: parseFunction + }, + { + type: isObject, + parseFunc: parseObjectData + } +) + +export { parseData, parseCondition, parseLoopArgs } diff --git a/packages/runtime-renderer/src/renderer/data-function/utils.ts b/packages/runtime-renderer/src/renderer/data-function/utils.ts new file mode 100644 index 0000000000..aab106c735 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/data-function/utils.ts @@ -0,0 +1,31 @@ +import { newFn } from './parser' +export function generateFunction(rawCode: any, context = {}) { + try { + return newFn(`return (${rawCode})`).call(context).bind(context) + } catch (error) { + // eslint-disable-next-line no-console + console.error(`generateFunction error: ${JSON.stringify(error)}`) + return null + } +} +export const reset = (obj) => { + Object.keys(obj).forEach((key) => delete obj[key]) +} + +// 用于解析store中的actions和getters +export const parseJSFunction = (data: any, _scope: any = null, _ctx: any = null) => { + try { + const fn = newFn(`return ${data.value}`).call(_ctx, _scope) // 拿到函数本体,不绑定任何 this + return fn + } catch (error) { + // eslint-disable-next-line no-console + console.error('函数声明解析报错:', error, data) + } +} + +export const getDeletedKeys = (objA, objB) => { + const keyA = Object.keys(objA) + const keyB = new Set(Object.keys(objB)) + + return keyA.filter((item) => !keyB.has(item)) +} diff --git a/packages/runtime-renderer/src/renderer/material-function/blockComplier.ts b/packages/runtime-renderer/src/renderer/material-function/blockComplier.ts new file mode 100644 index 0000000000..5fed97ed00 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/material-function/blockComplier.ts @@ -0,0 +1,33 @@ +import { compile as blockCompiler } from '@opentiny/tiny-engine-block-compiler' +import { genSFCWithDefaultPlugin } from '@opentiny/tiny-engine-dsl-vue' +import { useAppSchema } from '../../composables/useAppSchema' +const blockCompileCache = new Map() +export const getBlockCompileResult = async (name: any) => { + if (blockCompileCache.has(name)) { + return { + [name]: blockCompileCache.get(name) + } + } + + const list: any = await useAppSchema().fetchBlockByName(name) + + const block = list?.histories?.find((item: any) => item.version === list?.version) + + const realSchema = block.content || list?.content + if (!realSchema) { + return + } + const componentsMap = useAppSchema().appSchema.value?.componentsMap || [] + + // 需要出码的区块 + const sourceCode = genSFCWithDefaultPlugin(realSchema, componentsMap || [], { blockRelativePath: './' }) + + const blocksSourceCode = { + fileName: realSchema.fileName, + sourceCode + } + + const compiledResult = blockCompiler([blocksSourceCode], { compileCache: blockCompileCache }) + + return compiledResult +} diff --git a/packages/runtime-renderer/src/renderer/material-function/index.ts b/packages/runtime-renderer/src/renderer/material-function/index.ts new file mode 100644 index 0000000000..c7cb98be17 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/material-function/index.ts @@ -0,0 +1,2 @@ +export * from './material-getter' +export * from './blockComplier' diff --git a/packages/runtime-renderer/src/renderer/material-function/material-getter.ts b/packages/runtime-renderer/src/renderer/material-function/material-getter.ts new file mode 100644 index 0000000000..189fca853a --- /dev/null +++ b/packages/runtime-renderer/src/renderer/material-function/material-getter.ts @@ -0,0 +1,122 @@ +import { h, defineAsyncComponent, reactive, defineComponent } from 'vue' +import { isHTMLTag } from '@vue/shared' +import { + CanvasRow, + CanvasCol, + CanvasRowColContainer, + CanvasFlexBox, + CanvasSection, + CanvasNavigation, + FormModel, + TableModel, + PageModel +} from '@opentiny/tiny-engine-builtin-component' +import BlockLoadError from '../../components/BlockLoadError.vue' +import { + CanvasBox, + CanvasText, + CanvasIcon, + CanvasSlot, + CanvasImg, + CanvasPlaceholder, + CanvasRouterLink, + CanvasRouterView +} from '../builtin' +import { getBlockCompileResult } from './blockComplier' +import { addTagTask } from '../app-function/importMap' +import config from '../../../config.ts' + +export const Mapper: any = { + Icon: CanvasIcon, + Text: CanvasText, + div: CanvasBox, + Slot: CanvasSlot, + slot: CanvasSlot, + Template: CanvasBox, + Img: CanvasImg, + CanvasSection, + CanvasFlexBox, + CanvasRow, + CanvasCol, + CanvasRowColContainer, + CanvasPlaceholder, + FormModel, + TableModel, + PageModel, + RouterView: CanvasRouterView, + RouterLink: CanvasRouterLink, + CanvasNavigation +} +const getNative = (name: string) => { + return window.TinyLowcodeComponent?.[name] +} + +const getBlock = (name: string) => { + return window.blocks?.[name] +} + +const blockComponentsBlobUrlMap = new Map() + +// TODO: 这里的全局 getter 方法名,可以做成配置化 +const loadBlockComponent = async (name: string) => { + try { + if (blockComponentsBlobUrlMap.has(name)) { + return import(/* @vite-ignore */ blockComponentsBlobUrlMap.get(name)) + } + + const blocksBlob = (await getBlockCompileResult(name)) as Array<{ blobURL: string; style: string }> + + for (const [fileName, value] of Object.entries(blocksBlob)) { + blockComponentsBlobUrlMap.set(fileName, value.blobURL) + + if (!value.style) { + continue + } + + // 注册 JS,以区块为维度 + addTagTask({ + id: fileName, + tag: 'style', + textContent: value.style, + type: config.enableTailwindCSS ? 'text/tailwindcss' : 'text/css' + }) + } + + return import(/* @vite-ignore */ blockComponentsBlobUrlMap.get(name)) + } catch (error) { + // 加载错误提示 + return h(BlockLoadError, { name }) + } +} + +window.loadBlockComponent = loadBlockComponent + +export const getBlockComponent = (name: string) => { + return defineAsyncComponent(() => loadBlockComponent(name)) +} + +// 移除区块缓存 +export const removeBlockCompsCache = () => { + blockComponentsBlobUrlMap.forEach((_, fileName) => { + const stylesheet = document.querySelector(`#${fileName}`) + stylesheet?.remove?.() + }) + + blockComponentsBlobUrlMap.clear() +} + +// 获取图标组件 +export const getIcon = (name: string) => { + return defineComponent({ + name: 'Icon', + render() { + return h(CanvasIcon, { name, ...this.$props }) + } + }) +} + +export const getComponent = (name: string) => { + return Mapper[name] || getNative(name) || getBlock(name) || (isHTMLTag(name) ? name : getBlockComponent(name)) +} + +export const blockSlotDataMap = reactive>({}) diff --git a/packages/runtime-renderer/src/renderer/page-function/accessor.ts b/packages/runtime-renderer/src/renderer/page-function/accessor.ts new file mode 100644 index 0000000000..ee1da1c92c --- /dev/null +++ b/packages/runtime-renderer/src/renderer/page-function/accessor.ts @@ -0,0 +1,50 @@ +import { watchEffect, type WatchStopHandle } from 'vue' +import { generateFunction } from '../data-function' + +type IAccessorType = 'getter' | 'setter' +interface IAccessor { + getter: { value: string } + setter: { value: string } +} + +export function useAccessorMap(context: any) { + const generateAccessor = (type: IAccessorType, accessor: IAccessor, property: string) => { + const accessorFn = generateFunction(accessor[type].value, context) as (...args: any) => any + + return { property, accessorFn, type } + } + + // 这里缓存状态变量对应的访问器,用于watchEffect更新和取消监听 + const stateAccessorMap = new Map() + + // 缓存区块属性的访问器 + const propsAccessorMap = new Map() + + const generateStateAccessors = (type: IAccessorType, accessor: IAccessor, key: string) => { + const stateWatchEffectKey = `${key}${type}` + const { property, accessorFn } = generateAccessor(type, accessor, key) + + // 将之前已有的watchEffect取消监听,这里操作很有必要,不然会造成数据混乱 + stateAccessorMap.get(stateWatchEffectKey)?.() + + // 更新watchEffect监听 + stateAccessorMap.set( + stateWatchEffectKey, + watchEffect(() => { + try { + accessorFn() + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`状态变量${property}的访问器函数:${accessorFn.name}执行报错`, error) + } + }) + ) + } + + return { + generateAccessor, + stateAccessorMap, + propsAccessorMap, + generateStateAccessors + } +} diff --git a/packages/runtime-renderer/src/renderer/page-function/css.ts b/packages/runtime-renderer/src/renderer/page-function/css.ts new file mode 100644 index 0000000000..a94ac6a288 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/page-function/css.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import postcss from 'postcss' +import scopedPlugin from './scope-css-plugin' +import config from '../../../config.ts' + +export function getBlockCssScopeId(fileName?: string): string { + const invalidateCharRE = /[^a-z0-9-]/g + const normalized = String(fileName || 'default') + .toLowerCase() + .replace(invalidateCharRE, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + return `data-render-block-${normalized}` +} + +export function normalizeScopeKey(pageId?: string, isBlock?: boolean): string { + const id = String(pageId) + if (!id) { + return 'data-render-page-default' + } else if (id.startsWith('data-render-')) { + return id + } else if (isBlock) { + return getBlockCssScopeId(id) + } else { + return `data-render-page-${id}` + } +} + +export function handleScopedCss(id: string, content: string) { + const plugins = id ? [scopedPlugin(id)] : [] + return postcss(plugins).process(content, { from: undefined }) +} + +export function addStyle(key: string, content: string) { + if (!content) return + let styleSheet = document.querySelector(`#${key}`) + + if (!styleSheet) { + styleSheet = document.createElement('style') + styleSheet.setAttribute('id', key) + if (config.enableTailwindCSS) { + styleSheet.setAttribute('type', 'text/tailwindcss') + } + document.head.appendChild(styleSheet) + } + const id = { [key]: key, 'app-global-css': '' }[key] + handleScopedCss(id, content).then((scopedCss) => { + styleSheet.textContent = scopedCss.css + }) +} +export function setPageCss(css: string = '', pageId?: string): void { + addStyle(normalizeScopeKey(pageId), css) +} + +function clearPageCss(key: string): void { + const styleSheet = document.querySelector(`#${key}`) + if (styleSheet) { + styleSheet.remove() + } +} + +function clearAllPageCSS(): void { + const styleSheets = document.head.querySelectorAll('[id^="data-render-page-"]') + styleSheets?.forEach((styleSheet) => { + styleSheet.remove() + }) +} +export default { + setPageCss, + clearPageCss, + clearAllPageCSS +} diff --git a/packages/runtime-renderer/src/renderer/page-function/index.ts b/packages/runtime-renderer/src/renderer/page-function/index.ts new file mode 100644 index 0000000000..11ced47043 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/page-function/index.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +export * from './css' +export * from './lifecyle' diff --git a/packages/runtime-renderer/src/renderer/page-function/lifecyle.ts b/packages/runtime-renderer/src/renderer/page-function/lifecyle.ts new file mode 100644 index 0000000000..fef82910b8 --- /dev/null +++ b/packages/runtime-renderer/src/renderer/page-function/lifecyle.ts @@ -0,0 +1,94 @@ +import { Notify } from '@opentiny/vue' +import { parseData } from '../data-function' +import type { JSFunction } from '../../types/index.ts' +import { + onBeforeMount, + onMounted, + onBeforeUpdate, + onUpdated, + onBeforeUnmount, + onUnmounted, + onErrorCaptured, + onActivated, + onDeactivated +} from 'vue' + +const executeUserLifecycle = (hookName: string, lifeCycleConfig: JSFunction | undefined, context: any) => { + if (!lifeCycleConfig || lifeCycleConfig.type !== 'JSFunction') { + return + } + + try { + const fn = parseData(lifeCycleConfig, {}, context) + if (typeof fn === 'function') { + fn.call(context, context) + } + } catch (error) { + Notify({ + type: 'warning', + title: `${hookName} 生命周期执行失败`, + message: (error as any)?.message || `${hookName} 生命周期函数执行报错,请检查语法` + }) + } +} +export function registerLifecycleHooks(lifeCycles: any, context: any) { + // 注册生命周期钩子 + if (lifeCycles?.setup) { + executeUserLifecycle('setup', lifeCycles?.setup, context) + } + + if (lifeCycles?.onBeforeMount) { + onBeforeMount(() => { + executeUserLifecycle('onBeforeMount', lifeCycles.onBeforeMount, context) + }) + } + + if (lifeCycles?.onMounted) { + onMounted(() => { + executeUserLifecycle('onMounted', lifeCycles.onMounted, context) + }) + } + + if (lifeCycles?.onBeforeUpdate) { + onBeforeUpdate(() => { + executeUserLifecycle('onBeforeUpdate', lifeCycles.onBeforeUpdate, context) + }) + } + + if (lifeCycles?.onUpdated) { + onUpdated(() => { + executeUserLifecycle('onUpdated', lifeCycles.onUpdated, context) + }) + } + + if (lifeCycles?.onBeforeUnmount) { + onBeforeUnmount(() => { + executeUserLifecycle('onBeforeUnmount', lifeCycles.onBeforeUnmount, context) + }) + } + + if (lifeCycles?.onUnmounted) { + onUnmounted(() => { + executeUserLifecycle('onUnmounted', lifeCycles.onUnmounted, context) + }) + } + + if (lifeCycles?.onErrorCaptured) { + onErrorCaptured((_error, _instance, _info) => { + executeUserLifecycle('onErrorCaptured', lifeCycles.onErrorCaptured, context) + return true + }) + } + + if (lifeCycles?.onActivated) { + onActivated(() => { + executeUserLifecycle('onActivated', lifeCycles.onActivated, context) + }) + } + + if (lifeCycles?.onDeactivated) { + onDeactivated(() => { + executeUserLifecycle('onDeactivated', lifeCycles.onDeactivated, context) + }) + } +} diff --git a/packages/runtime-renderer/src/renderer/page-function/scope-css-plugin.ts b/packages/runtime-renderer/src/renderer/page-function/scope-css-plugin.ts new file mode 100644 index 0000000000..bc973e1d2a --- /dev/null +++ b/packages/runtime-renderer/src/renderer/page-function/scope-css-plugin.ts @@ -0,0 +1,193 @@ +/** @ref {@vue/compiler-sfc@2.7.16/src/stylePlugins/scoped.ts } */ +/* eslint-disable @typescript-eslint/no-use-before-define, prefer-const*/ +import { type PluginCreator, Rule, AtRule } from 'postcss' +import selectorParser from 'postcss-selector-parser' + +const animationNameRE = /^(-\w+-)?animation-name$/ +const animationRE = /^(-\w+-)?animation$/ + +const scopedPlugin: PluginCreator = (id = '') => { + const keyframes = Object.create(null) + const shortId = id.replace(/^data-v-/, '') + + return { + postcssPlugin: 'vue-sfc-scoped', + Rule(rule) { + processRule(id, rule) + }, + AtRule(node) { + if (/-?keyframes$/.test(node.name) && !node.params.endsWith(`-${shortId}`)) { + // register keyframes + keyframes[node.params] = node.params = node.params + '-' + shortId + } + }, + OnceExit(root) { + if (Object.keys(keyframes).length) { + // If keyframes are found in this