diff --git a/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js b/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js index d914af40b4..cf0c78dd87 100644 --- a/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js +++ b/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js @@ -44,6 +44,7 @@ const getDevAlias = (useSourceAlias) => { '@opentiny/tiny-engine-toolbar-media': path.resolve(basePath, 'packages/toolbars/media/index.ts'), '@opentiny/tiny-engine-toolbar-preview': path.resolve(basePath, 'packages/toolbars/preview/index.ts'), '@opentiny/tiny-engine-toolbar-generate-code': path.resolve(basePath, 'packages/toolbars/generate-code/index.ts'), + '@opentiny/tiny-engine-toolbar-upload': path.resolve(basePath, 'packages/toolbars/upload/index.ts'), '@opentiny/tiny-engine-toolbar-refresh': path.resolve(basePath, 'packages/toolbars/refresh/index.ts'), '@opentiny/tiny-engine-toolbar-redoundo': path.resolve(basePath, 'packages/toolbars/redoundo/index.ts'), '@opentiny/tiny-engine-toolbar-clean': path.resolve(basePath, 'packages/toolbars/clean/index.ts'), @@ -70,7 +71,8 @@ const getDevAlias = (useSourceAlias) => { '@opentiny/tiny-engine-workspace-template-center': path.resolve( basePath, 'packages/workspace/template-center/index.ts' - ) + ), + '@opentiny/tiny-engine-vue-to-dsl': path.resolve(basePath, 'packages/vue-to-dsl/src/index.ts') } } diff --git a/packages/design-core/package.json b/packages/design-core/package.json index 02cc0c14c5..931f4c7a0e 100644 --- a/packages/design-core/package.json +++ b/packages/design-core/package.json @@ -76,6 +76,7 @@ "@opentiny/tiny-engine-toolbar-collaboration": "workspace:*", "@opentiny/tiny-engine-toolbar-fullscreen": "workspace:*", "@opentiny/tiny-engine-toolbar-generate-code": "workspace:*", + "@opentiny/tiny-engine-toolbar-upload": "workspace:*", "@opentiny/tiny-engine-toolbar-lang": "workspace:*", "@opentiny/tiny-engine-toolbar-lock": "workspace:*", "@opentiny/tiny-engine-toolbar-logo": "workspace:*", diff --git a/packages/design-core/re-export.js b/packages/design-core/re-export.js index 01c5a72a5b..2d3a51cd25 100644 --- a/packages/design-core/re-export.js +++ b/packages/design-core/re-export.js @@ -12,6 +12,7 @@ export { default as Clean } from '@opentiny/tiny-engine-toolbar-clean' export { default as ThemeSwitch, ThemeSwitchService } from '@opentiny/tiny-engine-toolbar-theme-switch' export { default as Preview } from '@opentiny/tiny-engine-toolbar-preview' export { default as GenerateCode, SaveLocalService } from '@opentiny/tiny-engine-toolbar-generate-code' +export { default as Upload } from '@opentiny/tiny-engine-toolbar-upload' export { default as Refresh } from '@opentiny/tiny-engine-toolbar-refresh' export { default as Collaboration } from '@opentiny/tiny-engine-toolbar-collaboration' export { default as Setting } from '@opentiny/tiny-engine-toolbar-setting' diff --git a/packages/design-core/registry.js b/packages/design-core/registry.js index 9108e25304..799c92254b 100644 --- a/packages/design-core/registry.js +++ b/packages/design-core/registry.js @@ -26,6 +26,7 @@ import { ThemeSwitch, Preview, GenerateCode, + Upload, Refresh, Collaboration, Materials, @@ -153,6 +154,7 @@ export default { __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.preview'] === false ? null : Preview, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.refresh'] === false ? null : Refresh, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.generate-code'] === false ? null : GenerateCode, + __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.upload'] === false ? null : Upload, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.save'] === false ? null : Save, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.fullscreen'] === false ? null : Fullscreen, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.lang'] === false ? null : Lang, diff --git a/packages/layout/src/defaultLayout.js b/packages/layout/src/defaultLayout.js index b234bfc19e..080b50fe98 100644 --- a/packages/layout/src/defaultLayout.js +++ b/packages/layout/src/defaultLayout.js @@ -28,7 +28,7 @@ export default { right: [ [META_APP.Robot, META_APP.ThemeSwitch, META_APP.RedoUndo, META_APP.Clean], [META_APP.Preview], - [META_APP.GenerateCode, META_APP.Save] + [META_APP.Upload, META_APP.GenerateCode, META_APP.Save] ], collapse: [ [META_APP.Collaboration], diff --git a/packages/register/src/constants.ts b/packages/register/src/constants.ts index e4a355d6e2..b2bdce1b00 100644 --- a/packages/register/src/constants.ts +++ b/packages/register/src/constants.ts @@ -41,6 +41,7 @@ export const META_APP = { Refresh: 'engine.toolbars.refresh', Save: 'engine.toolbars.save', GenerateCode: 'engine.toolbars.generate-code', + Upload: 'engine.toolbars.upload', Preview: 'engine.toolbars.preview', RedoUndo: 'engine.toolbars.redoundo', Fullscreen: 'engine.toolbars.fullscreen', diff --git a/packages/toolbars/upload/index.ts b/packages/toolbars/upload/index.ts new file mode 100644 index 0000000000..5efbd88c4b --- /dev/null +++ b/packages/toolbars/upload/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. + * + */ + +import entry from './src/Main.vue' +import metaData from './meta' +import './src/styles/vars.less' + +export default { + ...metaData, + entry +} diff --git a/packages/toolbars/upload/meta.js b/packages/toolbars/upload/meta.js new file mode 100644 index 0000000000..0d8f8a47cd --- /dev/null +++ b/packages/toolbars/upload/meta.js @@ -0,0 +1,11 @@ +export default { + id: 'engine.toolbars.upload', + type: 'toolbars', + title: 'upload', + options: { + icon: { + default: 'upload' + }, + renderType: 'button' + } +} diff --git a/packages/toolbars/upload/package.json b/packages/toolbars/upload/package.json new file mode 100644 index 0000000000..3d8d7c1490 --- /dev/null +++ b/packages/toolbars/upload/package.json @@ -0,0 +1,44 @@ +{ + "name": "@opentiny/tiny-engine-toolbar-upload", + "version": "2.10.0", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vite build" + }, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/opentiny/tiny-engine", + "directory": "packages/toolbars/upload" + }, + "bugs": { + "url": "https://github.com/opentiny/tiny-engine/issues" + }, + "author": "OpenTiny Team", + "license": "MIT", + "homepage": "https://opentiny.design/tiny-engine", + "dependencies": { + "@opentiny/tiny-engine-common": "workspace:*", + "@opentiny/tiny-engine-meta-register": "workspace:*", + "@opentiny/tiny-engine-utils": "workspace:*", + "@opentiny/tiny-engine-vue-to-dsl": "workspace:*" + }, + "devDependencies": { + "@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*", + "@vitejs/plugin-vue": "^5.1.2", + "@vitejs/plugin-vue-jsx": "^4.0.1", + "vite": "^5.4.2" + }, + "peerDependencies": { + "@opentiny/vue": "^3.20.0", + "@opentiny/vue-icon": "^3.20.0", + "vue": "^3.4.15" + } +} diff --git a/packages/toolbars/upload/src/Main.vue b/packages/toolbars/upload/src/Main.vue new file mode 100644 index 0000000000..9cf366994d --- /dev/null +++ b/packages/toolbars/upload/src/Main.vue @@ -0,0 +1,724 @@ + + + + diff --git a/packages/toolbars/upload/src/OverwriteDialog.vue b/packages/toolbars/upload/src/OverwriteDialog.vue new file mode 100644 index 0000000000..6d2f326352 --- /dev/null +++ b/packages/toolbars/upload/src/OverwriteDialog.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/packages/toolbars/upload/src/http.ts b/packages/toolbars/upload/src/http.ts new file mode 100644 index 0000000000..453a10ad1c --- /dev/null +++ b/packages/toolbars/upload/src/http.ts @@ -0,0 +1,30 @@ +/** + * 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. + * + */ + +/* metaService: engine.toolbars.upload.http */ + +import { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' + +// 获取页面列表 +export const fetchPageList = (appId: string) => getMetaApi(META_SERVICE.Http).get(`/app-center/api/pages/list/${appId}`) + +// 获取区块分组列表 +export const fetchBlockGroups = (params?: any) => + getMetaApi(META_SERVICE.Http).get('/material-center/api/block-groups', { params: { ...params, from: 'block' } }) + +// 创建区块分组 +export const createBlockGroup = (params: any) => + getMetaApi(META_SERVICE.Http).post('/material-center/api/block-groups/create', params) + +// 创建区块 +export const createBlock = (params: any) => + getMetaApi(META_SERVICE.Http).post('/material-center/api/block/create', params) diff --git a/packages/toolbars/upload/src/styles/vars.less b/packages/toolbars/upload/src/styles/vars.less new file mode 100644 index 0000000000..4f35f889b5 --- /dev/null +++ b/packages/toolbars/upload/src/styles/vars.less @@ -0,0 +1,12 @@ +:root { + --te-toolbars-upload-button-bg-color: var(--te-common-bg-prompt); + --te-toolbars-upload-text-color-primary: var(--te-common-text-primary); + --te-toolbars-upload-text-color-secondary: var(--te-common-text-secondary); + --te-toolbars-upload-icon-color: var(--te-common-icon-secondary); + --te-toolbars-upload-icon-color-primary: var(--te-common-icon-primary); + --te-toolbars-upload-bg-color-primary: var(--te-common-bg-primary); + --te-toolbars-upload-bg-color: var(--te-common-bg-default); + --te-toolbars-upload-bg-color-hover: var(--te-common-bg-container); + --te-toolbars-upload-border-color-checked: var(--te-common-border-checked); + --te-toolbars-upload-border-color-divider: var(--te-common-border-divider); +} diff --git a/packages/toolbars/upload/vite.config.ts b/packages/toolbars/upload/vite.config.ts new file mode 100644 index 0000000000..af7bc73920 --- /dev/null +++ b/packages/toolbars/upload/vite.config.ts @@ -0,0 +1,39 @@ +/** + * 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 { defineConfig } from 'vite' +import path from 'path' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import generateComment from '@opentiny/tiny-engine-vite-plugin-meta-comments' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [generateComment(), vue(), vueJsx()], + publicDir: false, + resolve: {}, + build: { + sourcemap: true, + lib: { + entry: path.resolve(__dirname, './index.ts'), + name: 'toolbar-upload', + fileName: (_format, entryName) => `${entryName}.js`, + formats: ['es'] + }, + rollupOptions: { + output: { + banner: 'import "./style.css"' + }, + external: ['vue', /@opentiny\/tiny-engine.*/, /@opentiny\/vue.*/] + } + } +}) diff --git a/packages/vue-to-dsl/.gitignore b/packages/vue-to-dsl/.gitignore new file mode 100644 index 0000000000..9b1960e711 --- /dev/null +++ b/packages/vue-to-dsl/.gitignore @@ -0,0 +1 @@ +output/ \ No newline at end of file diff --git a/packages/vue-to-dsl/README.md b/packages/vue-to-dsl/README.md new file mode 100644 index 0000000000..c041ca2597 --- /dev/null +++ b/packages/vue-to-dsl/README.md @@ -0,0 +1,224 @@ +# @opentiny/tiny-engine-vue-to-dsl + +> 将 Vue 代码文件/项目反向转换为 TinyEngine DSL Schema 的工具包 + +## 简介 + +`@opentiny/tiny-engine-vue-to-dsl` 解析 Vue 代码文件,生成可用于 TinyEngine 的 DSL Schema。同时内置“整包应用”转换能力,可从项目目录或 zip 包中聚合出 App 级 Schema(含 i18n、数据源、全局状态、页面元信息等)。 + +## 主要特性 + +- 支持模板、脚本(Options API / script setup)、样式的完整解析 +- 从 Vue 工程目录或 zip 文件生成 AppSchema +- 可配置组件映射、可插拔自定义解析器 +- TypeScript 实现,导出完整类型;提供单元与集成测试 + +## 安装 + +```bash +pnpm add @opentiny/tiny-engine-vue-to-dsl +``` + +## 目录结构 + +```text +src/ +├─ converter.ts # 主转换器(含 app 级聚合与 zip 支持) +├─ generator/ # schema 生成与归一 +├─ parser/ # SFC 粗分(template/script/style 块) +├─ parsers/ # 各块解析实现 +├─ constants.ts # 组件映射与组件包清单 +├─ index.ts # 包导出入口 +└─ types/ # 类型导出 +``` + +## 快速开始 + +```ts +import { VueToDslConverter } from '@opentiny/tiny-engine-vue-to-dsl'; + +const converter = new VueToDslConverter(); +const vueCode = ` + + + +`; + +const result = await converter.convertFromString(vueCode, 'Hello.vue'); +console.log(result.schema); +``` + +## API 概览 + +入口:`src/index.ts` + +导出: + +- `VueToDslConverter` 主转换器 +- 解析工具:`parseVueFile`、`parseSFC` +- 生成器:`generateSchema`、`generateAppSchema` +- 细分解析器:`parseTemplate`、`parseScript`、`parseStyle` +- 类型与常量:`types/*`、默认组件映射 `defaultComponentMap`、默认组件包清单 `defaultComponentsMap` + +### VueToDslConverter + +```ts +new VueToDslConverter(options?: VueToSchemaOptions) + +interface VueToSchemaOptions { + componentMap?: Record + preserveComments?: boolean + strictMode?: boolean + // 控制是否输出 computed 字段(默认 false) + computed_flag?: boolean + customParsers?: { + template?: { parse: (code: string) => any } + script?: { parse: (code: string) => any } + style?: { parse: (code: string) => any } + } + fileName?: string + path?: string + title?: string + description?: string +} + +type ConvertResult = { + schema: any | null + dependencies: string[] + errors: string[] + warnings: string[] +} +``` + +实例方法: + +- `convertFromString(code, fileName?)`:从字符串转换 +- `convertFromFile(filePath)`:从文件转换 +- `convertMultipleFiles(filePaths)`:批量转换 +- `convertAppDirectory(appDir)`:从工程目录(约定 src/ 结构)生成 App 级 schema +- `convertAppFromZip(zipBuffer)`:从 zip Buffer 生成 App 级 schema(Node 与浏览器均可用) +- `setOptions(partial)` / `getOptions()`:运行期更新/读取配置 + +### App 级聚合产物(convertAppDirectory/convertAppFromZip) + +输出结构(概要): + +```ts +{ + meta: { name, description, generatedAt, generator }, + i18n: { en_US: {}, zh_CN: {} }, + utils: Array<{ + name: string, + type: 'npm' | 'function', + content: { type: 'JSFunction', value: string, package?: string, destructuring?: boolean, exportName?: string } + }>, + dataSource: { list: any[] }, + globalState: Array<{ id: string, state: Record }>, + pageSchema: any[], + componentsMap: typeof defaultComponentsMap +} +``` + +数据来源约定: + +- 页面:`src/views/**/*.vue` +- i18n:`src/i18n/en_US.json`、`src/i18n/zh_CN.json` +- 工具函数:`src/utils.js`(简单 import/export 分析,支持命名/默认导入导出) +- 数据源:`src/lowcodeConfig/dataSource.json` +- 全局状态:`src/stores/*.js`(简易 Pinia `defineStore` 解析,只提取 state 返回对象) +- 路由:`src/router/index.js`(提取 name/path 与 import 的页面文件,设置 `meta.router/isPage/isHome`) + +## 模板/脚本/样式支持 + +模板(`parseTemplate`) + +- HTML 标签与自定义组件;通过 `componentMap` 做名称映射 +- 指令:`v-if`/`v-for`/`v-show`/`v-model`/`v-on`/`v-bind`/`v-slot` 等核心指令 +- v-for:尝试抽取迭代表达式,写入 `loop: { type: 'JSExpression', value: 'this.xxx' }` +- 事件与绑定:能解析简单字面量,复杂表达式以 `JSExpression` 形式保留 +- 文本与插值:转为 `Text` 组件;插值为 `JSExpression` +- 特殊:`tiny-icon-*` 归一为通用 `Icon` 组件并写入 `name` 属性 + +脚本(`parseScript`) + +- script setup: + - `reactive`/`ref` 识别到 state;`computed` 识别到 computed + - 顶层函数与返回对象内成员识别到 methods + - onMounted/onUpdated... 等生命周期识别 +- Options API: + - `props`(数组语法)/`methods`/`computed`/生命周期基础支持 +- import 收集:用于返回 `dependencies` + +样式(`parseStyle` + 辅助) + +- 基础样式串:直出 `css` +- 辅助能力:`parseCSSRules`、`extractCSSVariables`、`hasMediaQueries`、`extractMediaQueries` + +## 输出 Schema 约定(页面级) + +- 根节点 `componentName: 'Page'`,自动补齐 `id`(8 位字母数字) +- `state`/`methods`/`computed`/`lifecycle` 值以 `{ type: 'JSFunction', value: string }` 表达(state 中基础类型按需折叠) +- `children` 为模板树;属性中无法安全字面量化的表达式以 `JSExpression` 表达 +- 所有字符串做轻度“去换行/多空格”规整 + +## 测试用例说明 + +测试目录位于 `test/`,包含: + +- `test/sfc/`:单个 SFC 的基础转换测试 +- `test/testcases/`:按用例目录组织的场景测试(新增用例放这里) +- `test/full/`:整包项目/zip 的端到端转换测试 + +在本包目录 `packages/vue-to-dsl` 下使用 Vitest 进行单元与集成测试,运行: + +```bash +pnpm i +pnpm test +# 或 +npx vitest run +``` + +运行后会将每个用例的结果写入 `output/schema.json`,便于比对。 + +用例结构(示例): + +```text +test/testcases/ + 001_simple/ + input/component.vue # 输入 SFC + expected/schema.json # 期望 Schema(可为“子集”) + output/schema.json # 测试生成(自动写入) +``` + +断言规则(见 `test/testcases/index.test.js`): + +- 忽略动态字段:递归忽略所有层级的 `meta` 与 `id` +- 子集匹配:实际输出只需“包含” expected 的结构和值(数组按 expected 长度顺序比对前 N 项) +- 若 expected 含 `error: true`:仅断言发生错误并允许 schema 存在部分内容 + +因此 expected 可仅保留关键片段,无需完全复制整个 schema,适合 children 很多的页面。 + +新增用例步骤: + +1. 在 `test/testcases/` 新建目录(序号递增) +2. 添加 `input/component.vue` +3. 添加最小化 `expected/schema.json`(仅关键字段) +4. 运行测试,参考 `output/schema.json` 微调 expected + +组件映射: + +- 本测试文件内已设置常用 OpenTiny 组件映射(`tiny-form`、`tiny-grid`、`tiny-select`、`tiny-button-group`、`tiny-time-line` 等) +- 如使用未映射组件,可在测试中补充 `componentMap`,或在用例中用已映射组件替代 diff --git a/packages/vue-to-dsl/cli.ts b/packages/vue-to-dsl/cli.ts new file mode 100644 index 0000000000..3fabf3ea22 --- /dev/null +++ b/packages/vue-to-dsl/cli.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ +/** + * Vue To DSL CLI Tool (TypeScript) + * 命令行工具,用于将Vue SFC文件转换为TinyEngine DSL Schema + */ + +import fs from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' +import { VueToDslConverter } from './src/converter' + +// 解决在 ESM 下使用 __dirname/__filename +const __filename = fileURLToPath(import.meta.url) + +// 命令行参数解析 +const args = process.argv.slice(2) + +const showHelp = args.includes('--help') || args.includes('-h') +if (args.length === 0 || showHelp) { + console.log(` +使用方法: + node ${path.basename(__filename)} [options] + +选项: + --output, -o 输出文件路径 + --format, -f 输出格式 (json, js) 默认: json + --help, -h 显示帮助信息 + --computed 启用输出 computed 字段(默认关闭) + +示例: + node ${path.basename(__filename)} ./components/MyComponent.vue + node ${path.basename(__filename)} ./components/MyComponent.vue --output ./output/schema.json + node ${path.basename(__filename)} ./components/MyComponent.vue --format js --output ./output/schema.js +`) + process.exit(0) +} + +// 解析参数 +const inputFile = args[0] +let outputFile: string | undefined +let format: 'json' | 'js' = 'json' +let computedFlag = false + +for (let i = 1; i < args.length; ) { + const option = args[i] + const value = args[i + 1] + + switch (option) { + case '--output': + case '-o': + outputFile = value + i += 2 + break + case '--format': + case '-f': + if (value === 'json' || value === 'js') { + format = value + } + i += 2 + break + case '--help': + case '-h': + console.log('显示帮助信息...') + process.exit(0) + break + case '--computed': + computedFlag = true + i += 1 + break + default: + // 跳过无法识别的参数,避免死循环 + i += 1 + } +} + +// 设置默认输出文件 +if (!outputFile) { + const baseName = path.basename(inputFile, '.vue') + outputFile = `${baseName}-schema.${format}` +} +const outputPath = outputFile as string + +/** + * 获取Schema统计信息 + */ +function getSchemaStats(schema: any) { + return { + stateCount: schema.state ? Object.keys(schema.state).length : 0, + methodCount: schema.methods ? Object.keys(schema.methods).length : 0, + computedCount: schema.computed ? Object.keys(schema.computed).length : 0, + lifecycleCount: schema.lifeCycles ? Object.keys(schema.lifeCycles).length : 0, + childrenCount: schema.children ? schema.children.length : 0, + cssLength: schema.css ? schema.css.length : 0 + } +} + +async function main() { + try { + console.log('🚀 开始转换Vue文件到DSL Schema...') + console.log(`📁 输入文件: ${inputFile}`) + console.log(`📄 输出文件: ${outputPath}`) + console.log(`📋 输出格式: ${format}`) + console.log() + + // 检查输入文件是否存在 + try { + await fs.access(inputFile) + } catch (error) { + console.error(`❌ 错误: 文件不存在 - ${inputFile}`) + process.exit(1) + } + + // 创建转换器 + const converter = new VueToDslConverter({ + componentMap: { + button: 'TinyButton', + input: 'TinyInput', + form: 'TinyForm' + }, + preserveComments: false, + strictMode: false, + computed_flag: computedFlag + }) + + // 执行转换 + const result = await converter.convertFromFile(inputFile) + + // 显示转换结果 + if (result.errors.length > 0) { + console.log('⚠️ 转换过程中的错误:') + result.errors.forEach((error: string) => console.log(` - ${error}`)) + console.log() + } + + if (result.warnings.length > 0) { + console.log('⚠️ 转换过程中的警告:') + result.warnings.forEach((warning: string) => console.log(` - ${warning}`)) + console.log() + } + + if (result.dependencies.length > 0) { + console.log('📦 发现的依赖项:') + result.dependencies.forEach((dep: string) => console.log(` - ${dep}`)) + console.log() + } + + if (!result.schema) { + console.error('❌ 转换失败,未生成Schema') + process.exit(1) + } + + // 生成输出内容 + let outputContent: string + if (format === 'json') { + outputContent = JSON.stringify(result.schema, null, 2) + } else if (format === 'js') { + outputContent = `// Generated DSL Schema from ${inputFile} +// Generated at: ${new Date().toISOString()} + +export default ${JSON.stringify(result.schema, null, 2)} +` + } else { + console.error(`❌ 错误: 不支持的输出格式 - ${format}`) + process.exit(1) + return + } + + // 确保输出目录存在 + const outputDir = path.dirname(outputPath) + if (outputDir !== '.' && outputDir !== '') { + await fs.mkdir(outputDir, { recursive: true }) + } + + // 写入输出文件 + await fs.writeFile(outputPath, outputContent, 'utf-8') + + console.log('✅ 转换完成!') + console.log(`📁 输出文件已保存到: ${outputPath}`) + + // 显示Schema统计信息 + const stats = getSchemaStats(result.schema) + console.log() + console.log('📊 Schema统计信息:') + console.log(` 组件名称: ${result.schema.componentName}`) + console.log(` 文件名称: ${result.schema.fileName}`) + console.log(` 状态数量: ${stats.stateCount}`) + console.log(` 方法数量: ${stats.methodCount}`) + console.log(` 计算属性: ${stats.computedCount}`) + console.log(` 生命周期: ${stats.lifecycleCount}`) + console.log(` 子组件数: ${stats.childrenCount}`) + console.log(` CSS长度: ${stats.cssLength} 字符`) + } catch (error: any) { + console.error('❌ 转换过程中发生错误:') + console.error(error?.message || error) + if (error?.stack) console.error(error.stack) + process.exit(1) + } +} + +// 运行主函数 +void main() diff --git a/packages/vue-to-dsl/package.json b/packages/vue-to-dsl/package.json new file mode 100644 index 0000000000..acbea172ed --- /dev/null +++ b/packages/vue-to-dsl/package.json @@ -0,0 +1,55 @@ +{ + "name": "@opentiny/tiny-engine-vue-to-dsl", + "version": "1.0.0", + "description": "Convert Vue SFC files back to TinyEngine DSL schema", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "dist/tiny-engine-vue-to-dsl.cjs", + "module": "dist/tiny-engine-vue-to-dsl.js", + "types": "dist/index.d.ts", + "bin": { + "tiny-vue-to-dsl": "dist/cli.cjs" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run build:types && vite build && pnpm run build:cli", + "build:types": "tsc -p tsconfig.json", + "build:cli": "vite build --config vite.config.cli.mjs", + "test": "vitest", + "test:unit": "vitest run", + "coverage": "vitest run --coverage", + "dev": "vite build --watch" + }, + "repository": { + "type": "git", + "url": "https://github.com/opentiny/tiny-engine", + "directory": "packages/vue-to-dsl" + }, + "bugs": { + "url": "https://github.com/opentiny/tiny-engine/issues" + }, + "author": "OpenTiny Team", + "license": "MIT", + "homepage": "https://opentiny.design/tiny-engine", + "dependencies": { + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0", + "@vue/compiler-dom": "^3.4.15", + "@vue/compiler-sfc": "^3.4.15", + "jszip": "^3.10.1", + "vue": "^3.4.15" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "@vitest/coverage-v8": "^1.4.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0", + "vite": "^5.4.2", + "vitest": "^1.4.0" + } +} diff --git a/packages/vue-to-dsl/src/constants.ts b/packages/vue-to-dsl/src/constants.ts new file mode 100644 index 0000000000..2a77f66ce0 --- /dev/null +++ b/packages/vue-to-dsl/src/constants.ts @@ -0,0 +1,217 @@ +export const defaultComponentMap: Record = { + 'tiny-form': 'TinyForm', + 'tiny-form-item': 'TinyFormItem', + 'tiny-button': 'TinyButton', + 'tiny-button-group': 'TinyButtonGroup', + 'tiny-switch': 'TinySwitch', + 'tiny-select': 'TinySelect', + 'tiny-search': 'TinySearch', + 'tiny-input': 'TinyInput', + 'tiny-grid': 'TinyGrid', + 'tiny-grid-item': 'TinyGridItem', + 'tiny-col': 'TinyCol', + 'tiny-row': 'TinyRow', + 'tiny-time-line': 'TinyTimeLine', + 'tiny-card': 'TinyCard' +} + +export const defaultComponentsMap = [ + { + componentName: 'TinyCarouselItem', + package: '@opentiny/vue', + exportName: 'CarouselItem', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyCheckboxButton', + package: '@opentiny/vue', + exportName: 'CheckboxButton', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyTree', package: '@opentiny/vue', exportName: 'Tree', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyPopover', + package: '@opentiny/vue', + exportName: 'Popover', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyTooltip', + package: '@opentiny/vue', + exportName: 'Tooltip', + destructuring: true, + version: '3.2.0' + }, + { componentName: 'TinyCol', package: '@opentiny/vue', exportName: 'Col', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyDropdownItem', + package: '@opentiny/vue', + exportName: 'DropdownItem', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyPager', package: '@opentiny/vue', exportName: 'Pager', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyPlusAccessdeclined', + package: '@opentiny/vue', + exportName: 'AccessDeclined', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinyPlusFrozenPage', + package: '@opentiny/vue', + exportName: 'FrozenPage', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinyPlusNonSupportRegion', + package: '@opentiny/vue', + exportName: 'NonSupportRegion', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinyPlusBeta', + package: '@opentiny/vue', + exportName: 'Beta', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinySearch', + package: '@opentiny/vue', + exportName: 'Search', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyRow', package: '@opentiny/vue', exportName: 'Row', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyFormItem', + package: '@opentiny/vue', + exportName: 'FormItem', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyAlert', package: '@opentiny/vue', exportName: 'Alert', destructuring: true, version: '3.2.0' }, + { componentName: 'TinyInput', package: '@opentiny/vue', exportName: 'Input', destructuring: true, version: '3.24.0' }, + { componentName: 'TinyTabs', package: '@opentiny/vue', exportName: 'Tabs', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyDropdownMenu', + package: '@opentiny/vue', + exportName: 'DropdownMenu', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyDialogBox', + package: '@opentiny/vue', + exportName: 'DialogBox', + destructuring: true, + version: '3.2.0' + }, + { + componentName: 'TinySwitch', + package: '@opentiny/vue', + exportName: 'Switch', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyTimeLine', + package: '@opentiny/vue', + exportName: 'TimeLine', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyTabItem', + package: '@opentiny/vue', + exportName: 'TabItem', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyRadio', package: '@opentiny/vue', exportName: 'Radio', destructuring: true, version: '3.24.0' }, + { componentName: 'TinyForm', package: '@opentiny/vue', exportName: 'Form', destructuring: true, version: '3.24.0' }, + { componentName: 'TinyGrid', package: '@opentiny/vue', exportName: 'Grid', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyNumeric', + package: '@opentiny/vue', + exportName: 'Numeric', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyCheckboxGroup', + package: '@opentiny/vue', + exportName: 'CheckboxGroup', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinySelect', + package: '@opentiny/vue', + exportName: 'Select', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyButtonGroup', + package: '@opentiny/vue', + exportName: 'ButtonGroup', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyButton', + package: '@opentiny/vue', + exportName: 'Button', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyCarousel', + package: '@opentiny/vue', + exportName: 'Carousel', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyPopeditor', + package: '@opentiny/vue', + exportName: 'Popeditor', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyDatePicker', + package: '@opentiny/vue', + exportName: 'DatePicker', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyDropdown', + package: '@opentiny/vue', + exportName: 'Dropdown', + destructuring: true, + version: '0.1.20' + }, + { + componentName: 'TinyChartHistogram', + package: '@opentiny/vue', + exportName: 'ChartHistogram', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'PortalHome', main: 'common/components/home', destructuring: false, version: '1.0.0' }, + { componentName: 'PreviewBlock1', main: 'preview', destructuring: false, version: '1.0.0' }, + { componentName: 'PortalHeader', main: 'common', destructuring: false, version: '1.0.0' }, + { componentName: 'PortalBlock', main: 'portal', destructuring: false, version: '1.0.0' }, + { componentName: 'PortalPermissionBlock', main: '', destructuring: false, version: '1.0.0' }, + { componentName: 'TinyCard', exportName: 'Card', package: '@opentiny/vue', version: '^3.10.0', destructuring: true } +] diff --git a/packages/vue-to-dsl/src/converter.ts b/packages/vue-to-dsl/src/converter.ts new file mode 100644 index 0000000000..1bcd471c48 --- /dev/null +++ b/packages/vue-to-dsl/src/converter.ts @@ -0,0 +1,1214 @@ +import { parseSFC } from './parser/index' +import { parseTemplate } from './parsers/templateParser' +import { parseScript } from './parsers/scriptParser' +import { parseStyle } from './parsers/styleParser' +import { generateSchema, generateAppSchema } from './generator/index' +import { defaultComponentMap } from './constants' +import fs from 'fs/promises' +import path from 'path' +import os from 'os' +import JSZip from 'jszip' + +export interface VueToSchemaOptions { + componentMap?: Record + preserveComments?: boolean + strictMode?: boolean + // 控制是否在出码结果中包含 computed 字段,默认 false + computed_flag?: boolean + customParsers?: { + template?: { parse: (code: string) => any } + script?: { parse: (code: string) => any } + style?: { parse: (code: string) => any } + } + fileName?: string + path?: string + title?: string + description?: string +} + +export interface ConvertResult { + schema: any | null + dependencies: string[] + errors: string[] + warnings: string[] +} + +export class VueToDslConverter { + private options: VueToSchemaOptions + + constructor(options: VueToSchemaOptions = {}) { + this.options = { + componentMap: defaultComponentMap, + preserveComments: false, + strictMode: false, + computed_flag: false, + customParsers: {}, + ...options + } + } + + async convertFromString(vueCode: string, fileName?: string): Promise { + const errors: string[] = [] + const warnings: string[] = [] + const dependencies: string[] = [] + + try { + const sfcResult = parseSFC(vueCode) + if (!sfcResult.template && !sfcResult.scriptSetup && !sfcResult.script) { + throw new Error('Invalid Vue SFC: no template or script found') + } + + let templateSchema: any[] = [] + let scriptSchema: any = {} + let styleSchema: any = {} + + const scriptContent = sfcResult.scriptSetup || sfcResult.script + if (scriptContent) { + try { + scriptSchema = this.options.customParsers?.script + ? this.options.customParsers.script.parse(scriptContent) + : parseScript(scriptContent, { + isSetup: !!sfcResult.scriptSetup, + ...(this.options as any) + }) + + if (scriptSchema.imports) { + dependencies.push(...scriptSchema.imports.map((imp: any) => imp.source)) + } + + // Surface script parser soft errors returned by parseScript + if ((scriptSchema as any).error) { + const msg = (scriptSchema as any).error + errors.push(`Script parsing error: ${msg}`) + if (this.options.strictMode) throw new Error(msg) + } + } catch (error: any) { + errors.push(`Script parsing error: ${error.message}`) + if (this.options.strictMode) throw error + } + } + + if (sfcResult.template) { + try { + templateSchema = this.options.customParsers?.template + ? this.options.customParsers.template.parse(sfcResult.template) + : parseTemplate(sfcResult.template, { + ...this.options, + imports: scriptSchema.imports || [] + } as any) + } catch (error: any) { + errors.push(`Template parsing error: ${error.message}`) + if (this.options.strictMode) throw error + } + } + + if (sfcResult.style) { + try { + styleSchema = this.options.customParsers?.style + ? this.options.customParsers.style.parse(sfcResult.style) + : parseStyle(sfcResult.style, this.options as any) + } catch (error: any) { + errors.push(`Style parsing error: ${error.message}`) + if (this.options.strictMode) throw error + } + } + + // Set fileName in options for schema generation + if (fileName) { + this.options.fileName = fileName.replace(/\.vue$/i, '') + } + + const schema = await generateSchema(templateSchema, scriptSchema, styleSchema, this.options as any) + + return { + schema, + dependencies: [...new Set(dependencies)], + errors, + warnings + } + } catch (error: any) { + errors.push(`Conversion error: ${error.message}`) + return { schema: null, dependencies: [], errors, warnings } + } + } + + async convertFromFile(filePath: string): Promise { + try { + const vueCode = await fs.readFile(filePath, 'utf-8') + const fileName = path.basename(filePath, '.vue') + const result = await this.convertFromString(vueCode, fileName) + return result + } catch (error: any) { + return { schema: null, dependencies: [], errors: [`File reading error: ${error.message}`], warnings: [] } + } + } + + async convertMultipleFiles(filePaths: string[]): Promise { + const results: ConvertResult[] = [] + for (const filePath of filePaths) { + try { + const result = await this.convertFromFile(filePath) + results.push(result) + } catch (error: any) { + results.push({ + schema: null, + dependencies: [], + errors: [`Failed to convert ${filePath}: ${error.message}`], + warnings: [] + }) + } + } + return results + } + + // Recursively walk a directory and collect files that match a predicate + private async walk(dir: string, filter: (p: string, stat: any) => boolean, acc: string[] = []): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true } as any) + for (const entry of entries as any[]) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) { + await this.walk(p, filter, acc) + } else if (entry.isFile() && filter(p, entry)) { + acc.push(p) + } + } + } catch { + // ignore missing dirs + } + return acc + } + + // Convert a full app directory (e.g., test/full/input/appdemo01) into an aggregated schema.json + async convertAppDirectory(appDir: string): Promise { + const srcDir = path.join(appDir, 'src') + const viewsDir = path.join(srcDir, 'views') + + // 1) Collect page schemas from all .vue files under src/views/** + const vueFiles = await this.walk(viewsDir, (p) => p.endsWith('.vue')) + + // First pass: collect all files and detect naming conflicts + const fileMap = new Map() + for (const filePath of vueFiles) { + const relativePath = path.relative(viewsDir, filePath) + const baseName = path.basename(relativePath, '.vue') + if (!fileMap.has(baseName)) { + fileMap.set(baseName, []) + } + fileMap.get(baseName)!.push(relativePath) + } + + // Determine which files need special naming (camelCase with directory prefix) + const needsSpecialNaming = new Set() + for (const paths of fileMap.values()) { + if (paths.length > 1) { + // Multiple files with same basename, all need special naming + paths.forEach((p) => needsSpecialNaming.add(p)) + } + } + + // Helper function to convert path to camelCase + const pathToCamelCase = (relativePath: string, baseName: string): string => { + const parts = relativePath.replace(/\.vue$/i, '').split(/[\\/]/) + if (parts.length === 1) { + return baseName + } + // Join directory parts with the filename in camelCase + const dirParts = parts.slice(0, -1) + const camelCaseDir = dirParts + .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join('') + return camelCaseDir + baseName.charAt(0).toUpperCase() + baseName.slice(1) + } + + // Convert files with appropriate naming + const pageResults: ConvertResult[] = [] + for (const filePath of vueFiles) { + try { + const vueCode = await fs.readFile(filePath, 'utf-8') + const relativePath = path.relative(viewsDir, filePath) + const baseName = path.basename(relativePath, '.vue') + + // Use camelCase naming if there are conflicts, otherwise use basename + let fileName: string + if (needsSpecialNaming.has(relativePath)) { + fileName = pathToCamelCase(relativePath, baseName) + } else { + fileName = baseName + } + + const result = await this.convertFromString(vueCode, fileName) + pageResults.push(result) + } catch (error: any) { + pageResults.push({ + schema: null, + dependencies: [], + errors: [`Failed to convert ${filePath}: ${error.message}`], + warnings: [] + }) + } + } + + const pageSchemas = pageResults.map((r) => r.schema).filter(Boolean) + + // 2) Load i18n + let i18n: any = { en_US: {}, zh_CN: {} } + try { + const enPath = path.join(srcDir, 'i18n', 'en_US.json') + const zhPath = path.join(srcDir, 'i18n', 'zh_CN.json') + const [en, zh] = await Promise.all([ + fs.readFile(enPath, 'utf-8').catch(() => '{}'), + fs.readFile(zhPath, 'utf-8').catch(() => '{}') + ]) + i18n = { en_US: JSON.parse(en), zh_CN: JSON.parse(zh) } + } catch { + // keep defaults + } + + // 3) Load utils from src/utils.js (very lightweight parser) + const utils: any[] = [] + try { + const utilsPath = path.join(srcDir, 'utils.js') + const code = await fs.readFile(utilsPath, 'utf-8') + const importRegex = /import\s+(?:{\s*([\w,\s]+)\s*}|([\w$]+))\s+from\s+['"]([^'"]+)['"]/g + const imports: Array<{ local: string; source: string; destructuring: boolean }> = [] + let m: RegExpExecArray | null + while ((m = importRegex.exec(code))) { + const named = m[1] + const def = m[2] + const source = m[3] + if (named) { + named + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .forEach((n) => imports.push({ local: n, source, destructuring: true })) + } else if (def) { + imports.push({ local: def, source, destructuring: false }) + } + } + // exported names + const exportRegex = /export\s*{([^}]+)}/ + const expMatch = code.match(exportRegex) + const exported = expMatch + ? expMatch[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + // Build utils array for exported imports; fallback for exported local functions/vars + for (const name of exported) { + const found = imports.find((imp) => imp.local === name) + if (found) { + utils.push({ + name, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: found.source, + destructuring: found.destructuring, + exportName: name + } + }) + } else { + // treat as function utility placeholder + utils.push({ name, type: 'function', content: { type: 'JSFunction', value: '' } }) + } + } + } catch { + // ignore + } + + // 4) Load dataSource from lowcodeConfig/dataSource.json + const dataSource: any = { list: [] } + try { + const dsPath = path.join(srcDir, 'lowcodeConfig', 'dataSource.json') + const dsRaw = await fs.readFile(dsPath, 'utf-8') + const dsJson = JSON.parse(dsRaw) + // pass through; keep shape as-is + if (Array.isArray(dsJson.list)) dataSource.list = dsJson.list + } catch { + // ignore + } + + // 5) Load globalState from src/stores/*.js (very light support for pinia defineStore) + const globalState: any[] = [] + try { + const storesDir = path.join(srcDir, 'stores') + const storeFiles = await this.walk(storesDir, (p) => p.endsWith('.js')) + for (const sf of storeFiles) { + const code = await fs.readFile(sf, 'utf-8') + // Skip files that don't define a Pinia store (e.g., re-export index.js) + if (!/defineStore\s*\(/.test(code)) continue + // naive extraction: id: 'xxx' + const idMatch = code.match(/id:\s*['"]([^'"]+)['"]/) + const stateMatch = code.match(/state:\s*\(\)\s*=>\s*\((\{[\s\S]*?\})\)/) + const entry: any = { id: idMatch ? idMatch[1] : path.basename(sf, path.extname(sf)) } + if (stateMatch) { + try { + // very naive: turn JS object to JSON by removing trailing commas and function values + const objText = stateMatch[1] + const stateObj = Function(`return (${objText})`)() + entry.state = stateObj + } catch { + entry.state = {} + } + } else { + // No state found, skip this file to avoid empty entries + continue + } + // Only push when we have some keys in state (avoid empty {}) + if (entry.state && typeof entry.state === 'object' && Object.keys(entry.state).length > 0) { + globalState.push(entry) + } + } + } catch { + // ignore + } + + // 6) Read router info to enrich page meta (router path, isPage, isHome) + try { + const routerPath = path.join(srcDir, 'router', 'index.js') + const rcode = await fs.readFile(routerPath, 'utf-8') + // find root redirect name (home) + // Simply capture the first redirect name (root level in this project) + const homeMatch = rcode.match(/redirect:\s*\{\s*name:\s*['"]([^'"]+)['"]/) + const homeName = homeMatch ? homeMatch[1] : '' + + // To avoid incorrectly pairing the redirect name with the first route's path/component, + // remove the redirect object before extracting route entries. + const rclean = rcode.replace(/redirect\s*:\s*\{[\s\S]*?\}/, '') + + const routeEntries: Array<{ routeName: string; routePath: string; importPath: string }> = [] + const routeRegex = + /name:\s*['"]([^'"]+)['"][\s\S]*?path:\s*['"]([^'"]+)['"][\s\S]*?component:\s*\(\)\s*=>\s*import\(\s*['"]([^'"]+)['"]\s*\)/g + let m: RegExpExecArray | null + while ((m = routeRegex.exec(rclean))) { + routeEntries.push({ routeName: m[1], routePath: m[2], importPath: m[3] }) + } + // Build map by fileName (basename of the import .vue) + const byFile: Record = {} + for (const e of routeEntries) { + const base = path.basename(e.importPath).replace(/\.vue$/i, '') + byFile[base] = { routeName: e.routeName, routePath: e.routePath, isHome: e.routeName === homeName } + } + // Enrich page schemas + for (const ps of pageSchemas) { + const fileName = ps?.fileName + if (!fileName) continue + let info = byFile[fileName] + // If not found, try to match by checking if fileName ends with the base name (for camelCase names) + if (!info) { + for (const [base, routeInfo] of Object.entries(byFile)) { + if (fileName.endsWith(base.charAt(0).toUpperCase() + base.slice(1))) { + info = routeInfo + break + } + } + } + ps.meta = ps.meta || {} + if (info) { + // Remove leading slash from router path + const routerPath = info.routePath.startsWith('/') ? info.routePath.slice(1) : info.routePath + ps.meta.router = routerPath + ps.meta.isPage = true + ps.meta.isHome = !!info.isHome + } else { + // Generate default router path from fileName if no match found + ps.meta.router = fileName.toLowerCase() + ps.meta.isPage = true + } + } + } catch (error) { + // If router enrichment fails, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + + // 7) Collect sub-components from src/components/**/*.vue and convert to block schemas + const blockSchemas: any[] = [] + try { + const componentsDir = path.join(srcDir, 'components') + const componentVueFiles = await this.walk(componentsDir, (p) => p.endsWith('.vue')) + for (const filePath of componentVueFiles) { + try { + const vueCode = await fs.readFile(filePath, 'utf-8') + const baseName = path.basename(filePath, '.vue') + const savedOptions = { ...this.options } + this.options = { ...this.options, isBlock: true } as any + const result = await this.convertFromString(vueCode, baseName) + this.options = savedOptions + if (result.schema) { + result.schema.componentName = 'Block' + blockSchemas.push(result.schema) + } + } catch { + // skip individual component conversion errors + } + } + } catch { + // ignore if src/components doesn't exist + } + + // Also scan page schemas for componentType=Block nodes and ensure they have block schemas + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + + // 8) Assemble app schema + const appSchema = generateAppSchema(pageSchemas, { + i18n, + utils, + dataSource, + globalState, + blockSchemas + }) + + return appSchema + } + + // Recursively collect componentType=Block references from page schemas + // to ensure all referenced blocks have corresponding block schemas + private collectBlockRefsFromSchemas(pageSchemas: any[], blockSchemas: any[]): void { + const existingBlockNames = new Set(blockSchemas.map((b) => b.fileName)) + + const collectBlockNames = (node: any): void => { + if (!node || typeof node !== 'object') return + if (node.componentType === 'Block' && node.componentName && !existingBlockNames.has(node.componentName)) { + // Create a placeholder block schema for referenced but not found components + existingBlockNames.add(node.componentName) + blockSchemas.push({ + componentName: 'Block', + fileName: node.componentName, + meta: { name: node.componentName }, + children: node.children || [], + props: node.props || {}, + state: {}, + methods: {} + }) + } + if (Array.isArray(node.children)) { + node.children.forEach(collectBlockNames) + } + } + + for (const ps of pageSchemas) { + if (ps?.children) { + ps.children.forEach(collectBlockNames) + } + } + } + + setOptions(options: VueToSchemaOptions) { + this.options = { ...this.options, ...options } + } + + getOptions(): VueToSchemaOptions { + return { ...this.options } + } + + // Convert an app from a zip buffer (in-memory). The buffer should be the content of the zip file (not a path). + async convertAppFromZip(zipBuffer: ArrayBuffer | Uint8Array | Buffer): Promise { + // Browser-safe path: avoid fs/path/os, work fully in-memory + if (typeof window !== 'undefined' && typeof (window as any).document !== 'undefined') { + const zip = await JSZip.loadAsync(zipBuffer as any) + + // Collect file entries (posix paths in zip) + const allFiles = Object.keys((zip as any).files || {}) + .filter((p) => !(zip as any).files[p].dir) + .filter((p) => !p.startsWith('__MACOSX/')) + + // Determine root prefix (top-level folder) + const topLevels = new Set( + allFiles.map((p) => p.split('/')[0]).filter((seg) => !!seg && seg !== '.' && seg !== '..') + ) + let rootPrefix = '' + if (topLevels.size === 1) rootPrefix = [...topLevels][0] + '/' + + const joinRoot = (sub: string) => (rootPrefix ? rootPrefix + sub.replace(/^\/+/, '') : sub.replace(/^\/+/, '')) + const readText = async (rel: string) => { + const file = zip.file(rel) + return file ? await file.async('string') : null + } + + // 1) Pages: src/views/**/*.vue + const viewPrefix = joinRoot('src/views/') + const vueFiles = allFiles.filter((p) => p.startsWith(viewPrefix) && p.endsWith('.vue')) + + // First pass: collect all files and detect naming conflicts + const fileMap = new Map() + for (const vf of vueFiles) { + const relativePath = vf.substring(viewPrefix.length) + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || '' + if (!fileMap.has(baseName)) { + fileMap.set(baseName, []) + } + fileMap.get(baseName)!.push(relativePath) + } + + // Determine which files need special naming (camelCase with directory prefix) + const needsSpecialNaming = new Set() + for (const paths of fileMap.values()) { + if (paths.length > 1) { + // Multiple files with same basename, all need special naming + paths.forEach((p) => needsSpecialNaming.add(p)) + } + } + + // Helper function to convert path to camelCase + const pathToCamelCase = (relativePath: string, baseName: string): string => { + const parts = relativePath.replace(/\.vue$/i, '').split('/') + if (parts.length === 1) { + return baseName + } + // Join directory parts with the filename in camelCase + const dirParts = parts.slice(0, -1) + const camelCaseDir = dirParts + .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join('') + return camelCaseDir + baseName.charAt(0).toUpperCase() + baseName.slice(1) + } + + // Convert files with appropriate naming + const pageSchemas: any[] = [] + for (const vf of vueFiles) { + const code = await readText(vf) + if (!code) continue + + const relativePath = vf.substring(viewPrefix.length) + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || 'Page' + + // Use camelCase naming if there are conflicts, otherwise use basename + let fileName: string + if (needsSpecialNaming.has(relativePath)) { + fileName = pathToCamelCase(relativePath, baseName) + } else { + fileName = baseName + } + + const res = await this.convertFromString(code, fileName) + if (res.schema) pageSchemas.push(res.schema) + } + + // 2) i18n + let i18n: any = { en_US: {}, zh_CN: {} } + try { + const en = (await readText(joinRoot('src/i18n/en_US.json'))) || '{}' + const zh = (await readText(joinRoot('src/i18n/zh_CN.json'))) || '{}' + i18n = { en_US: JSON.parse(en), zh_CN: JSON.parse(zh) } + } catch { + // keep defaults + } + + // 3) utils from src/utils.js + const utils: any[] = [] + try { + const code = await readText(joinRoot('src/utils.js')) + if (code) { + const importRegex = /import\s+(?:{\s*([\w,\s]+)\s*}|([\w$]+))\s+from\s+['"]([^'"]+)['"]/g + const imports: Array<{ local: string; source: string; destructuring: boolean }> = [] + let m: RegExpExecArray | null + while ((m = importRegex.exec(code))) { + const named = m[1] + const def = m[2] + const source = m[3] + if (named) { + named + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .forEach((n) => imports.push({ local: n, source, destructuring: true })) + } else if (def) { + imports.push({ local: def, source, destructuring: false }) + } + } + const exportRegex = /export\s*{([^}]+)}/ + const expMatch = code.match(exportRegex) + const exported = expMatch + ? expMatch[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + for (const name of exported) { + const found = imports.find((imp) => imp.local === name) + if (found) { + utils.push({ + name, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: found.source, + destructuring: found.destructuring, + exportName: name + } + }) + } else { + utils.push({ name, type: 'function', content: { type: 'JSFunction', value: '' } }) + } + } + } + } catch { + // ignore + } + + // 4) dataSource + const dataSource: any = { list: [] } + try { + const dsRaw = await readText(joinRoot('src/lowcodeConfig/dataSource.json')) + if (dsRaw) { + const dsJson = JSON.parse(dsRaw) + if (Array.isArray(dsJson.list)) dataSource.list = dsJson.list + } + } catch { + // ignore + } + + // 5) globalState from src/stores/*.js + const storesPrefix = joinRoot('src/stores/') + const storeFiles = allFiles.filter((p) => p.startsWith(storesPrefix) && p.endsWith('.js')) + const globalState: any[] = [] + for (const sf of storeFiles) { + try { + const code = await readText(sf) + if (!code || !/defineStore\s*\(/.test(code)) continue + const idMatch = code.match(/id:\s*['"]([^'"]+)['"]/) + const stateMatch = code.match(/state:\s*\(\)\s*=>\s*\((\{[\s\S]*?\})\)/) + const entry: any = { id: idMatch ? idMatch[1] : (sf.split('/').pop() || 'store').replace(/\.[^.]+$/, '') } + if (stateMatch) { + try { + const objText = stateMatch[1] + const stateObj = Function(`return (${objText})`)() + entry.state = stateObj + } catch { + entry.state = {} + } + } else { + continue + } + if (entry.state && typeof entry.state === 'object' && Object.keys(entry.state).length > 0) { + globalState.push(entry) + } + } catch { + // ignore + } + } + + // 6) router enrichment + try { + const rcode = await readText(joinRoot('src/router/index.js')) + if (rcode) { + const homeMatch = rcode.match(/redirect:\s*\{\s*name:\s*['"]([^'"]+)['"]/) + const homeName = homeMatch ? homeMatch[1] : '' + const rclean = rcode.replace(/redirect\s*:\s*\{[\s\S]*?\}/, '') + const routeEntries: Array<{ routeName: string; routePath: string; importPath: string }> = [] + const routeRegex = + /name:\s*['"]([^'"]+)['"][\s\S]*?path:\s*['"]([^'"]+)['"][\s\S]*?component:\s*\(\)\s*=>\s*import\(\s*['"]([^'"]+)['"]\s*\)/g + let m: RegExpExecArray | null + while ((m = routeRegex.exec(rclean))) + routeEntries.push({ routeName: m[1], routePath: m[2], importPath: m[3] }) + const byFile: Record = {} + for (const e of routeEntries) { + const base = (e.importPath.split('/').pop() || '').replace(/\.vue$/i, '') + byFile[base] = { routeName: e.routeName, routePath: e.routePath, isHome: e.routeName === homeName } + } + for (const ps of pageSchemas) { + const fileName = ps?.fileName + if (!fileName) continue + let info = byFile[fileName] + // If not found, try to match by checking if fileName ends with the base name (for camelCase names) + if (!info) { + for (const [base, routeInfo] of Object.entries(byFile)) { + // Try exact match (case-insensitive) + if (fileName.toLowerCase() === base.toLowerCase()) { + info = routeInfo + break + } + // Try matching if fileName ends with base name (for camelCase names) + if (fileName.endsWith(base.charAt(0).toUpperCase() + base.slice(1))) { + info = routeInfo + break + } + } + } + ps.meta = ps.meta || {} + if (info) { + // Remove leading slash from router path + const routerPath = info.routePath.startsWith('/') ? info.routePath.slice(1) : info.routePath + ps.meta.router = routerPath + ps.meta.isPage = true + ps.meta.isHome = !!info.isHome + } else { + // Generate default router path from fileName if no match found + ps.meta.router = fileName.toLowerCase() + ps.meta.isPage = true + } + } + } else { + // If router file not found, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + } catch (error) { + // If router enrichment fails, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + + // 7) Collect sub-components from src/components/**/*.vue and convert to block schemas + const blockSchemas: any[] = [] + const componentsPrefix = joinRoot('src/components/') + const componentVueFiles = allFiles.filter((p) => p.startsWith(componentsPrefix) && p.endsWith('.vue')) + for (const cf of componentVueFiles) { + try { + const code = await readText(cf) + if (!code) continue + const baseName = (cf.split('/').pop() || '').replace(/\.vue$/i, '') || 'Block' + const savedOptions = { ...this.options } + this.options = { ...this.options, isBlock: true } as any + const res = await this.convertFromString(code, baseName) + this.options = savedOptions + if (res.schema) { + res.schema.componentName = 'Block' + blockSchemas.push(res.schema) + } + } catch { + // skip individual component conversion errors + } + } + + // Also scan page schemas for componentType=Block nodes + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + + // 8) Assemble app schema + const appSchema = generateAppSchema(pageSchemas, { + i18n, + utils, + dataSource, + globalState, + blockSchemas + }) + + return appSchema + } + + // Node.js path: unzip to temp and reuse directory-based converter + // 1) Unzip into a temp directory + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'vue-to-dsl-')) + const zip = await JSZip.loadAsync(zipBuffer as any) + + const fileEntries: string[] = [] + const writeTasks: Promise[] = [] + zip.forEach((relPath, file) => { + // Skip macOS metadata + if (relPath.startsWith('__MACOSX/')) return + const outPath = path.join(tmpBase, relPath) + if (file.dir) { + writeTasks.push(fs.mkdir(outPath, { recursive: true })) + } else { + fileEntries.push(relPath) + writeTasks.push( + (async () => { + await fs.mkdir(path.dirname(outPath), { recursive: true }) + const content = await file.async('nodebuffer') + await fs.writeFile(outPath, content) + })() + ) + } + }) + await Promise.all(writeTasks) + + // 2) Determine the root app directory inside the zip + const topLevels = new Set( + fileEntries.map((p) => p.split('/')[0]).filter((seg) => !!seg && seg !== '.' && seg !== '..') + ) + + let appRoot = tmpBase + if (topLevels.size === 1) { + const only = [...topLevels][0] + appRoot = path.join(tmpBase, only) + } + + // 3) Delegate to convertAppDirectory + const schema = await this.convertAppDirectory(appRoot) + return schema + } + + async convertAppFromDirectory(files: FileList): Promise { + const fileArray = Array.from(files) + let relevantFiles = [] + + const readText = async (file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsText(file) + }) + } + + const createGitignoreFilter = (gitignoreContent: string) => { + const lines = gitignoreContent + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + const patterns = lines.map((line) => { + const isNegative = line.startsWith('!') + const pattern = isNegative ? line.slice(1) : line + + // Convert gitignore pattern to regex + const regexString = pattern + .replace(/([.+?^${}()|[\]\\])/g, '\\$1') // Escape special chars + .replace(/\/\*\*$/, '/.*') // '/**' at the end + .replace(/\*\*/g, '.*') // '**' + .replace(/\*/g, '[^/]*') // '*' + .replace(/\?/g, '[^/]') // '?' + + // Handle directory matching + if (regexString.endsWith('/')) { + return { regex: new RegExp(`^${regexString}`), isNegative } + } + + return { regex: new RegExp(`^${regexString}(/.*)?$`), isNegative } + }) + + return (path: string) => { + let isIgnored = false + for (const { regex, isNegative } of patterns) { + if (regex.test(path)) { + isIgnored = !isNegative + } + } + return !isIgnored + } + } + + const gitignoreFile = fileArray.find((file) => file.webkitRelativePath.endsWith('/.gitignore')) + + if (gitignoreFile) { + const gitignoreContent = await readText(gitignoreFile) + const rootDir = gitignoreFile.webkitRelativePath.split('/')[0] + const filter = createGitignoreFilter(gitignoreContent) + + relevantFiles = fileArray.filter((file) => { + const relativePath = file.webkitRelativePath.slice(rootDir.length + 1) + return relativePath && filter(relativePath) + }) + } else { + // Filter out node_modules + relevantFiles = fileArray.filter((file) => !file.webkitRelativePath.includes('node_modules')) + } + + // 1) Pages: src/views/**/*.vue + const vueFiles = relevantFiles.filter( + (file) => file.webkitRelativePath.includes('src/views/') && file.name.endsWith('.vue') + ) + + // First pass: collect all files and detect naming conflicts + const fileMap = new Map() + for (const vf of vueFiles) { + const webkitPath = vf.webkitRelativePath + const viewsIndex = webkitPath.indexOf('src/views/') + const relativePath = viewsIndex >= 0 ? webkitPath.substring(viewsIndex + 'src/views/'.length) : vf.name + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || '' + if (!fileMap.has(baseName)) { + fileMap.set(baseName, []) + } + fileMap.get(baseName)!.push(relativePath) + } + + // Determine which files need special naming (camelCase with directory prefix) + const needsSpecialNaming = new Set() + for (const paths of fileMap.values()) { + if (paths.length > 1) { + // Multiple files with same basename, all need special naming + paths.forEach((p) => needsSpecialNaming.add(p)) + } + } + + // Helper function to convert path to camelCase + const pathToCamelCase = (relativePath: string, baseName: string): string => { + const parts = relativePath.replace(/\.vue$/i, '').split('/') + if (parts.length === 1) { + return baseName + } + // Join directory parts with the filename in camelCase + const dirParts = parts.slice(0, -1) + const camelCaseDir = dirParts + .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join('') + return camelCaseDir + baseName.charAt(0).toUpperCase() + baseName.slice(1) + } + + const pageSchemas: any[] = [] + for (const vf of vueFiles) { + const code = await readText(vf) + if (!code) continue + + const webkitPath = vf.webkitRelativePath + const viewsIndex = webkitPath.indexOf('src/views/') + const relativePath = viewsIndex >= 0 ? webkitPath.substring(viewsIndex + 'src/views/'.length) : vf.name + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || 'Page' + + // Use camelCase naming if there are conflicts, otherwise use basename + let fileName: string + if (needsSpecialNaming.has(relativePath)) { + fileName = pathToCamelCase(relativePath, baseName) + } else { + fileName = baseName + } + + const res = await this.convertFromString(code, fileName) + if (res.schema) pageSchemas.push(res.schema) + } + + // 2) i18n + let i18n: any = { en_US: {}, zh_CN: {} } + try { + const enFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/i18n/en_US.json')) + const zhFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/i18n/zh_CN.json')) + const en = enFile ? await readText(enFile) : '{}' + const zh = zhFile ? await readText(zhFile) : '{}' + i18n = { en_US: JSON.parse(en), zh_CN: JSON.parse(zh) } + } catch { + // keep defaults + } + + // 3) utils from src/utils.js + const utils: any[] = [] + try { + const utilsFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/utils.js')) + if (utilsFile) { + const code = await readText(utilsFile) + const importRegex = /import\s+(?:{\s*([\w,\s]+)\s*}|([\w$]+))\s+from\s+['"]([^'"]+)['"]/g + const imports: Array<{ local: string; source: string; destructuring: boolean }> = [] + let m: RegExpExecArray | null + while ((m = importRegex.exec(code))) { + const named = m[1] + const def = m[2] + const source = m[3] + if (named) { + named + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .forEach((n) => imports.push({ local: n, source, destructuring: true })) + } else if (def) { + imports.push({ local: def, source, destructuring: false }) + } + } + const exportRegex = /export\s*{([^}]+)}/ + const expMatch = code.match(exportRegex) + const exported = expMatch + ? expMatch[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + for (const name of exported) { + const found = imports.find((imp) => imp.local === name) + if (found) { + utils.push({ + name, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: found.source, + destructuring: found.destructuring, + exportName: name + } + }) + } else { + utils.push({ name, type: 'function', content: { type: 'JSFunction', value: '' } }) + } + } + } + } catch { + // ignore + } + + // 4) dataSource + const dataSource: any = { list: [] } + try { + const dsFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/lowcodeConfig/dataSource.json')) + if (dsFile) { + const dsRaw = await readText(dsFile) + const dsJson = JSON.parse(dsRaw) + if (Array.isArray(dsJson.list)) dataSource.list = dsJson.list + } + } catch { + // ignore + } + + // 5) globalState from src/stores/*.js + const storeFiles = relevantFiles.filter( + (f) => f.webkitRelativePath.includes('src/stores/') && f.name.endsWith('.js') + ) + const globalState: any[] = [] + for (const sf of storeFiles) { + try { + const code = await readText(sf) + if (!code || !/defineStore\s*\(/.test(code)) continue + const idMatch = code.match(/id:\s*['"]([^'"]+)['"]/) + const stateMatch = code.match(/state:\s*\(\)\s*=>\s*\((\{[\s\S]*?\})\)/) + const entry: any = { id: idMatch ? idMatch[1] : sf.name.replace(/\.[^.]+$/, '') } + if (stateMatch) { + try { + const objText = stateMatch[1] + const stateObj = Function(`return (${objText})`)() + entry.state = stateObj + } catch { + entry.state = {} + } + } else { + continue + } + if (entry.state && typeof entry.state === 'object' && Object.keys(entry.state).length > 0) { + globalState.push(entry) + } + } catch { + // ignore + } + } + + // 6) router enrichment + try { + const routerFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/router/index.js')) + if (routerFile) { + const rcode = await readText(routerFile) + const homeMatch = rcode.match(/redirect:\s*\{\s*name:\s*['"]([^'"]+)['"]/) + const homeName = homeMatch ? homeMatch[1] : '' + const rclean = rcode.replace(/redirect\s*:\s*\{[\s\S]*?\}/, '') + const routeEntries: Array<{ routeName: string; routePath: string; importPath: string }> = [] + const routeRegex = + /name:\s*['"]([^'"]+)['"][\s\S]*?path:\s*['"]([^'"]+)['"][\s\S]*?component:\s*\(\)\s*=>\s*import\(\s*['"]([^'"]+)['"]\s*\)/g + let m: RegExpExecArray | null + while ((m = routeRegex.exec(rclean))) routeEntries.push({ routeName: m[1], routePath: m[2], importPath: m[3] }) + const byFile: Record = {} + for (const e of routeEntries) { + const base = (e.importPath.split('/').pop() || '').replace(/\.vue$/i, '') + byFile[base] = { routeName: e.routeName, routePath: e.routePath, isHome: e.routeName === homeName } + } + for (const ps of pageSchemas) { + const fileName = ps?.fileName + if (!fileName) continue + let info = byFile[fileName] + // If not found, try to match by checking if fileName ends with the base name (for camelCase names) + if (!info) { + for (const [base, routeInfo] of Object.entries(byFile)) { + // Try exact match (case-insensitive) + if (fileName.toLowerCase() === base.toLowerCase()) { + info = routeInfo + break + } + // Try matching if fileName ends with base name (for camelCase names) + if (fileName.endsWith(base.charAt(0).toUpperCase() + base.slice(1))) { + info = routeInfo + break + } + } + } + ps.meta = ps.meta || {} + if (info) { + // Remove leading slash from router path + const routerPath = info.routePath.startsWith('/') ? info.routePath.slice(1) : info.routePath + ps.meta.router = routerPath + ps.meta.isPage = true + ps.meta.isHome = !!info.isHome + } else { + // Generate default router path from fileName if no match found + ps.meta.router = fileName.toLowerCase() + ps.meta.isPage = true + } + } + } else { + // If router file not found, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + } catch (error) { + // If router enrichment fails, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + + // 7) Collect sub-components from src/components/**/*.vue and convert to block schemas + const blockSchemas: any[] = [] + const componentVueFiles = relevantFiles.filter( + (file) => file.webkitRelativePath.includes('src/components/') && file.name.endsWith('.vue') + ) + for (const cf of componentVueFiles) { + try { + const code = await readText(cf) + if (!code) continue + const baseName = cf.name.replace(/\.vue$/i, '') || 'Block' + const savedOptions = { ...this.options } + this.options = { ...this.options, isBlock: true } as any + const res = await this.convertFromString(code, baseName) + this.options = savedOptions + if (res.schema) { + res.schema.componentName = 'Block' + blockSchemas.push(res.schema) + } + } catch { + // skip individual component conversion errors + } + } + + // Also scan page schemas for componentType=Block nodes + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + + // 8) Assemble app schema + const appSchema = generateAppSchema(pageSchemas, { + i18n, + utils, + dataSource, + globalState, + blockSchemas + }) + + return appSchema + } +} diff --git a/packages/vue-to-dsl/src/generator/index.ts b/packages/vue-to-dsl/src/generator/index.ts new file mode 100644 index 0000000000..fd5f2e2416 --- /dev/null +++ b/packages/vue-to-dsl/src/generator/index.ts @@ -0,0 +1,194 @@ +import { defaultComponentsMap } from '../constants' +function convertToPlainValue(expr: any) { + // If it's already an object or array, return as-is (for nested reactive objects) + if (typeof expr === 'object' && expr !== null) return expr + if (typeof expr !== 'string') return expr + const trimmed = expr.trim() + if (/^['"].*['"]$/.test(trimmed)) return trimmed.slice(1, -1) + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed) + if (trimmed === 'true') return true + if (trimmed === 'false') return false + if (trimmed === 'null') return null + return trimmed +} + +function extractRefPrimitive(expr: any) { + // If it's already an object or array, return as-is + if (typeof expr === 'object' && expr !== null) return expr + if (typeof expr !== 'string') return expr + const m = expr.match(/^ref\((.*)\)$/) + if (!m) return expr + const inner = m[1].trim() + return convertToPlainValue(inner) +} + +function transformState(state: Record) { + const result: Record = {} + Object.keys(state).forEach((key) => { + const stateItem = state[key] + if (typeof stateItem === 'object' && stateItem.type) { + switch (stateItem.type) { + case 'reactive': + result[key] = convertToPlainValue(stateItem.value) + break + case 'ref': + result[key] = extractRefPrimitive(stateItem.value) + break + default: + result[key] = stateItem.value || stateItem + } + } else { + result[key] = stateItem + } + }) + return result +} + +function transformMethods(methods: Record) { + const result: Record = {} + Object.keys(methods).forEach((key) => { + const method = methods[key] + if (typeof method === 'object' && method.value) { + result[key] = { type: 'JSFunction', value: method.value } + } else if (typeof method === 'string') { + result[key] = { type: 'JSFunction', value: method } + } else { + result[key] = { type: 'JSFunction', value: 'function() { /* method implementation */ }' } + } + }) + return result +} + +function transformComputed(computed: Record) { + const result: Record = {} + Object.keys(computed).forEach((key) => { + const computedItem = computed[key] + if (typeof computedItem === 'object' && computedItem.value) { + result[key] = { type: 'JSFunction', value: computedItem.value } + } else if (typeof computedItem === 'string') { + result[key] = { type: 'JSFunction', value: computedItem } + } else { + result[key] = { type: 'JSFunction', value: 'function() { /* computed getter */ }' } + } + }) + return result +} + +function transformLifeCycles(lifecycle: Record) { + const result: Record = {} + Object.keys(lifecycle).forEach((key) => { + const lifecycleItem = lifecycle[key] + if (typeof lifecycleItem === 'object' && lifecycleItem.value) { + result[key] = { type: 'JSFunction', value: lifecycleItem.value } + } else if (typeof lifecycleItem === 'string') { + result[key] = { type: 'JSFunction', value: lifecycleItem } + } else { + result[key] = { type: 'JSFunction', value: 'function() { /* lifecycle hook */ }' } + } + }) + return result +} + +function transformProps(props: any[]) { + return props.map((prop) => { + if (typeof prop === 'string') return { name: prop, type: 'any', default: undefined } + if (typeof prop === 'object') + return { + name: prop.name || 'unknownProp', + type: prop.type || 'any', + default: prop.default, + required: prop.required || false + } + return prop + }) +} + +// Generate an 8-char id with lowercase letters and digits +function generateId(): string { + let s = '' + while (s.length < 8) s += Math.random().toString(36).slice(2) + return s.slice(0, 8) +} + +// Recursively assign id to nodes with componentName +function assignComponentIds(node: any): void { + if (!node || typeof node !== 'object') return + if (typeof node.componentName === 'string') { + if (!node.id) node.id = generateId() + } + if (Array.isArray(node.children)) node.children.forEach(assignComponentIds) +} + +// Deeply sanitize all string values in the schema +function sanitizeSchemaStrings(obj: any): any { + if (obj === null || obj === undefined) return obj + if (typeof obj === 'string') return obj + if (Array.isArray(obj)) return obj.map((v) => sanitizeSchemaStrings(v)) + if (typeof obj === 'object') { + const out: any = Array.isArray(obj) ? [] : {} + Object.keys(obj).forEach((k) => { + out[k] = sanitizeSchemaStrings(obj[k]) + }) + return out + } + return obj +} + +export async function generateSchema(templateSchema: any[], scriptSchema: any, styleSchema: any, options: any = {}) { + const fileName = options.fileName || 'UnnamedPage' + // Capitalize first letter for display name + const displayName = fileName.charAt(0).toUpperCase() + fileName.slice(1) + + const schema: any = { + componentName: options.isBlock ? 'Block' : 'Page', + fileName: fileName, + meta: { + name: displayName + } + } + if (scriptSchema) { + if (scriptSchema.state) schema.state = transformState(scriptSchema.state) + if (scriptSchema.methods) schema.methods = transformMethods(scriptSchema.methods) + // only output computed when computed_flag is explicitly enabled + if (options.computed_flag === true && scriptSchema.computed) { + schema.computed = transformComputed(scriptSchema.computed) + } + if (scriptSchema.lifeCycles) schema.lifeCycles = transformLifeCycles(scriptSchema.lifeCycles) + if (scriptSchema.props && scriptSchema.props.length > 0) schema.props = transformProps(scriptSchema.props) + } + if (styleSchema && styleSchema.css) schema.css = styleSchema.css + if (templateSchema && templateSchema.length > 0) schema.children = templateSchema + // sanitize all strings to remove newlines in the final output + const sanitized = sanitizeSchemaStrings(schema) + // assign 8-char ids to all component nodes (including Page root) + assignComponentIds(sanitized) + return sanitized +} + +export function generateAppSchema(pageSchemas: any[], options: any = {}) { + // Ensure all pages have a router path without leading slash + if (pageSchemas && Array.isArray(pageSchemas)) { + for (const ps of pageSchemas) { + if (ps && ps.meta && ps.meta.router && typeof ps.meta.router === 'string') { + // Remove leading slash from router path + if (ps.meta.router.startsWith('/')) { + ps.meta.router = ps.meta.router.slice(1) + } + } + } + } + + return { + meta: { + name: options.name || 'Generated App', + description: options.description || 'App generated from Vue SFC files' + }, + i18n: options.i18n || { en_US: {}, zh_CN: {} }, + utils: options.utils || [], + dataSource: options.dataSource || { list: [] }, + globalState: options.globalState || [], + pageSchema: pageSchemas || [], + blockSchemas: options.blockSchemas || [], + componentsMap: options.componentsMap || defaultComponentsMap + } +} diff --git a/packages/vue-to-dsl/src/index.d.ts b/packages/vue-to-dsl/src/index.d.ts new file mode 100644 index 0000000000..d1241e660c --- /dev/null +++ b/packages/vue-to-dsl/src/index.d.ts @@ -0,0 +1,155 @@ +declare module '@opentiny/tiny-engine-vue-to-dsl' { + export interface VueToSchemaOptions { + // 组件映射配置 + componentMap?: Record + // 是否保留注释 + preserveComments?: boolean + // 是否严格模式 + strictMode?: boolean + // 控制是否输出 computed 字段(默认 false) + computed_flag?: boolean + // 自定义解析器 + customParsers?: { + template?: TemplateParser + script?: ScriptParser + style?: StyleParser + } + } + + export interface TemplateParser { + parse(template: string, options?: any): TemplateSchema + } + + export interface ScriptParser { + parse(script: string, options?: any): ScriptSchema + } + + export interface StyleParser { + parse(style: string, options?: any): StyleSchema + } + + export interface TemplateSchema { + componentName: string + props?: Record + children?: TemplateSchema[] + condition?: string + loop?: string + key?: string + ref?: string + [key: string]: any + } + + export interface ScriptSchema { + state?: Record + methods?: Record + computed?: Record + lifeCycles?: Record + imports?: ImportInfo[] + props?: PropInfo[] + emits?: string[] + } + + export interface StyleSchema { + css: string + scoped?: boolean + lang?: string + } + + export interface ImportInfo { + source: string + specifiers: string[] + default?: string + } + + export interface PropInfo { + name: string + type?: string + default?: any + required?: boolean + } + + export interface PageSchema { + componentName: 'Page' + fileName: string + path: string + meta?: Record + state?: Record + methods?: Record + computed?: Record + lifeCycles?: Record + props?: PropInfo[] + css?: string + children?: TemplateSchema[] + } + + export interface ConvertResult { + schema: PageSchema + dependencies: string[] + errors: string[] + warnings: string[] + } + + export class VueToDslConverter { + constructor(options?: VueToSchemaOptions) + + /** + * 将Vue SFC文件内容转换为DSL Schema + */ + convertFromString(vueCode: string): Promise + + /** + * 将Vue SFC文件转换为DSL Schema + */ + convertFromFile(filePath: string): Promise + + /** + * 批量转换多个Vue文件 + */ + convertMultipleFiles(filePaths: string[]): Promise + } + + /** + * 解析Vue SFC文件 + */ + export function parseVueFile(filePath: string): Promise<{ + template?: string + script?: string + style?: string + scriptSetup?: string + }> + + /** + * 解析Vue SFC代码字符串 + */ + export function parseSFC(vueCode: string): { + template?: string + script?: string + style?: string + scriptSetup?: string + } + + /** + * 生成DSL Schema + */ + export function generateSchema( + template: string, + script: string, + style?: string, + options?: VueToSchemaOptions + ): Promise + + /** + * 解析模板 + */ + export function parseTemplate(template: string): TemplateSchema[] + + /** + * 解析脚本 + */ + export function parseScript(script: string): ScriptSchema + + /** + * 解析样式 + */ + export function parseStyle(style: string): StyleSchema +} diff --git a/packages/vue-to-dsl/src/index.ts b/packages/vue-to-dsl/src/index.ts new file mode 100644 index 0000000000..6075591824 --- /dev/null +++ b/packages/vue-to-dsl/src/index.ts @@ -0,0 +1,7 @@ +import './index.d.ts' + +export { VueToDslConverter } from './converter' +export { parseVueFile, parseSFC } from './parser' +export { generateSchema, generateAppSchema } from './generator' +export { parseTemplate, parseScript, parseStyle } from './parsers' +export * from './types/index' diff --git a/packages/vue-to-dsl/src/parser/index.ts b/packages/vue-to-dsl/src/parser/index.ts new file mode 100644 index 0000000000..40eb747e9d --- /dev/null +++ b/packages/vue-to-dsl/src/parser/index.ts @@ -0,0 +1,63 @@ +import { parse } from '@vue/compiler-sfc' +import fs from 'fs/promises' + +export function parseSFC(vueCode: string): any { + const { descriptor, errors } = parse(vueCode) + if (errors && (errors as any[]).length > 0) { + // eslint-disable-next-line no-console + console.warn('SFC parsing warnings:', errors) + } + + const result: any = {} + if (descriptor.template) { + result.template = descriptor.template.content + result.templateLang = descriptor.template.lang || 'html' + } + if (descriptor.scriptSetup) { + result.scriptSetup = descriptor.scriptSetup.content + result.scriptSetupLang = descriptor.scriptSetup.lang || 'js' + } + if (descriptor.script) { + result.script = descriptor.script.content + result.scriptLang = descriptor.script.lang || 'js' + } + if (descriptor.styles && descriptor.styles.length > 0) { + result.style = descriptor.styles.map((style) => style.content).join('\n\n') + result.styleBlocks = descriptor.styles.map((style) => ({ + content: style.content, + lang: style.lang || 'css', + scoped: style.scoped || false, + module: style.module || false + })) + } + if (descriptor.customBlocks && descriptor.customBlocks.length > 0) { + result.customBlocks = descriptor.customBlocks.map((block) => ({ + type: block.type, + content: (block as any).content, + attrs: (block as any).attrs + })) + } + return result +} + +export async function parseVueFile(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8') + return parseSFC(content) +} + +export function validateSFC(sfcResult: any): boolean { + return !!(sfcResult.template || sfcResult.script || sfcResult.scriptSetup) +} + +export function getSFCMeta(sfcResult: any) { + return { + hasTemplate: !!sfcResult.template, + hasScript: !!sfcResult.script, + hasScriptSetup: !!sfcResult.scriptSetup, + hasStyle: !!sfcResult.style, + templateLang: sfcResult.templateLang, + scriptLang: sfcResult.scriptLang || sfcResult.scriptSetupLang, + styleBlocks: sfcResult.styleBlocks || [], + customBlocks: sfcResult.customBlocks || [] + } +} diff --git a/packages/vue-to-dsl/src/parsers/index.ts b/packages/vue-to-dsl/src/parsers/index.ts new file mode 100644 index 0000000000..9b596456c2 --- /dev/null +++ b/packages/vue-to-dsl/src/parsers/index.ts @@ -0,0 +1,3 @@ +export { parseTemplate } from './templateParser' +export { parseScript } from './scriptParser' +export { parseStyle, parseCSSRules, extractCSSVariables, hasMediaQueries, extractMediaQueries } from './styleParser' diff --git a/packages/vue-to-dsl/src/parsers/scriptParser.ts b/packages/vue-to-dsl/src/parsers/scriptParser.ts new file mode 100644 index 0000000000..69d9933740 --- /dev/null +++ b/packages/vue-to-dsl/src/parsers/scriptParser.ts @@ -0,0 +1,614 @@ +import { parse } from '@babel/parser' +import traverseModule from '@babel/traverse' +import * as t from '@babel/types' + +const traverse: any = (traverseModule as any)?.default ?? (traverseModule as any) + +const LIFECYCLE_HOOKS = [ + 'onMounted', + 'onUpdated', + 'onUnmounted', + 'onBeforeMount', + 'onBeforeUpdate', + 'onBeforeUnmount', + 'onActivated', + 'onDeactivated', + 'mounted', + 'updated', + 'unmounted', + 'beforeMount', + 'beforeUpdate', + 'beforeUnmount', + 'activated', + 'deactivated', + 'created', + 'beforeCreate', + 'destroyed', + 'beforeDestroy', + 'setup' +] + +function isVueReactiveCall(node: any, apiName: string) { + if (!t.isCallExpression(node)) return false + // direct call: reactive()/ref()/computed() + if (t.isIdentifier(node.callee) && node.callee.name === apiName) return true + // member call: vue.reactive()/Vue.ref()/anything.ref() + if (t.isMemberExpression(node.callee)) { + const callee = node.callee + const prop = callee.property + if (t.isIdentifier(prop) && prop.name === apiName) return true + } + return false +} + +function isLifecycleHook(name: string) { + return LIFECYCLE_HOOKS.includes(name) +} + +function getNodeValue(node: any): any { + if (t.isStringLiteral(node)) return node.value + if (t.isNumericLiteral(node)) return node.value + if (t.isBooleanLiteral(node)) return node.value + if (t.isNullLiteral(node)) return null + if (t.isUnaryExpression(node) && node.operator === '-' && t.isNumericLiteral(node.argument)) { + return -node.argument.value + } + if (t.isCallExpression(node)) { + let calleeStr = '' + if (t.isIdentifier(node.callee)) { + calleeStr = node.callee.name + } else if (t.isMemberExpression(node.callee)) { + const obj = node.callee.object as any + const prop = node.callee.property as any + const objStr = t.isIdentifier(obj) ? obj.name : '' + const propStr = t.isIdentifier(prop) ? prop.name : '' + if (objStr && propStr) calleeStr = `${objStr}.${propStr}` + } + const args = node.arguments.map((arg: any) => getNodeValue(arg)) + if (calleeStr) + return `${calleeStr}(${args.map((a: any) => (typeof a === 'string' ? `'${a}'` : String(a))).join(', ')})` + return 'undefined' + } + if (t.isObjectExpression(node)) { + const obj: Record = {} + node.properties.forEach((prop: any) => { + if (t.isObjectProperty(prop)) { + let keyName: string | null = null + if (t.isIdentifier(prop.key)) keyName = prop.key.name + else if (t.isStringLiteral(prop.key)) keyName = prop.key.value + else if (t.isNumericLiteral(prop.key)) keyName = String(prop.key.value) + if (keyName) obj[keyName] = getNodeValue(prop.value as any) + } + }) + return obj + } + if (t.isArrayExpression(node)) { + return node.elements.map((el: any) => (el ? getNodeValue(el) : null)) + } + return 'undefined' +} + +function getSource(node: any, source: string): string { + if (!node) return '' + const start = (node as any).start + const end = (node as any).end + if (typeof start === 'number' && typeof end === 'number') return source.slice(start, end) + return '' +} + +function arrowToFunctionString(name: string, node: t.ArrowFunctionExpression, source: string) { + const asyncStr = node.async ? 'async ' : '' + const params = node.params.map((p) => getSource(p, source)).join(', ') + if (t.isBlockStatement(node.body)) { + const body = getSource(node.body, source) + return `${asyncStr}function ${name}(${params}) ${body}` + } + const expr = getSource(node.body, source) + return `${asyncStr}function ${name}(${params}) { return ${expr}; }` +} + +function functionExpressionToNamedFunctionString( + name: string, + node: t.FunctionExpression | t.ObjectMethod, + source: string +) { + const asyncStr = (node as any).async ? 'async ' : '' + const params = (node as any).params.map((p: any) => getSource(p, source)).join(', ') + const body = getSource((node as any).body, source) + return `${asyncStr}function ${name}(${params}) ${body}` +} + +// ---- setup 专属逻辑的小分支封装(共享生命周期处理主干)---- +const isSetupName = (name: string) => name === 'setup' + +function setLifecycleEntry(result: any, name: string, code: string, opts: { noOverride?: boolean } = {}) { + if (opts.noOverride && result.lifeCycles[name]) return + result.lifeCycles[name] = { type: 'lifecycle', value: code || (name ? `function ${name}(){}` : 'function() {}') } +} + +function setMethodEntry(result: any, name: string, code: string) { + result.methods[name] = { type: 'function', value: code || `function ${name}(){}` } +} + +function routeFunctionLikeByName(result: any, name: string, code: string) { + if (isSetupName(name)) setLifecycleEntry(result, name, code) + else setMethodEntry(result, name, code) +} + +// Helpers to reduce duplication when handling variable declarators in + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/package.json b/packages/vue-to-dsl/test/full/input/appdemo01/package.json new file mode 100644 index 0000000000..6f3161e7ca --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/package.json @@ -0,0 +1,29 @@ +{ + "name": "portal-app", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "dependencies": { + "@opentiny/tiny-engine-i18n-host": "^1.0.0", + "@opentiny/vue": "3.24.0", + "@opentiny/vue-icon": "3.24.0", + "axios": "latest", + "axios-mock-adapter": "^1.19.0", + "vue": "^3.3.9", + "vue-i18n": "^9.2.0-beta.3", + "vue-router": "^4.2.5", + "pinia": "^2.1.7", + "@opentiny/tiny-engine-builtin-component": "^2.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.2", + "@vitejs/plugin-vue-jsx": "^4.0.1", + "vite": "^5.4.2" + } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/public/favicon.ico b/packages/vue-to-dsl/test/full/input/appdemo01/public/favicon.ico new file mode 100644 index 0000000000..6271b2d812 Binary files /dev/null and b/packages/vue-to-dsl/test/full/input/appdemo01/public/favicon.ico differ diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/App.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/App.vue new file mode 100644 index 0000000000..72b6032dea --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/App.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/http/axios.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/axios.js new file mode 100644 index 0000000000..3654c619b4 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/axios.js @@ -0,0 +1,143 @@ +/** + * 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' +import MockAdapter from 'axios-mock-adapter' + +export default (config) => { + const instance = axios.create(config) + const defaults = {} + let mock + + if (typeof MockAdapter.prototype.proxy === 'undefined') { + MockAdapter.prototype.proxy = function ({ url, config = {}, proxy, response, handleData } = {}) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let stream = this + const request = (proxy, any) => { + return (setting) => { + return new Promise((resolve) => { + config.responseType = 'json' + axios + .get(any ? proxy + setting.url + '.json' : proxy, config) + .then(({ data }) => { + if (typeof handleData === 'function') { + data = handleData.call(null, data, setting) + } + resolve([200, data]) + }) + .catch((error) => { + resolve([error.response.status, error.response.data]) + }) + }) + } + } + + if (url === '*' && proxy && typeof proxy === 'string') { + stream = proxy === '*' ? this.onAny().passThrough() : this.onAny().reply(request(proxy, true)) + } else { + if (proxy && typeof proxy === 'string') { + stream = this.onAny(url).reply(request(proxy)) + } else if (typeof response === 'function') { + stream = this.onAny(url).reply(response) + } + } + + return stream + } + } + + return { + request(config) { + return instance(config) + }, + get(url, config) { + return instance.get(url, config) + }, + delete(url, config) { + return instance.delete(url, config) + }, + head(url, config) { + return instance.head(url, config) + }, + post(url, data, config) { + return instance.post(url, data, config) + }, + put(url, data, config) { + return instance.put(url, data, config) + }, + patch(url, data, config) { + return instance.patch(url, data, config) + }, + all(iterable) { + return axios.all(iterable) + }, + spread(callback) { + return axios.spread(callback) + }, + 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) + } + } + }, + mock(config) { + if (!mock) { + mock = new MockAdapter(instance) + } + + if (Array.isArray(config)) { + config.forEach((item) => { + mock.proxy(item) + }) + } + + return mock + }, + disableMock() { + if (mock) { + mock.restore() + } + mock = undefined + }, + isMock() { + return typeof mock !== 'undefined' + }, + CancelToken: axios.CancelToken, + isCancel: axios.isCancel + } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/http/config.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/config.js new file mode 100644 index 0000000000..cfa3714e17 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/config.js @@ -0,0 +1,15 @@ +/** + * 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 { + withCredentials: false +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/http/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/index.js new file mode 100644 index 0000000000..b0a08546a6 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/index.js @@ -0,0 +1,27 @@ +/** + * 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' +import config from './config' + +export default (dataHandler) => { + const http = axios(config) + + http.interceptors.response.use(dataHandler, (error) => { + const response = error.response + if (response.status === 403 && response.headers && response.headers['x-login-url']) { + // TODO 处理无权限时,重新登录再发送请求 + } + }) + + return http +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/en_US.json b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/en_US.json new file mode 100644 index 0000000000..be5c684e5e --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/en_US.json @@ -0,0 +1,25 @@ +{ + "lowcode.c257d5e8": "search", + "lowcode.61c8ac8c": "dsdsa", + "lowcode.f53187a0": "test", + "lowcode.97ad00dd": "createMaterial", + "lowcode.61dcef52": "sadasda", + "lowcode.45f4c42a": "gfdgfd", + "lowcode.c6f5a652": "fsdafds", + "lowcode.34923432": "fdsafds", + "lowcode.6534943e": "fdsafdsa", + "lowcode.44252642": "aaaa", + "lowcode.2a743651": "fdsaf", + "lowcode.24315357": "fsdafds", + "lowcode.44621691": "sd", + "lowcode.65636226": "fdsfsd", + "lowcode.6426a4e2": "fdsafsd", + "lowcode.e41c6636": "aa", + "lowcode.51c23164": "aa", + "lowcode.17245b46": "aa", + "lowcode.4573143c": "a", + "lowcode.56432442": "aa", + "lowcode.33566643": "aa", + "lowcode.565128f3": "aa", + "lowcode.56643835": "aa" +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/index.js new file mode 100644 index 0000000000..f6c510b279 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/index.js @@ -0,0 +1,9 @@ +import i18n from '@opentiny/tiny-engine-i18n-host' +import lowcode from '../lowcodeConfig/lowcode' +import locale from './locale.js' + +i18n.lowcode = lowcode +i18n.global.mergeLocaleMessage('en_US', locale.en_US) +i18n.global.mergeLocaleMessage('zh_CN', locale.zh_CN) + +export default i18n diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/locale.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/locale.js new file mode 100644 index 0000000000..75308fc752 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/locale.js @@ -0,0 +1,4 @@ +import en_US from './en_US.json' +import zh_CN from './zh_CN.json' + +export default { en_US, zh_CN } diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/zh_CN.json b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/zh_CN.json new file mode 100644 index 0000000000..59357fdfcc --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/zh_CN.json @@ -0,0 +1,26 @@ +{ + "lowcode.c257d5e8": "查询", + "lowcode.61c8ac8c": "地方", + "lowcode.f53187a0": "测试", + "lowcode.97ad00dd": "创建物料资产包", + "lowcode.61dcef52": "terterere", + "lowcode.45f4c42a": "gdfgdf", + "lowcode.c6f5a652": "fsdaf", + "lowcode.34923432": "fdsafdsa", + "lowcode.48521e45": "fdsfds", + "lowcode.6534943e": "fdsafds", + "lowcode.44252642": "fdsafds", + "lowcode.2a743651": "sda", + "lowcode.24315357": "fdsafds", + "lowcode.44621691": "fdsafsd", + "lowcode.65636226": "fdsaf", + "lowcode.6426a4e2": "sd", + "lowcode.e41c6636": "aa", + "lowcode.51c23164": "aa", + "lowcode.17245b46": "aa", + "lowcode.4573143c": "aa", + "lowcode.56432442": "aa", + "lowcode.33566643": "aa", + "lowcode.565128f3": "aa", + "lowcode.56643835": "aa" +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/bridge.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/bridge.js new file mode 100644 index 0000000000..7a19e4a116 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/bridge.js @@ -0,0 +1,13 @@ +/** + * 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 () => {} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.js new file mode 100644 index 0000000000..f82f146bfc --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.js @@ -0,0 +1,104 @@ +/** + * 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' +import dataSources from './dataSource.json' + +const dataSourceMap = {} + +// 暂时使用 eval 解析 JSON 数据里的函数 +const createFn = (fnContent) => { + return (...args) => { + // eslint-disable-next-line no-eval + window.eval('var fn = ' + fnContent) + // eslint-disable-next-line no-undef + return fn.apply(this, args) + } +} + +const globalDataHandle = dataSources.dataHandler ? createFn(dataSources.dataHandler.value) : (res) => res + +const load = (http, options, dataSource, shouldFetch) => (params, customUrl) => { + // 如果没有配置远程请求,则直接返回静态数据,返回前可能会有全局数据处理 + if (!options) { + return globalDataHandle(dataSource.config.data) + } + + if (!shouldFetch()) { + return + } + + dataSource.status = 'loading' + + const { method, uri: url, params: defaultParams, timeout, headers } = options + const config = { method, url, headers, timeout } + + const data = params || defaultParams + + config.url = customUrl || config.url + + if (method.toLowerCase() === 'get') { + config.params = data + } else { + config.data = data + } + + return http.request(config) +} + +dataSources.list.forEach((config) => { + const http = useHttp(globalDataHandle) + const dataSource = { config } + + dataSourceMap[config.name] = dataSource + + const shouldFetch = config.shouldFetch?.value ? createFn(config.shouldFetch.value) : () => true + const willFetch = config.willFetch?.value ? createFn(config.willFetch.value) : (options) => options + + const dataHandler = (res) => { + const data = config.dataHandler?.value ? createFn(config.dataHandler.value)(res) : res + dataSource.status = 'loaded' + dataSource.data = data + return data + } + + const errorHandler = (error) => { + if (config.errorHandler?.value) { + createFn(config.errorHandler.value)(error) + } + dataSource.status = 'error' + dataSource.error = error + } + + http.interceptors.request.use(willFetch, errorHandler) + http.interceptors.response.use(dataHandler, errorHandler) + + if (import.meta.env.VITE_APP_MOCK === 'mock') { + http.mock([ + { + url: config.options?.uri, + response() { + return Promise.resolve([200, { data: config.data }]) + } + }, + { + url: '*', + proxy: '*' + } + ]) + } + + dataSource.status = 'init' + dataSource.load = load(http, config.options, dataSource, shouldFetch) +}) + +export default dataSourceMap diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.json b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.json new file mode 100644 index 0000000000..73ff9cb058 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.json @@ -0,0 +1,632 @@ +{ + "list": [ + { + "id": 132, + "name": "getAllComponent", + "data": [], + "type": "array" + }, + { + "id": 133, + "name": "getAllList", + "columns": [ + { + "name": "test", + "title": "测试", + "field": "test", + "type": "string", + "format": {} + }, + { + "name": "test1", + "title": "测试1", + "field": "test1", + "type": "string", + "format": {} + } + ], + "type": "array", + "data": [ + { + "test": "test1", + "test1": "test1", + "_id": "341efc48" + }, + { + "test": "test2", + "test1": "test1", + "_id": "b86b516c" + }, + { + "test": "test3", + "test1": "test1", + "_id": "f680cd78" + } + ], + "options": { + "uri": "", + "method": "GET" + }, + "dataHandler": { + "type": "JSFunction", + "value": "function dataHandler(data) { \n return data \n}" + }, + "willFetch": { + "type": "JSFunction", + "value": "function willFetch(option) {\n return option \n}" + }, + "shouldFetch": { + "type": "JSFunction", + "value": "function shouldFetch(option) {\n return true \n}" + }, + "errorHandler": { + "type": "JSFunction", + "value": "function errorHandler(err) {}" + } + }, + { + "id": 135, + "name": "getAllMaterialList", + "columns": [ + { + "name": "id", + "title": "id", + "field": "id", + "type": "string", + "format": {} + }, + { + "name": "name", + "title": "name", + "field": "name", + "type": "string", + "format": {} + }, + { + "name": "framework", + "title": "framework", + "field": "framework", + "type": "string", + "format": { + "required": true + } + }, + { + "name": "components", + "title": "components", + "field": "components", + "type": "string", + "format": {} + }, + { + "name": "content", + "title": "content", + "field": "content", + "type": "string", + "format": {} + }, + { + "name": "url", + "title": "url", + "field": "url", + "type": "string", + "format": {} + }, + { + "name": "published_at", + "title": "published_at", + "field": "published_at", + "type": "string", + "format": {} + }, + { + "name": "created_at", + "title": "created_at", + "field": "created_at", + "type": "string", + "format": {} + }, + { + "name": "updated_at", + "title": "updated_at", + "field": "updated_at", + "type": "string", + "format": {} + }, + { + "name": "published", + "title": "published", + "field": "published", + "type": "string", + "format": {} + }, + { + "name": "last_build_info", + "title": "last_build_info", + "field": "last_build_info", + "type": "string", + "format": {} + }, + { + "name": "tenant", + "title": "tenant", + "field": "tenant", + "type": "string", + "format": {} + }, + { + "name": "version", + "title": "version", + "field": "version", + "type": "string", + "format": {} + }, + { + "name": "description", + "title": "description", + "field": "description", + "type": "string", + "format": {} + } + ], + "type": "array", + "data": [ + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "2a23e653" + }, + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "06b253be" + }, + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "c55a41ed" + }, + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "f37123ec" + }, + { + "id": "7a63c1a2", + "url": "", + "name": "tiny-vue", + "tenant": "", + "content": "Tiny Vue物料", + "version": "1.0.0", + "framework": "Vue", + "published": "", + "components": "", + "created_at": "", + "updated_at": "", + "description": "Tiny Vue物料", + "published_at": "", + "last_build_info": "", + "_id": "7a63c1a2" + } + ], + "options": { + "uri": "", + "method": "GET" + }, + "willFetch": { + "type": "JSFunction", + "value": "function willFetch(option) {\n return option \n}" + }, + "dataHandler": { + "type": "JSFunction", + "value": "function dataHandler(data) { \n return data \n}" + }, + "shouldFetch": { + "type": "JSFunction", + "value": "function shouldFetch(option) {\n return true \n}" + }, + "errorHandler": { + "type": "JSFunction", + "value": "function errorHandler(err) {}" + } + }, + { + "id": 139, + "name": "treedata", + "data": [ + { + "label": "level111", + "value": "111", + "id": "f6609643", + "pid": "", + "_RID": "row_4" + }, + { + "label": "level1-son", + "value": "111-1", + "id": "af1f937f", + "pid": "f6609643", + "_RID": "row_5" + }, + { + "label": "level222", + "value": "222", + "id": "28e3709c", + "pid": "", + "_RID": "row_6" + }, + { + "label": "level2-son", + "value": "222-1", + "id": "6b571bef", + "pid": "28e3709c", + "_RID": "row_5" + }, + { + "id": "6317c2cc", + "pid": "fdfa", + "label": "fsdfaa", + "value": "fsadf", + "_RID": "row_6" + }, + { + "id": "9cce369f", + "pid": "test", + "label": "test1", + "value": "001" + } + ], + "type": "tree" + }, + { + "id": 150, + "name": "componentList", + "data": [ + { + "_RID": "row_1", + "name": "表单", + "isSelected": "true", + "description": "由按钮、输入框、选择器、单选框、多选框等控件组成,用以收集、校验、提交数据" + }, + { + "name": "按钮", + "isSelected": "false", + "description": "常用的操作按钮,提供包括默认按钮、图标按钮、图片按钮、下拉按钮等类型" + }, + { + "id": "490f8a00", + "_RID": "row_3", + "name": "表单项", + "framework": "", + "materials": "", + "description": "Form 组件下的 FormItem 配置" + }, + { + "id": "c259b8b3", + "_RID": "row_4", + "name": "开关", + "framework": "", + "materials": "", + "description": "关闭或打开" + }, + { + "id": "083ed9c7", + "_RID": "row_5", + "name": "互斥按钮组", + "framework": "", + "materials": "", + "description": "以按钮组的方式出现,常用于多项类似操作" + }, + { + "id": "09136cea", + "_RID": "row_6", + "name": "提示框", + "framework": "", + "materials": "", + "description": "Popover可通过对一个触发源操作触发弹出框,支持自定义弹出内容,延迟触发和渐变动画" + }, + { + "id": "a63b57d5", + "_RID": "row_7", + "name": "文字提示框", + "framework": "", + "materials": "", + "description": "动态显示提示信息,一般通过鼠标事件进行响应;提供 warning、error、info、success 四种类型显示不同类别的信" + }, + { + "id": "a0f6e8a3", + "_RID": "row_8", + "name": "树", + "framework": "", + "materials": "", + "description": "可进行展示有父子层级的数据,支持选择,异步加载等功能。但不推荐用它来展示菜单,展示菜单推荐使用树菜单" + }, + { + "id": "d1aa18fc", + "_RID": "row_9", + "name": "分页", + "framework": "", + "materials": "", + "description": "当数据量过多时,使用分页分解数据,常用于 Grid 和 Repeater 组件" + }, + { + "id": "ca49cc52", + "_RID": "row_10", + "name": "表格", + "framework": "", + "materials": "", + "description": "提供了非常强大数据表格功能,可以展示数据列表,可以对数据列表进行选择、编辑等" + }, + { + "id": "4e20ecc9", + "name": "搜索框", + "framework": "", + "materials": "", + "description": "指定条件对象进行搜索数据" + }, + { + "id": "6b093ee5", + "name": "折叠面板", + "framework": "", + "materials": "", + "description": "内容区可指定动态页面或自定义 html 等,支持展开收起操作" + }, + { + "id": "0a09abc0", + "name": "对话框", + "framework": "", + "materials": "", + "description": "模态对话框,在浮层中显示,引导用户进行相关操作" + }, + { + "id": "f814b901", + "name": "标签页签项", + "framework": "", + "materials": "", + "description": "tab页签" + }, + { + "id": "c5ae797c", + "name": "单选", + "framework": "", + "materials": "", + "description": "用于配置不同场景的选项,在一组备选项中进行单选" + }, + { + "id": "33d0c590", + "_RID": "row_13", + "name": "弹出编辑", + "framework": "", + "materials": "", + "description": "该组件只能在弹出的面板中选择数据,不能手动输入数据;弹出面板中显示为 Tree 组件或者 Grid 组件" + }, + { + "id": "16711dfa", + "_RID": "row_14", + "name": "下拉框", + "framework": "", + "materials": "", + "description": "Select 选择器是一种通过点击弹出下拉列表展示数据并进行选择的 UI 组件" + }, + { + "id": "a9fd190a", + "_RID": "row_15", + "name": "折叠面板项", + "framework": "", + "materials": "", + "description": "内容区可指定动态页面或自定义 html 等,支持展开收起操作" + }, + { + "id": "a7dfa9ec", + "_RID": "row_16", + "name": "复选框", + "framework": "", + "materials": "", + "description": "用于配置不同场景的选项,提供用户可在一组选项中进行多选" + }, + { + "id": "d4bb8330", + "name": "输入框", + "framework": "", + "materials": "", + "description": "通过鼠标或键盘输入字符" + }, + { + "id": "ced3dc83", + "name": "时间线", + "framework": "", + "materials": "", + "description": "时间线" + } + ], + "type": "array", + "columns": [ + { + "name": "name", + "type": "string", + "field": "name", + "title": "name", + "format": { + "max": 0, + "min": 0, + "dateTime": false, + "required": false, + "stringType": "" + } + }, + { + "name": "description", + "type": "string", + "field": "description", + "title": "description", + "format": { + "max": 0, + "min": 0, + "dateTime": false, + "required": false, + "stringType": "" + } + }, + { + "name": "isSelected", + "type": "string", + "field": "isSelected", + "title": "isSelected", + "format": { + "max": 0, + "min": 0, + "dateTime": false, + "required": false, + "stringType": "" + } + } + ], + "options": { + "uri": "http://localhost:9090/assets/json/bundle.json", + "method": "GET" + }, + "willFetch": { + "type": "JSFunction", + "value": "function willFetch(option) {\n return option \n}" + }, + "dataHandler": { + "type": "JSFunction", + "value": "function dataHandler(data) { \n return data \n}" + }, + "shouldFetch": { + "type": "JSFunction", + "value": "function shouldFetch(option) {\n return true \n}" + }, + "errorHandler": { + "type": "JSFunction", + "value": "function errorHandler(err) {}" + } + }, + { + "id": 151, + "name": "selectedComponents", + "columns": [ + { + "name": "name", + "title": "name", + "field": "name", + "type": "string", + "format": { + "required": false, + "stringType": "", + "min": 0, + "max": 0, + "dateTime": false + } + }, + { + "name": "description", + "title": "description", + "field": "description", + "type": "string", + "format": { + "required": false, + "stringType": "", + "min": 0, + "max": 0, + "dateTime": false + } + }, + { + "name": "isSelected", + "title": "isSelected", + "field": "isSelected", + "type": "string", + "format": { + "required": false, + "stringType": "", + "min": 0, + "max": 0, + "dateTime": false + } + } + ], + "type": "array", + "data": [ + { + "name": "标签页", + "description": "分隔内容上有关联但属于不同类别的数据集合", + "isSelected": "true", + "_RID": "row_2" + }, + { + "name": "布局列", + "description": "列配置信息", + "isSelected": "true", + "id": "76a7080a", + "_RID": "row_4" + }, + { + "name": "日期选择器", + "description": "用于设置/选择日期,包括年月/年月日/年月日时分/年月日时分秒日期格式", + "isSelected": "true", + "id": "76b20d73", + "_RID": "row_1" + }, + { + "name": "走马灯", + "description": "常用于一组图片或卡片轮播,当内容空间不足时,可以用走马灯的形式进行收纳,进行轮播展现", + "isSelected": "true", + "id": "4c884c3d" + } + ] + } + ], + "dataHandler": { + "type": "JSFunction", + "value": "function dataHanlder(res){\n return res;\n}" + } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/lowcode.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/lowcode.js new file mode 100644 index 0000000000..29da8186b5 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/lowcode.js @@ -0,0 +1,86 @@ +/** + * 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 { getCurrentInstance, nextTick, provide, inject } from 'vue' +import { useRouter, useRoute } from 'vue-router' +import { I18nInjectionKey } from 'vue-i18n' +import dataSourceMap from './dataSource' +import * as utils from '../utils' +import * as bridge from './bridge' +import { useStores } from './store' + +export const lowcodeWrap = (props, context) => { + const global = {} + const instance = getCurrentInstance() + const router = useRouter() + const route = useRoute() + const { t, locale } = inject(I18nInjectionKey).global + const emit = context.emit + const ref = (ref) => instance.refs[ref] + + const setState = (newState, callback) => { + Object.assign(global.state, newState) + nextTick(() => callback.apply(global)) + } + + const getLocale = () => locale.value + const setLocale = (val) => { + locale.value = val + } + + const location = () => window.location + const history = () => window.history + + Object.defineProperties(global, { + props: { get: () => props }, + emit: { get: () => emit }, + setState: { get: () => setState }, + router: { get: () => router }, + route: { get: () => route }, + i18n: { get: () => t }, + getLocale: { get: () => getLocale }, + setLocale: { get: () => setLocale }, + location: { get: location }, + history: { get: history }, + utils: { get: () => utils }, + bridge: { get: () => bridge }, + dataSourceMap: { get: () => dataSourceMap }, + $: { get: () => ref } + }) + + const wrap = (fn) => { + if (typeof fn === 'function') { + return (...args) => fn.apply(global, args) + } + + Object.entries(fn).forEach(([name, value]) => { + Object.defineProperty(global, name, { + get: () => value + }) + }) + + fn.t = t + + return fn + } + + return wrap +} + +export default () => { + const i18n = inject(I18nInjectionKey) + provide(I18nInjectionKey, i18n) + + const stores = useStores() + + return { t: i18n.global.t, lowcodeWrap, stores } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/store.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/store.js new file mode 100644 index 0000000000..f7f39c7a84 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/store.js @@ -0,0 +1,13 @@ +import * as useDefinedStores from '@/stores' + +const useStores = () => { + const stores = {} + + Object.values({ ...useDefinedStores }).forEach((store) => { + stores[store.$id] = store() + }) + + return stores +} + +export { useStores } diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/main.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/main.js new file mode 100644 index 0000000000..c4574461b3 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/main.js @@ -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. + * + */ + +import { createApp } from 'vue' +import router from './router' +import { createPinia } from 'pinia' +import App from './App.vue' + +const pinia = createPinia() + +createApp(App).use(pinia).use(router).mount('#app') diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/router/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/router/index.js new file mode 100644 index 0000000000..a06668a4c0 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/router/index.js @@ -0,0 +1,37 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +const routes = [ + { + path: '/', + children: [ + { + name: '1', + path: 'CreateVm', + component: () => import('@/views/CreateVm.vue'), + children: [] + }, + { + name: '1GhxcwoNeestd4aI', + path: 'Demopage', + component: () => import('@/views/DemoPage.vue'), + children: [] + }, + { + name: 'MQSQpz7noWlTRnse', + path: 'Lifecycle', + component: () => import('@/views/LifeCyclePage.vue'), + children: [] + }, + { + name: 'mPX398RIysZI3CRG', + path: 'UntitledA', + component: () => import('@/views/UntitledA.vue'), + children: [] + } + ] + } +] + +export default createRouter({ + history: createWebHashHistory(), + routes +}) diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/index.js new file mode 100644 index 0000000000..380fa26bf6 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/index.js @@ -0,0 +1 @@ +export { testState } from './testState' diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/testState.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/testState.js new file mode 100644 index 0000000000..c2312435e9 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/testState.js @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia' +export const testState = defineStore({ + id: 'testState', + state: () => ({ + name: 'testName', + license: '', + age: 18, + food: ['apple', 'orange', 'banana', 19], + desc: { description: 'hello world', money: 100, other: '', rest: ['a', 'b', 'c', 20] } + }), + getters: { + getAge: function getAge() { + return this.age + }, + getName: function getName() { + return this.name + } + }, + actions: { + setAge: function setAge(age) { + this.age = age + }, + setName: function setName(name) { + this.name = name + } + } +}) diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/utils.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/utils.js new file mode 100644 index 0000000000..42009b621d --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/utils.js @@ -0,0 +1,13 @@ +import axios from 'axios' +import { Button } from '@opentiny/vue' +import { NavMenu } from '@opentiny/vue' +import { Modal } from '@opentiny/vue' +import { Pager } from '@opentiny/vue' +const npm = '' +const test = function test() { + return 'test' +} +const util = function util() { + console.log(321) +} +export { axios, Button, NavMenu, Modal, npm, Pager, test, util } diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/DemoPage.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/DemoPage.vue new file mode 100644 index 0000000000..cecb287f05 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/DemoPage.vue @@ -0,0 +1,27 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/LifeCyclePage.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/LifeCyclePage.vue new file mode 100644 index 0000000000..8be8b629bf --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/LifeCyclePage.vue @@ -0,0 +1,60 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/UntitledA.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/UntitledA.vue new file mode 100644 index 0000000000..c9b57ed7cc --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/UntitledA.vue @@ -0,0 +1,38 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/createVm.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/createVm.vue new file mode 100644 index 0000000000..ed8ec0b426 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/createVm.vue @@ -0,0 +1,442 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/vite.config.js b/packages/vue-to-dsl/test/full/input/appdemo01/vite.config.js new file mode 100644 index 0000000000..b611ebc4a3 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import path from 'path' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, 'src') + } + }, + plugins: [vue(), vueJsx()], + define: { + 'process.env': { ...process.env } + }, + build: { + minify: true, + commonjsOptions: { + transformMixedEsModules: true + }, + cssCodeSplit: false + }, + base: './' +}) diff --git a/packages/vue-to-dsl/test/sfc/converter.test.js b/packages/vue-to-dsl/test/sfc/converter.test.js new file mode 100644 index 0000000000..b7a8cb4a4b --- /dev/null +++ b/packages/vue-to-dsl/test/sfc/converter.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { VueToDslConverter } from '../../src/converter' + +describe('VueToDslConverter', () => { + const converter = new VueToDslConverter({ computed_flag: true }) + + it('should convert simple Vue SFC to DSL', async () => { + const vueCode = ` + + + + + + ` + + const result = await converter.convertFromString(vueCode) + + expect(result.errors).toHaveLength(0) + expect(result.schema).toBeDefined() + expect(result.schema.componentName).toBe('Page') + expect(result.schema.state).toBeDefined() + expect(result.schema.methods).toBeDefined() + expect(result.schema.css).toBeDefined() + expect(result.schema.children).toBeDefined() + }) + + it('should handle Vue Options API', async () => { + const vueCode = ` + + + + ` + + const result = await converter.convertFromString(vueCode) + + expect(result.errors).toHaveLength(0) + expect(result.schema).toBeDefined() + expect(result.schema.state).toBeDefined() + expect(result.schema.methods).toBeDefined() + expect(result.schema.lifeCycles).toBeDefined() + }) + + it('should parse + ` + + const result = await converter.convertFromString(vueCode) + + expect(result.errors).toHaveLength(0) + expect(result.schema.state.count).toBeDefined() + // lifecycle hook stored with key 'onMounted' + expect(result.schema.lifeCycles.onMounted).toBeDefined() + expect(result.schema.methods.inc).toBeDefined() + }) + + it('should omit computed by default when flag is false', async () => { + const vueCode = ` + + + + ` + + const defaultConverter = new VueToDslConverter() + const result = await defaultConverter.convertFromString(vueCode) + expect(result.errors).toHaveLength(0) + expect(result.schema.computed).toBeUndefined() + }) +}) diff --git a/packages/vue-to-dsl/test/testcases/001_simple/expected/schema.json b/packages/vue-to-dsl/test/testcases/001_simple/expected/schema.json new file mode 100644 index 0000000000..ffcad68012 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/001_simple/expected/schema.json @@ -0,0 +1,60 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "switchStatus": true + }, + "methods": { + "onClickNew": { + "type": "JSFunction", + "value": "function onClickNew(event) {\n this.state.switchStatus = !this.state.switchStatus\n}" + } + }, + "computed": {}, + "lifeCycles": { + "setup": { + "type": "JSFunction", + "value": "function setup({ props, state, watch, onMounted }) {\n console.log('setup')\n state.switchStatus = false\n}" + } + }, + "css": ".page-base-style {\n padding: 24px;\n background: #ffffff;\n}\n\n.block-base-style {\n margin: 16px;\n}\n\n.component-base-style {\n margin: 8px;\n}", + "children": [ + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "TinySwitch", + "props": { + "className": "component-base-style", + "modelValue": { + "type": "JSExpression", + "value": "this.state.switchStatus", + "model": true + } + }, + "children": [], + "id": "fv6wuu63" + }, + { + "componentName": "TinyButton", + "props": { + "text": "按钮文案", + "className": "component-base-style", + "onClick": { + "type": "JSExpression", + "value": "this.onClickNew" + } + }, + "children": [], + "id": "88gmxjxz" + } + ], + "id": "ngzpirht" + } + ], + "id": "g6s705pv" +} diff --git a/packages/vue-to-dsl/test/testcases/001_simple/input/component.vue b/packages/vue-to-dsl/test/testcases/001_simple/input/component.vue new file mode 100644 index 0000000000..c930701a99 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/001_simple/input/component.vue @@ -0,0 +1,49 @@ + + + + diff --git a/packages/vue-to-dsl/test/testcases/002_createVM/expected/schema.json b/packages/vue-to-dsl/test/testcases/002_createVM/expected/schema.json new file mode 100644 index 0000000000..b9d2c6319a --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/002_createVM/expected/schema.json @@ -0,0 +1,1125 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "dataDisk": [1, 2, 3] + }, + "methods": {}, + "computed": {}, + "lifeCycles": {}, + "css": "body {\n background-color: #eef0f5;\n margin-bottom: 80px;\n}", + "children": [ + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "div", + "props": { + "style": "padding-bottom: 10px; padding-top: 10px" + }, + "children": [ + { + "componentName": "TinyTimeLine", + "props": { + "active": "2", + "style": "border-radius: 0px", + "horizontal": true, + "data": [ + { + "name": "基础配置" + }, + { + "name": "网络配置" + }, + { + "name": "高级配置" + }, + { + "name": "确认配置" + } + ] + }, + "children": [], + "id": "f6o3ajqj" + } + ], + "id": "md6cwygl" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "计费模式" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "包年/包月", + "value": "1" + }, + { + "text": "按需计费", + "value": "2" + } + ] + }, + "children": [], + "id": "ubwd8lfk" + } + ], + "id": "676a0faj" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "区域" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "style": "border-radius: 0px; margin-right: 10px", + "data": [ + { + "text": "乌兰察布二零一", + "value": "1" + } + ] + }, + "children": [], + "id": "s13qy75a" + }, + { + "componentName": "span", + "props": { + "style": "background-color: [object Event]; color: #8a8e99; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "温馨提示:页面左上角切换区域" + }, + "id": "43spgaa5" + } + ], + "id": "sfcukuue" + }, + { + "componentName": "span", + "props": { + "style": "display: block; color: #8a8e99; border-radius: 0px; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "不同区域的云服务产品之间内网互不相通;请就近选择靠近您业务的区域,可减少网络时延,提高访问速度" + }, + "id": "bkvslsjy" + } + ], + "id": "dsregz0u" + } + ], + "id": "i6ol9j9w" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "可用区", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "可用区1", + "value": "1" + }, + { + "text": "可用区2", + "value": "2" + }, + { + "text": "可用区3", + "value": "3" + } + ] + }, + "children": [], + "id": "vwnsns4b" + } + ], + "id": "l3xly2oq" + } + ], + "id": "4ekrjalz" + } + ], + "id": "rvsu4gu6" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "CPU架构" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "x86计算", + "value": "1" + }, + { + "text": "鲲鹏计算", + "value": "2" + } + ] + }, + "children": [], + "id": "sa4dv21x" + } + ], + "id": "kgjdu3xq" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "区域" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex; justify-content: flex-start; align-items: center" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex; align-items: center; margin-right: 10px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 80px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "vCPUs" + }, + "id": "3qi9sj2n" + } + ], + "id": "pjytdebw" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "y0t8wovv" + } + ], + "id": "fx3sn05n" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; align-items: center; margin-right: 10px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 80px; border-radius: 0px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "内存" + }, + "id": "vp34oq3k" + } + ], + "id": "0ft31pas" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "zt8zcs8t" + } + ], + "id": "95z4v9c1" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; align-items: center" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 80px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "规格名称" + }, + "id": "hib8vlsc" + } + ], + "id": "q1qpyi0h" + }, + { + "componentName": "TinySearch", + "props": { + "modelValue": "", + "placeholder": "输入关键词" + }, + "children": [], + "id": "krtylew9" + } + ], + "id": "fapyalc6" + } + ], + "id": "vs6rq4a7" + }, + { + "componentName": "div", + "props": { + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "style": "border-radius: 0px; margin-top: 12px", + "data": [ + { + "text": "通用计算型", + "value": "1" + }, + { + "text": "通用计算增强型", + "value": "2" + }, + { + "text": "内存优化型", + "value": "3" + }, + { + "text": "内存优化型", + "value": "4" + }, + { + "text": "磁盘增强型", + "value": "5" + }, + { + "text": "超高I/O型", + "value": "6" + }, + { + "text": "GPU加速型", + "value": "7" + } + ] + }, + "children": [], + "id": "w2rvp1zj" + }, + { + "componentName": "TinyGrid", + "props": { + "style": "margin-top: 12px; border-radius: 0px", + "auto-resize": true, + "editConfig": { + "trigger": "click", + "mode": "cell", + "showStatus": true + }, + "columns": [ + { + "type": "radio", + "width": 60 + }, + { + "field": "employees", + "title": "规格名称" + }, + { + "field": "created_date", + "title": "vCPUs | 内存(GiB)", + "sortable": true + }, + { + "field": "city", + "title": "CPU", + "sortable": true + }, + { + "title": "基准 / 最大带宽\t", + "sortable": true + }, + { + "title": "内网收发包", + "sortable": true + } + ], + "data": [ + { + "id": "1", + "name": "GFD科技有限公司", + "city": "福州", + "employees": 800, + "created_date": "2014-04-30 00:56:00", + "boole": false + }, + { + "id": "2", + "name": "WWW科技有限公司", + "city": "深圳", + "employees": 300, + "created_date": "2016-07-08 12:36:22", + "boole": true + } + ] + }, + "children": [], + "id": "56qdrmjc" + }, + { + "componentName": "div", + "props": { + "style": "margin-top: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 150px; display: inline-block" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "当前规格" + }, + "id": "j0lbbokj" + } + ], + "id": "819b4ldc" + }, + { + "componentName": "span", + "props": { + "style": "font-weight: 700" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "通用计算型 | Si2.large.2 | 2vCPUs | 4 GiB" + }, + "id": "mhxn1jsh" + } + ], + "id": "nroj6wso" + } + ], + "id": "kf0lnpnv" + } + ], + "id": "tk67ku85" + } + ], + "id": "08j8y1ff" + } + ], + "id": "dqcoos16" + } + ], + "id": "wgnx6mrw" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "镜像", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "公共镜像", + "value": "1" + }, + { + "text": "私有镜像", + "value": "2" + }, + { + "text": "共享镜像", + "value": "3" + } + ] + }, + "children": [], + "id": "3pmrh1zz" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; margin-top: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 170px; margin-right: 10px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "4s9tm0oo" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 340px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "i0ou3048" + } + ], + "id": "rawlzvaa" + }, + { + "componentName": "div", + "props": { + "style": "margin-top: 12px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "color: #e37d29" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "请注意操作系统的语言类型。" + }, + "id": "vlnnyrxv" + } + ], + "id": "fkhps62d" + } + ], + "id": "laqstaoh" + } + ], + "id": "m8as5pxu" + } + ], + "id": "7pb83c31" + } + ], + "id": "3ng26b65" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "系统盘", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex" + }, + "children": [ + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 200px; margin-right: 10px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "w3pe0p5l" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px; margin-right: 10px" + }, + "children": [], + "id": "hhs8g284" + }, + { + "componentName": "span", + "props": { + "style": "color: #575d6c; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "GiB IOPS上限240,IOPS突发上限5,000" + }, + "id": "zj1dyvyc" + } + ], + "id": "b5accadg" + } + ], + "id": "ro8z737r" + } + ], + "id": "c6513skc" + } + ], + "id": "bqckjdbt" + }, + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "数据盘", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin-top: 12px; display: flex" + }, + "children": [ + { + "componentName": "Icon", + "props": { + "style": "margin-right: 10px; width: 16px; height: 16px", + "name": "IconPanelMini" + }, + "children": [], + "id": "in4uom22" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 200px; margin-right: 10px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "f9b3adim" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px; margin-right: 10px" + }, + "children": [], + "id": "hkc6mfzd" + }, + { + "componentName": "span", + "props": { + "style": "color: #575d6c; font-size: 12px; margin-right: 10px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "GiB IOPS上限600,IOPS突发上限5,000" + }, + "id": "gu5suwyv" + } + ], + "id": "4f38cvox" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px" + }, + "children": [], + "id": "oj3nt5s1" + } + ], + "loop": { + "type": "JSExpression", + "value": "this.state.dataDisk" + }, + "id": "c74s7y9n" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; margin-top: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "Icon", + "props": { + "style": "width: 16px; height: 16px; margin-right: 10px", + "name": "IconPlus" + }, + "children": [], + "id": "ze75z2il" + }, + { + "componentName": "span", + "props": { + "style": "font-size: 12px; border-radius: 0px; margin-right: 10px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "增加一块数据盘" + }, + "id": "wmbogfnt" + } + ], + "id": "jrvc6fj3" + }, + { + "componentName": "span", + "props": { + "style": "color: #8a8e99; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "您还可以挂载 21 块磁盘(云硬盘)" + }, + "id": "7w1z3fem" + } + ], + "id": "pczkwydn" + } + ], + "id": "dn6ab3hv" + } + ], + "id": "j260lifx" + } + ], + "id": "g46smnve" + } + ], + "id": "ccze0iwn" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-color: #ffffff;\n padding-top: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n position: fixed;\n inset: auto 0% 0% 0%;\n height: 80px;\n line-height: 80px;\n border-radius: 0px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [], + "id": "k4z8lqi7" + }, + { + "componentName": "TinyRow", + "props": { + "style": "border-radius: 0px; height: 100%" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": "8" + }, + "children": [ + { + "componentName": "TinyRow", + "props": { + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": "5", + "style": "display: flex" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "margin-right: 10px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "购买量" + }, + "id": "o1jcb13q" + } + ], + "id": "qnptw0yo" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px; margin-right: 10px" + }, + "children": [], + "id": "jwlwlvz0" + }, + { + "componentName": "span", + "props": {}, + "children": [ + { + "componentName": "Text", + "props": { + "text": "台" + }, + "id": "jmfizbf2" + } + ], + "id": "uxtzimy2" + } + ], + "id": "pm92xrj9" + }, + { + "componentName": "TinyCol", + "props": { + "span": "7" + }, + "children": [ + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "span", + "props": { + "style": "font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "配置费用" + }, + "id": "xxiefanz" + } + ], + "id": "ae4vk0vk" + }, + { + "componentName": "span", + "props": { + "style": "padding-left: 10px; padding-right: 10px; color: #de504e" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "¥1.5776" + }, + "id": "i40ub6sn" + } + ], + "id": "cb02qhdh" + }, + { + "componentName": "span", + "props": { + "style": "font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "/小时" + }, + "id": "adkjij9o" + } + ], + "id": "k2x66nf0" + } + ], + "id": "n1d16xku" + }, + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "span", + "props": { + "style": "font-size: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "参考价格,具体扣费请以账单为准。" + }, + "id": "vmd1mfi5" + } + ], + "id": "dpi9os07" + }, + { + "componentName": "span", + "props": { + "style": "font-size: 12px; color: #344899" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "了解计费详情" + }, + "id": "eq0byqdt" + } + ], + "id": "n8tt8sko" + } + ], + "id": "q9wmr8ma" + } + ], + "id": "cwequlwq" + } + ], + "id": "8jwt4esp" + } + ], + "id": "sy3gd183" + }, + { + "componentName": "TinyCol", + "props": { + "span": "4", + "style": "\n display: flex;\n flex-direction: row-reverse;\n border-radius: 0px;\n height: 100%;\n justify-content: flex-start;\n align-items: center;\n " + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "text": "下一步: 网络配置", + "type": "danger", + "style": "max-width: unset" + }, + "children": [], + "id": "skzmpiap" + } + ], + "id": "n9wdnvab" + } + ], + "id": "rjuainel" + } + ], + "id": "veqg3b07" + } + ], + "id": "z9geov13" + } + ], + "id": "ozevdge6" +} diff --git a/packages/vue-to-dsl/test/testcases/002_createVM/input/component.vue b/packages/vue-to-dsl/test/testcases/002_createVM/input/component.vue new file mode 100644 index 0000000000..57881bbb7a --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/002_createVM/input/component.vue @@ -0,0 +1,411 @@ + + + + diff --git a/packages/vue-to-dsl/test/testcases/003_login/expected/schema.json b/packages/vue-to-dsl/test/testcases/003_login/expected/schema.json new file mode 100644 index 0000000000..da4a931af2 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/003_login/expected/schema.json @@ -0,0 +1,254 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "form": { + "username": "", + "password": "" + } + }, + "methods": { + "onSubmit": { + "type": "JSFunction", + "value": "function onSubmit() {\n if (!state.form.username || !state.form.password) return\n console.log('login', state.form)\n}" + }, + "onReset": { + "type": "JSFunction", + "value": "function onReset() {\n state.form.username = ''\n state.form.password = ''\n}" + } + }, + "computed": {}, + "lifeCycles": {}, + "css": ".login-page {\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n background: linear-gradient(135deg, #f5f7fa 0%, #e9eff7 100%);\n}\n\n.login-card {\n width: 420px;\n background: #fff;\n border: 1px solid #eef0f5;\n border-radius: 12px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);\n padding: 24px 24px 16px;\n}\n\n.login-header {\n text-align: center;\n margin-bottom: 12px;\n}\n\n.login-title {\n margin: 0;\n font-size: 22px;\n color: #1f2329;\n}\n\n.login-subtitle {\n margin: 6px 0 0;\n color: #6b7280;\n font-size: 12px;\n}\n\n.login-extra {\n display: flex;\n justify-content: space-between;\n font-size: 12px;\n}\n\n.link {\n color: #344899;\n text-decoration: none;\n}\n\n.link:hover {\n text-decoration: underline;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "login-page" + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "login-card" + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "login-header" + }, + "children": [ + { + "componentName": "h2", + "props": { + "className": "login-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "欢迎登录" + }, + "id": "ofvczp1s" + } + ], + "id": "x8ef07lh" + } + ], + "id": "1jfod2b7" + }, + { + "componentName": "TinyForm", + "props": { + "label-width": "80px", + "label-position": "left", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "用户名" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入用户名", + "modelValue": { + "type": "JSExpression", + "value": "this.state.form.username", + "model": true + } + }, + "children": [], + "id": "1ygfx967" + } + ], + "id": "9c9vcnnl" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "密码" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "type": "password", + "placeholder": "请输入密码", + "modelValue": { + "type": "JSExpression", + "value": "this.state.form.password", + "model": true + } + }, + "children": [], + "id": "n7apt8qz" + } + ], + "id": "ic28zqv9" + }, + { + "componentName": "TinyFormItem", + "props": {}, + "children": [ + { + "componentName": "TinyRow", + "props": { + "gutter": 8 + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "type": "primary", + "style": "width: 100%", + "onClick": { + "type": "JSExpression", + "value": "this.onSubmit" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "登录" + }, + "id": "2nrmrn45" + } + ], + "id": "19hic5dx" + } + ], + "id": "y407fl72" + }, + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "type": "default", + "style": "width: 100%", + "onClick": { + "type": "JSExpression", + "value": "this.onReset" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "重置" + }, + "id": "whtbv216" + } + ], + "id": "vbz134gr" + } + ], + "id": "pttzhyn8" + } + ], + "id": "rk9r36ku" + } + ], + "id": "61dtkdhp" + }, + { + "componentName": "TinyFormItem", + "props": {}, + "children": [ + { + "componentName": "div", + "props": { + "className": "login-extra" + }, + "children": [ + { + "componentName": "a", + "props": { + "href": "#", + "className": "link" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "忘记密码?" + }, + "id": "3sho8h0e" + } + ], + "id": "z3ct2qcg" + }, + { + "componentName": "a", + "props": { + "href": "#", + "className": "link" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "注册账号" + }, + "id": "sl7n8oa0" + } + ], + "id": "vcbhy095" + } + ], + "id": "hs39mc41" + } + ], + "id": "fwkcsif8" + } + ], + "id": "vznait0f" + } + ], + "id": "2ia7lcq3" + } + ], + "id": "p79r4yv8" + } + ], + "id": "qxpzn62z" +} diff --git a/packages/vue-to-dsl/test/testcases/003_login/input/component.vue b/packages/vue-to-dsl/test/testcases/003_login/input/component.vue new file mode 100644 index 0000000000..34020e34ea --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/003_login/input/component.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/vue-to-dsl/test/testcases/004_dashboard/expected/schema.json b/packages/vue-to-dsl/test/testcases/004_dashboard/expected/schema.json new file mode 100644 index 0000000000..13938d5ab6 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/004_dashboard/expected/schema.json @@ -0,0 +1,408 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "activeStep": 1, + "filters": { + "keyword": "", + "module": "" + }, + "category": "all", + "steps": [ + { + "name": "准备" + }, + { + "name": "处理" + }, + { + "name": "完成" + } + ], + "columns": [ + { + "field": "name", + "title": "名称" + }, + { + "field": "path", + "title": "路径" + } + ], + "quickLinks": [ + { + "id": 1, + "name": "用户管理", + "path": "/users" + }, + { + "id": 2, + "name": "订单管理", + "path": "/orders" + } + ], + "modules": [ + { + "value": "user", + "label": "用户" + }, + { + "value": "order", + "label": "订单" + }, + { + "value": "report", + "label": "报表" + } + ], + "categories": [ + { + "text": "全部", + "value": "all" + }, + { + "text": "常用", + "value": "fav" + }, + { + "text": "最近", + "value": "recent" + } + ], + "todoCols": [ + { + "field": "title", + "title": "标题" + }, + { + "field": "deadline", + "title": "截止时间" + } + ], + "todos": [ + { + "id": 1, + "title": "修复登录问题", + "deadline": "2025-09-30" + }, + { + "id": 2, + "title": "升级依赖", + "deadline": "2025-10-10" + } + ] + }, + "methods": { + "onSearch": { + "type": "JSFunction", + "value": "function onSearch() {\n console.log('search with', state.filters, state.category)\n}" + }, + "onReset": { + "type": "JSFunction", + "value": "function onReset() {\n state.filters.keyword = ''\n state.filters.module = ''\n state.category = 'all'\n}" + }, + "go": { + "type": "JSFunction", + "value": "function go(item) {\n console.log('go to', item.path)\n}" + } + }, + "computed": {}, + "lifeCycles": {}, + "css": ".dashboard-page {\n padding: 16px;\n}\n\n.panel {\n padding: 12px;\n border: 1px solid #eee;\n border-radius: 4px;\n background: #fff;\n}\n\n.panel-title {\n margin: 0 0 8px;\n font-size: 16px;\n font-weight: 600;\n}\n\n.quick-links {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 12px;\n}\n\n.quick-link-tile {\n height: 80px;\n border: 1px solid #e5e7eb;\n border-radius: 8px;\n background: #fafafa;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n transition: all 0.2s ease;\n}\n\n.quick-link-tile:hover {\n background: #f0f6ff;\n border-color: #cfe0ff;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n}\n\n.quick-link-text {\n color: #1f2329;\n font-size: 14px;\n font-weight: 500;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "dashboard-page" + }, + "children": [ + { + "componentName": "TinyTimeLine", + "props": { + "style": "margin-bottom: 12px", + "horizontal": true, + "active": { + "type": "JSExpression", + "value": "this.state.activeStep" + }, + "data": { + "type": "JSExpression", + "value": "this.state.steps" + } + }, + "children": [], + "id": "f59raon9" + }, + { + "componentName": "div", + "props": { + "className": "panel", + "style": "margin-bottom: 12px" + }, + "children": [ + { + "componentName": "h3", + "props": { + "className": "panel-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "筛选" + }, + "id": "kkgfrui1" + } + ], + "id": "dwwurpcj" + }, + { + "componentName": "TinyForm", + "props": { + "label-width": "80px", + "label-position": "left", + "inline": true + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "关键词" + }, + "children": [ + { + "componentName": "TinySearch", + "props": { + "placeholder": "请输入关键词", + "modelValue": { + "type": "JSExpression", + "value": "this.state.filters.keyword", + "model": true + } + }, + "children": [], + "id": "5m0pkah7" + } + ], + "id": "y3goleeb" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "模块" + }, + "children": [ + { + "componentName": "TinySelect", + "props": { + "placeholder": "请选择模块", + "modelValue": { + "type": "JSExpression", + "value": "this.state.filters.module", + "model": true + }, + "options": { + "type": "JSExpression", + "value": "this.state.modules" + } + }, + "children": [], + "id": "g7sbqnft" + } + ], + "id": "caz07g6b" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "分类" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": { + "type": "JSExpression", + "value": "this.state.category", + "model": true + }, + "data": { + "type": "JSExpression", + "value": "this.state.categories" + } + }, + "children": [], + "id": "lwb0qf2l" + } + ], + "id": "ta8d6xus" + }, + { + "componentName": "TinyFormItem", + "props": {}, + "children": [ + { + "componentName": "TinyButton", + "props": { + "type": "primary", + "onClick": { + "type": "JSExpression", + "value": "this.onSearch" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "搜索" + }, + "id": "fiygyf9b" + } + ], + "id": "acrjkrp2" + }, + { + "componentName": "TinyButton", + "props": { + "style": "margin-left: 8px", + "onClick": { + "type": "JSExpression", + "value": "this.onReset" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "重置" + }, + "id": "w2optwrv" + } + ], + "id": "hkb5xq8q" + } + ], + "id": "no24uuys" + } + ], + "id": "i87ca3b3" + } + ], + "id": "tjkp78uw" + }, + { + "componentName": "TinyRow", + "props": {}, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "panel" + }, + "children": [ + { + "componentName": "h3", + "props": { + "className": "panel-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "快速入口" + }, + "id": "5135tk3c" + } + ], + "id": "rjy5v72m" + }, + { + "componentName": "TinyGrid", + "props": { + "columns": { + "type": "JSExpression", + "value": "this.state.columns" + }, + "data": { + "type": "JSExpression", + "value": "this.state.quickLinks" + }, + "auto-resize": true + }, + "children": [], + "id": "xciifp02" + } + ], + "id": "h27vtj8q" + } + ], + "id": "7565lb42" + }, + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "panel" + }, + "children": [ + { + "componentName": "h3", + "props": { + "className": "panel-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "待办事项" + }, + "id": "2vpoheon" + } + ], + "id": "45q9o8z0" + }, + { + "componentName": "TinyGrid", + "props": { + "columns": { + "type": "JSExpression", + "value": "this.state.todoCols" + }, + "data": { + "type": "JSExpression", + "value": "this.state.todos" + }, + "auto-resize": true + }, + "children": [], + "id": "6o2fqs22" + } + ], + "id": "e1u7pepk" + } + ], + "id": "g32ur0yx" + } + ], + "id": "phzx3kdm" + } + ], + "id": "1z55g7br" + } + ], + "id": "6kkb8knu" +} diff --git a/packages/vue-to-dsl/test/testcases/004_dashboard/input/component.vue b/packages/vue-to-dsl/test/testcases/004_dashboard/input/component.vue new file mode 100644 index 0000000000..e5dd7a0507 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/004_dashboard/input/component.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/packages/vue-to-dsl/test/testcases/005_survey/expected/schema.json b/packages/vue-to-dsl/test/testcases/005_survey/expected/schema.json new file mode 100644 index 0000000000..0dc5e78ee0 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/005_survey/expected/schema.json @@ -0,0 +1,223 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "name": "", + "satisfaction": 3, + "feedback": "", + "recommend": true + }, + "methods": { + "submitSurvey": { + "type": "JSFunction", + "value": "function submitSurvey() {\n console.log('提交满意度调查:', {\n name: this.state.name,\n satisfaction: this.state.satisfaction,\n feedback: this.state.feedback,\n recommend: this.state.recommend\n })\n // 这里可以添加提交到服务器的逻辑\n alert('感谢您的反馈!')\n}" + } + }, + "computed": {}, + "lifeCycles": {}, + "css": ".page-base-style {\n padding: 24px;\n background: #ffffff;\n}\n\n.block-base-style {\n margin: 16px;\n}\n\n.component-base-style {\n margin: 8px;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "page-base-style" + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "100px", + "className": "component-base-style" + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "姓名" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入姓名", + "className": "component-base-style", + "modelValue": { + "type": "JSExpression", + "value": "this.state.name", + "model": true + } + }, + "children": [], + "id": "btf77s93" + } + ], + "id": "m0nep3a0" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "整体满意度" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex; flex-wrap: wrap; gap: 12px" + }, + "children": [ + { + "componentName": "TinyRadio", + "props": { + "text": "非常满意", + "label": 5, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "r0o027ty" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "满意", + "label": 4, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "moq6l88f" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "一般", + "label": 3, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "hwydklui" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "不满意", + "label": 2, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "hptyv62f" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "非常不满意", + "label": 1, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "mr5ipgmo" + } + ], + "id": "nfer8dbn" + } + ], + "id": "1dpt0uh8" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "具体意见" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "type": "textarea", + "placeholder": "请提出宝贵意见", + "className": "component-base-style", + "modelValue": { + "type": "JSExpression", + "value": "this.state.feedback", + "model": true + } + }, + "children": [], + "id": "c5qczlnv" + } + ], + "id": "94ebcml9" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "是否愿意推荐" + }, + "children": [ + { + "componentName": "TinySwitch", + "props": { + "modelValue": { + "type": "JSExpression", + "value": "this.state.recommend", + "model": true + } + }, + "children": [], + "id": "m2pgtpsd" + } + ], + "id": "6p4xx6mb" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "" + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "text": "提交", + "type": "primary", + "className": "component-base-style", + "onClick": { + "type": "JSExpression", + "value": "this.submitSurvey" + } + }, + "children": [], + "id": "q1isiake" + } + ], + "id": "mx5qz17f" + } + ], + "id": "3ny8gp70" + } + ], + "id": "t58jntyo" + } + ], + "id": "88gufz3b" +} diff --git a/packages/vue-to-dsl/test/testcases/005_survey/input/component.vue b/packages/vue-to-dsl/test/testcases/005_survey/input/component.vue new file mode 100644 index 0000000000..fb3155e636 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/005_survey/input/component.vue @@ -0,0 +1,85 @@ + + + + diff --git a/packages/vue-to-dsl/test/testcases/006_lifecycle/expected/schema.json b/packages/vue-to-dsl/test/testcases/006_lifecycle/expected/schema.json new file mode 100644 index 0000000000..70e7caa872 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/006_lifecycle/expected/schema.json @@ -0,0 +1,64 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": {}, + "methods": {}, + "computed": {}, + "lifeCycles": { + "onMounted": { + "type": "JSFunction", + "value": "function onMounted() {\n console.log('mounted')\n}" + }, + "onUpdated": { + "type": "JSFunction", + "value": "function onUpdated() {\n console.log('updated')\n}" + }, + "onUnmounted": { + "type": "JSFunction", + "value": "function onUnmounted() {\n console.log('unmounted')\n}" + }, + "onBeforeMount": { + "type": "JSFunction", + "value": "function onBeforeMount() {\n console.log('before mount')\n}" + }, + "onBeforeUpdate": { + "type": "JSFunction", + "value": "function onBeforeUpdate() {\n console.log('before update')\n}" + }, + "onBeforeUnmount": { + "type": "JSFunction", + "value": "function onBeforeUnmount() {\n console.log('before unmount')\n}" + }, + "onActivated": { + "type": "JSFunction", + "value": "function onActivated() {\n console.log('activated')\n}" + }, + "onDeactivated": { + "type": "JSFunction", + "value": "function onDeactivated() {\n console.log('deactivated')\n}" + } + }, + "css": ".lifecycle-container {\n color: #333;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "lifecycle-container" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "Lifecycle" + }, + "id": "lnvsla7q" + } + ], + "id": "rrfdkail" + } + ], + "id": "b30pdz9q" +} diff --git a/packages/vue-to-dsl/test/testcases/006_lifecycle/input/component.vue b/packages/vue-to-dsl/test/testcases/006_lifecycle/input/component.vue new file mode 100644 index 0000000000..befbe30bd5 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/006_lifecycle/input/component.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/vue-to-dsl/test/testcases/index.test.js b/packages/vue-to-dsl/test/testcases/index.test.js new file mode 100644 index 0000000000..d44d7369f3 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/index.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { VueToDslConverter } from '../../src/converter' +import fs from 'fs' +import path from 'path' + +describe('VueToDslConverter testcases', () => { + const baseDir = path.resolve(__dirname, '.') + const converter = new VueToDslConverter({ computed_flag: true }) + + const cases = fs.readdirSync(baseDir).filter((name) => /\d+_/.test(name)) + + cases.forEach((caseName) => { + const caseDir = path.join(baseDir, caseName) + const inputFile = path.join(caseDir, 'input', 'component.vue') + const expectFile = path.join(caseDir, 'expected', 'schema.json') + + it(`case ${caseName} should convert correctly`, async () => { + const result = await converter.convertFromFile(inputFile) + const expected = JSON.parse(fs.readFileSync(expectFile, 'utf-8')) + + // 保存到output目录 + const outputFile = path.join(caseDir, 'output', 'schema.json') + fs.mkdirSync(path.dirname(outputFile), { recursive: true }) + fs.writeFileSync(outputFile, JSON.stringify(result.schema, null, 2)) + + // helper: deep clean (remove dynamic keys like meta / id) + const deepClean = (val) => { + if (Array.isArray(val)) { + return val.map((v) => deepClean(v)) + } + if (val && typeof val === 'object') { + const out = {} + Object.keys(val).forEach((k) => { + if (k === 'meta' || k === 'id') return + out[k] = deepClean(val[k]) + }) + return out + } + return val + } + + // helper: normalize line endings by removing carriage returns to avoid Windows/Linux diffs + const normalizeCR = (val) => { + if (Array.isArray(val)) { + return val.map((v) => normalizeCR(v)) + } + if (val && typeof val === 'object') { + const out = {} + Object.keys(val).forEach((k) => { + out[k] = normalizeCR(val[k]) + }) + return out + } + if (typeof val === 'string') { + return val.replace(/\r/g, '') + } + return val + } + + // helper: expect actual to be a superset of expected (subset match) + const expectSubset = (actual, exp) => { + if (Array.isArray(exp)) { + expect(Array.isArray(actual)).toBe(true) + // Only check the first N items where N = exp.length + for (let i = 0; i < exp.length; i++) { + expectSubset(actual[i], exp[i]) + } + return + } + if (exp && typeof exp === 'object') { + expect(actual && typeof actual === 'object').toBe(true) + Object.keys(exp).forEach((k) => { + expectSubset(actual[k], exp[k]) + }) + return + } + // primitives + expect(actual).toEqual(exp) + } + + if (expected.error) { + expect(result.errors.length).toBeGreaterThan(0) + // 允许部分 schema 存在 + expect(result.schema).not.toBeUndefined() + } else { + expect(result.errors).toHaveLength(0) + expect(result.schema).toBeDefined() + const actualClean = deepClean(result.schema) + const expectedClean = deepClean(expected) + // Normalize CR to avoid Windows/Linux line ending diffs + const actualNorm = normalizeCR(actualClean) + const expectedNorm = normalizeCR(expectedClean) + // 进行部分匹配断言(忽略 meta/id 且仅要求包含期望结构) + expectSubset(actualNorm, expectedNorm) + } + }) + }) +}) diff --git a/packages/vue-to-dsl/tsconfig.json b/packages/vue-to-dsl/tsconfig.json new file mode 100644 index 0000000000..ac67ac085c --- /dev/null +++ b/packages/vue-to-dsl/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "emitDeclarationOnly": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "strict": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/vue-to-dsl/vite.config.cli.mjs b/packages/vue-to-dsl/vite.config.cli.mjs new file mode 100644 index 0000000000..fd24027a48 --- /dev/null +++ b/packages/vue-to-dsl/vite.config.cli.mjs @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite' +import path from 'node:path' + +// 专用于打包 CLI 入口 cli.ts,输出为 dist/cli.js(CJS) +export default defineConfig({ + build: { + lib: { + entry: path.resolve(__dirname, 'cli.ts'), + name: 'tiny-vue-to-dsl-cli', + formats: ['cjs'], + fileName: () => 'cli.cjs' + }, + rollupOptions: { + external: [ + 'fs', + 'fs/promises', + 'path', + 'url', + 'os', + 'jszip', + 'vue', + '@vue/compiler-sfc', + '@vue/compiler-dom', + '@babel/parser', + '@babel/traverse', + '@babel/types' + ] + }, + sourcemap: false, + emptyOutDir: false + } +}) diff --git a/packages/vue-to-dsl/vite.config.js b/packages/vue-to-dsl/vite.config.js new file mode 100644 index 0000000000..df1526a237 --- /dev/null +++ b/packages/vue-to-dsl/vite.config.js @@ -0,0 +1,41 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + name: 'TinyEngineVueToDsl', + formats: ['es', 'cjs'], + fileName: (format) => `tiny-engine-vue-to-dsl.${format === 'es' ? 'js' : format}` + }, + rollupOptions: { + external: [ + 'vue', + '@vue/compiler-sfc', + '@vue/compiler-dom', + '@babel/parser', + '@babel/traverse', + '@babel/types', + 'node:fs', + 'node:fs/promises', + 'node:path', + 'node:url' + ], + output: { + globals: { + vue: 'Vue', + '@vue/compiler-sfc': 'VueCompilerSFC', + '@vue/compiler-dom': 'VueCompilerDOM', + '@babel/parser': 'BabelParser', + '@babel/traverse': 'BabelTraverse', + '@babel/types': 'BabelTypes' + } + } + } + }, + resolve: { + alias: { + '@': '/src' + } + } +}) diff --git a/tsconfig.app.json b/tsconfig.app.json index bfaf4cfb34..4d935d1ca8 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -41,6 +41,7 @@ "@opentiny/tiny-engine-toolbar-clean": ["packages/toolbars/clean/index"], "@opentiny/tiny-engine-toolbar-fullscreen": ["packages/toolbars/fullscreen/index"], "@opentiny/tiny-engine-toolbar-generate-code": ["packages/toolbars/generate-code/index"], + "@opentiny/tiny-engine-toolbar-upload": ["packages/toolbars/upload/index"], "@opentiny/tiny-engine-toolbar-lang": ["packages/toolbars/lang/index"], "@opentiny/tiny-engine-toolbar-lock": ["packages/toolbars/lock/index"], "@opentiny/tiny-engine-toolbar-logo": ["packages/toolbars/logo/index"],