From b343220d771d74c769a82f5842ca63b93d02c78e Mon Sep 17 00:00:00 2001 From: yaoyun8 <142570291+yaoyun8@users.noreply.github.com> Date: Fri, 29 Dec 2023 16:21:16 +0800 Subject: [PATCH] feat: add materials script (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加拆分物料与构建物料脚本 --- .env.local | 5 + package.json | 11 +- scripts/buildMaterials.mjs | 199 ++++++++++++++++++++++ scripts/connection.mjs | 338 +++++++++++++++++++++++++++++++++++++ scripts/logger.mjs | 43 +++++ scripts/splitMaterials.mjs | 60 +++++++ 6 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 .env.local create mode 100644 scripts/buildMaterials.mjs create mode 100644 scripts/connection.mjs create mode 100644 scripts/logger.mjs create mode 100644 scripts/splitMaterials.mjs diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..a361a78 --- /dev/null +++ b/.env.local @@ -0,0 +1,5 @@ +SQL_HOST=localhost +SQL_PORT=3306 +SQL_USER=root +SQL_PASSWORD=admin +SQL_DATABASE=tiny_engine \ No newline at end of file diff --git a/package.json b/package.json index 365ca8e..328eb7d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "pub:preminor": "pnpm run build:plugin && pnpm run build:alpha && pnpm lerna version preminor --preid beta --no-push --yes && lerna publish from-package --pre-dist-tag beta --yes", "pub:prepatch": "pnpm run build:plugin && pnpm run build:alpha && pnpm lerna version prepatch --preid beta --no-push --yes && lerna publish from-package --pre-dist-tag beta --yes", "pub:prerelease": "pnpm run build:plugin && pnpm run build:alpha && pnpm lerna version prerelease --preid beta --no-push --yes && lerna publish from-package --pre-dist-tag beta --yes", - "setup": "node ./scripts/setup.js" + "setup": "node ./scripts/setup.js", + "splitMaterials": "node ./scripts/splitMaterials.mjs", + "buildMaterials": "node ./scripts/buildMaterials.mjs" }, "devDependencies": { "@babel/eslint-parser": "^7.21.3", @@ -30,16 +32,21 @@ "@vitejs/plugin-vue-jsx": "^1.3.2", "assert": "^2.0.0", "buffer": "^6.0.3", + "chokidar": "^3.5.3", + "concurrently": "^8.2.0", "cross-env": "^7.0.3", + "dotenv": "^16.3.1", "eslint": "^8.38.0", "eslint-plugin-vue": "^8.0.0", + "fast-glob": "^3.3.2", "fs-extra": "^10.1.0", "husky": "^8.0.0", - "concurrently": "^8.2.0", "lerna": "^7.2.0", "less": "^4.1.2", "lint-staged": "^13.2.0", + "mysql": "^2.18.1", "path": "^0.12.7", + "picocolors": "^1.0.0", "rimraf": "^3.0.2", "rollup-plugin-polyfill-node": "^0.12.0", "rollup-plugin-terser": "^7.0.2", diff --git a/scripts/buildMaterials.mjs b/scripts/buildMaterials.mjs new file mode 100644 index 0000000..10db778 --- /dev/null +++ b/scripts/buildMaterials.mjs @@ -0,0 +1,199 @@ +import fsExtra from 'fs-extra' +import path from 'node:path' +import chokidar from 'chokidar' +import fg from 'fast-glob' +import MysqlConnection from './connection.mjs' +import Logger from './logger.mjs' + +const logger = new Logger('buildMaterials') +// 物料文件存放文件夹名称 +const materialsDir = 'materials' +// 物料资产包 +const bundlePath = path.join(process.cwd(), '/packages/design-core/public/mock/bundle.json') +// mockServer应用数据 +const appInfoPath = path.join(process.cwd(), '/mockServer/src/services/appinfo.json') +const appInfo = fsExtra.readJSONSync(appInfoPath) +const bundle = { + data: { + framework: 'Vue', + materials: { + components: [], + blocks: [], + snippets: [] + } + } +} + +const connection = new MysqlConnection() + +/** + * 更新物料资产包和应用mock数据 + */ +const write = () => { + fsExtra.outputJSONSync(bundlePath, bundle, { spaces: 2 }) + fsExtra.outputJSONSync(appInfoPath, appInfo, { spaces: 2 }) +} + +/** + * 校验组件文件数据 + * @param {string} file 组件文件路径 + * @param {object} component 组件数据 + * @returns + */ +const validateComponent = (file, component) => { + const requiredFields = ['component'] + const fields = Object.keys(component) + const requiredList = requiredFields.filter((field) => !fields.includes(field)) + + if (requiredList.length) { + logger.error(`组件文件 ${file} 缺少必要字段:${requiredList.join('、')}。`) + + return false + } + + if (!component.npm) { + logger.warn(`组件文件 ${file} 缺少 npm 字段,出码时将不能通过import语句导入组件。`) + + return false + } + + return true +} + +/** + * 校验区块文件数据 + * @param {string} file 区块文件路径 + * @param {object} block 区块数据 + * @returns + */ +const validateBlock = (file, block) => { + const requiredFields = ['label', 'assets'] + const fields = Object.keys(block) + const requiredList = requiredFields.filter((field) => !fields.includes(field)) + + if (requiredList.length) { + logger.error(`区块文件 ${file} 缺少必要字段:${requiredList.join('、')}。`) + + return false + } + + return true +} + +/** + * 读取materials目录下的json文件,执行下列操作 + * 1. 合并生成物料资产包 + * 2. 更新应用的组件数据componentsMap + * 3. 连接上数据库后,将组件数据写入数据库(新增或更新) + */ +const generateComponents = () => { + try { + fg([`${materialsDir}/**/*.json`]).then((files) => { + if(!files.length) { + logger.warn('物料文件夹为空,请先执行`pnpm splitMaterials`命令拆分物料资产包') + } + + const { components = [], snippets = [], blocks = [] } = bundle.data.materials + const componentsMap = [] + const appInfoBlocksLabels = appInfo.blockHistories.map((item) => item.label) + + files.forEach((file) => { + const material = fsExtra.readJsonSync(file, { throws: false }) + + if (!material) { + logger.error(`读取物料文件 ${file} 失败`) + + return + } + + if (file.includes('/blocks/')) { + const valid = validateBlock(file, material) + + if (!valid) return + + blocks.push(material) + + if (!appInfoBlocksLabels.includes(material.label)) { + appInfo.blockHistories.push(material) + } + + return + } + + const valid = validateComponent(file, material) + + if (!valid) return + + const { snippets: componentSnippets, category, ...componentInfo } = material + + components.push(componentInfo) + + const snippet = snippets.find((item) => item.group === category) + + if (snippet) { + componentSnippets && snippet.children.push(componentSnippets[0]) + } else if (category && componentInfo) { + snippets.push({ + group: category, + children: componentSnippets || [] + }) + } + + const { component, npm = {} } = componentInfo + + componentsMap.push({ component, npm }) + + if (connection.connected) { + connection.initDB(material) + } + }) + + appInfo.materialHistory.components = componentsMap + + write() + }) + + logger.success('构建物料资产包成功') + } catch (error) { + logger.error(`构建物料资产包失败:${error}`) + } +} + +// 监听materials下json文件的变化 +const watcher = chokidar.watch(`${materialsDir}/**/*.json`, { ignoreInitial: true }) + +watcher.on('all', (event, file) => { + const eventMap = { + add: '新增', + change: '更新', + unlink: '删除' + } + + logger.info(`${eventMap[event]}组件文件 ${file}`) + + // 监听物料文件变化,更新物料资产包 + generateComponents() + + if (!connection.connected || event === 'unlink') return + + const component = fsExtra.readJsonSync(path.join(process.cwd(), file)) + + if (event === 'change') { + connection.updateComponent(component) + } else if (event === 'add') { + connection.insertComponent(component) + } +}) + +// 连接数据库 +connection + .connect() + .then(() => { + connection.initUserComponentsTable().finally(() => { + generateComponents() + }) + }) + .catch(() => { + // 未能连接数据库也可以执行更新本地mock数据 + generateComponents() + }) diff --git a/scripts/connection.mjs b/scripts/connection.mjs new file mode 100644 index 0000000..e973b52 --- /dev/null +++ b/scripts/connection.mjs @@ -0,0 +1,338 @@ +import mysql from 'mysql' +import Logger from './logger.mjs' +import fs from 'node:fs' +import path from 'node:path' +import dotenv from 'dotenv' + +const logger = new Logger('buildMaterials') + +// 先构造出.env*文件的绝对路径 +const appDirectory = fs.realpathSync(process.cwd()) +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath) +const pathsDotenv = resolveApp('.env') +// 加载.env.local +dotenv.config({ path: `${pathsDotenv}.local` }) +const { SQL_HOST, SQL_PORT, SQL_USER, SQL_PASSWORD, SQL_DATABASE } = process.env + +// 组件表名称 +const componentsTableName = 'user_components' +// 组件关联到物料资产包的id +const materialHistoryId = 639 +// 数据库配置 +const mysqlConfig = { + host: SQL_HOST, // 主机名(服务器地址) + port: SQL_PORT, // 端口号 + user: SQL_USER, // 用户名 + password: SQL_PASSWORD, // 密码 + database: SQL_DATABASE // 数据库名称 +} +class MysqlConnection { + constructor(config) { + this.config = config || mysqlConfig + // 是否连接上了数据库 + this.connected = false + this.connection = mysql.createConnection(this.config) + } + + connect() { + return new Promise((resolve, reject) => { + this.connection.connect((error) => { + if (error) { + logger.warn('未能连接到数据库,请查看数据库配置是否正确') + reject() + } else { + logger.success('连接数据库成功') + this.connected = true + resolve() + } + }) + }) + } + + /** + * 执行sql语句,更新数据库 + * @param {string} sql sql语句 + * @param {string} componentName 组件名称 + */ + query(sql) { + return new Promise((resolve, reject) => { + this.connection.query(sql, (error, result) => { + if (error) { + reject(error) + } else { + resolve(result) + } + }) + }) + } + + /** + * 组件字段映射 + * @param {string} field 字段名 + * @returns 映射后的字段名 + */ + fieldTransform(field) { + const fieldMap = { + docUrl: 'doc_url', + devMode: 'dev_mode', + schema: 'schema_fragment' + } + + return fieldMap[field] || field + } + + /** + * 格式化单引号 + * @param {string} str 待格式化的字符串 + * @returns 格式化后的字符串 + */ + formatSingleQuoteValue(str) { + if (typeof str !== 'string') { + return str + } + + return str.replace(/'/g, "\\'") + } + + /** + * 生成更新组件的sql语句 + * @param {object} component 组件数据 + * @returns 更新组件的sql语句 + */ + updateComponent(component) { + const values = [] + let sqlContent = `update ${componentsTableName} set ` + + Object.keys(component).forEach((key) => { + const { [key]: value } = component + const field = this.fieldTransform(key) + let updateContent = '' + + if (['id', 'component'].includes(field)) { + return + } + + if (typeof value === 'string') { + const formatValue = this.formatSingleQuoteValue(value) + + updateContent = `\`${field}\` = '${formatValue}'` + } else if (typeof field === 'number' || field === null) { + updateContent = `\`${field}\` = ${value}` + } else { + const formatValue = this.formatSingleQuoteValue(JSON.stringify(value)) + + updateContent = `\`${field}\` = '${formatValue}'` + } + + values.push(updateContent) + }) + + sqlContent += values.join() + sqlContent += ` where component = '${component.component}';` + + this.query(sqlContent, component.component) + .then(() => { + logger.success(`更新组件 ${component.component} 成功`) + }) + .catch((error) => { + logger.success(`更新组件 ${component.component} 失败:${error}`) + }) + } + + /** + * 新建的组件关联物料资产包 + * @param {number} id 新建的组件id + */ + relationMaterialHistory(id) { + const uniqSql = `SELECT * FROM \`material_histories_components__user_components_mhs\` WHERE \`material-history_id\`=${materialHistoryId} AND \`user-component_id\`=${id}` + this.query(uniqSql).then((result) => { + if (!result.length) { + const sqlContent = `INSERT INTO \`material_histories_components__user_components_mhs\` (\`material-history_id\`, \`user-component_id\`) VALUES (${materialHistoryId}, ${id})` + + this.query(sqlContent) + } + }) + } + + /** + * 生成新增组件的sql语句 + * @param {object} component 组件数据 + * @returns 新增组件的sql语句 + */ + insertComponent(component) { + const { + version, + name, + component: componentName, + icon, + description, + docUrl, + screenshot, + tags, + keywords, + devMode, + npm, + group, + category, + priority = 1, + snippets, + schema, + configure, + public: publicRight = 0, + framework = 'vue', + isOfficial = 0, + isDefault = 0, + tiny_reserved = 0, + tenant = 1, + createBy = 86, + updatedBy = 86 + } = component + const values = `('${version}', + '${this.formatSingleQuoteValue(JSON.stringify(name))}', + '${componentName}', + '${icon}', + '${this.formatSingleQuoteValue(description)}', + '${docUrl}', + '${screenshot}', + '${tags}', + '${keywords}', + '${devMode}', + '${this.formatSingleQuoteValue(JSON.stringify(npm))}', + '${group}', + '${category}', + '${priority}', + '${this.formatSingleQuoteValue(JSON.stringify(snippets))}', + '${this.formatSingleQuoteValue(JSON.stringify(schema))}', + '${this.formatSingleQuoteValue(JSON.stringify(configure))}', + '${publicRight}', + '${framework}', + '${isOfficial}', + '${isDefault}', + '${tiny_reserved}', + '${tenant}', + '${createBy}', + '${updatedBy}' + );` + + const sqlContent = `INSERT INTO ${componentsTableName} (version, name, component, icon, description, doc_url, + screenshot, tags, keywords, dev_mode, npm, \`group\`, \`category\`, priority, snippets, + schema_fragment, configure, \`public\`, framework, isOfficial, isDefault, tiny_reserved, + tenant, createdBy, updatedBy) VALUES ${values}`.replace(/\n/g, '') + + this.query(sqlContent, componentName) + .then((result) => { + const id = result.insertId + + logger.success(`新增组件 ${component.component} 成功`) + this.relationMaterialHistory(id) + }) + .catch((error) => { + logger.success(`新增组件 ${component.component} 失败:${error}`) + }) + } + + /** + * 初始化数据库数据,判断是否已存在组件,不存在时执行新增组件 + * @param {object} component 组件数据 + */ + initDB(component) { + const selectSqlContent = `SELECT * FROM ${this.config.database}.${componentsTableName} WHERE component = '${component.component}'` + + this.query(selectSqlContent) + .then((result) => { + if (!result.length) { + this.insertComponent(component) + } + }) + .catch((error) => { + logger.success(`查询组件 ${component.component} 失败:${error}`) + }) + } + + /** + * 创建组件表 + * @returns promise + */ + createUserComponentsTable() { + const sqlContent = ` + CREATE TABLE ${componentsTableName} ( + id int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + version varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, + name longtext CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, + component varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, + icon varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + description varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + doc_url varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + screenshot varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + tags varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + keywords varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + dev_mode varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, + npm longtext CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, + \`group\` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + category varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, + priority int(11) NULL DEFAULT NULL, + snippets longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL, + schema_fragment longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL, + configure longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL, + createdBy int(11) NULL DEFAULT NULL, + updatedBy int(11) NULL DEFAULT NULL, + created_by int(11) NULL DEFAULT NULL, + updated_by int(11) NULL DEFAULT NULL, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + public int(11) NULL DEFAULT NULL, + framework varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, + isOfficial tinyint(1) NULL DEFAULT NULL, + isDefault tinyint(1) NULL DEFAULT NULL, + tiny_reserved tinyint(1) NULL DEFAULT NULL, + tenant int(11) NULL DEFAULT NULL, + component_metadata longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL, + library int(11) NULL DEFAULT NULL, + PRIMARY KEY (id) USING BTREE, + UNIQUE INDEX unique_component(createdBy, framework, component, version) USING BTREE + ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC; + `.replace(/\n/g, '') + + return new Promise((resolve, reject) => { + this.query(sqlContent) + .then((result) => { + logger.success(`创建表 ${componentsTableName} 成功`) + resolve(result) + }) + .catch((error) => { + logger.success(`创建表 ${componentsTableName} 失败:${error}`) + reject(error) + }) + }) + } + + /** + * 初始化数据库的组件表 + * @returns promise + */ + initUserComponentsTable() { + return new Promise((resolve, reject) => { + // 查询是否已存在表 + this.query(`SHOW TABLES LIKE '${componentsTableName}'`) + .then((result) => { + if (result.length) { + // 已存在 + resolve() + } else { + this.createUserComponentsTable() + .then(() => { + resolve() + }) + .catch((err) => { + reject(err) + }) + } + }) + .catch((error) => { + reject(error) + }) + }) + } +} + +export default MysqlConnection diff --git a/scripts/logger.mjs b/scripts/logger.mjs new file mode 100644 index 0000000..e7e8657 --- /dev/null +++ b/scripts/logger.mjs @@ -0,0 +1,43 @@ +import colors from 'picocolors' + +class Logger { + constructor(command) { + this.command = command + } + + output(type, msg) { + const format = () => { + const colorMap = { + info: 'cyan', + warn: 'yellow', + error: 'red', + success: 'green' + } + const time = new Date().toLocaleTimeString() + const colorMsg = colors[colorMap[type]](msg) + + return `[${this.command}] [${colors.dim(time)}] ${colorMsg}` + } + + // eslint-disable-next-line no-console + return console.log(format()) + } + + info(msg) { + this.output('info', msg) + } + + warn(msg) { + this.output('warn', msg) + } + + error(msg) { + this.output('error', msg) + } + + success(msg) { + this.output('success', msg) + } +} + +export default Logger diff --git a/scripts/splitMaterials.mjs b/scripts/splitMaterials.mjs new file mode 100644 index 0000000..e81143c --- /dev/null +++ b/scripts/splitMaterials.mjs @@ -0,0 +1,60 @@ +import fs from 'fs-extra' +import path from 'node:path' +import Logger from './logger.mjs' + +const logger = new Logger('splitMaterials') + +// 物料资产包mock数据路径 +const bundlePath = path.join(process.cwd(), '/packages/design-core/public/mock/bundle.json') +// 物料文件存放文件夹名称 +const materialsDir = 'materials' +const bundle = fs.readJSONSync(bundlePath) +const { components, snippets, blocks } = bundle.data.materials + +const capitalize = (str) => `${str.charAt(0).toUpperCase()}${str.slice(1)}` +const toPascalCase = (str) => str.split('-').map(capitalize).join('') + +/** + * 将物料资产包拆分为单个组件 + */ +const splitMaterials = () => { + try { + components.forEach((comp) => { + snippets.some((child) => { + const snippet = child.children.find((item) => { + if (Array.isArray(comp.component)) { + return toPascalCase(comp.component[0]) === toPascalCase(item.snippetName) + } + + return toPascalCase(comp.component) === toPascalCase(item.snippetName) + }) + + if (snippet) { + comp.snippets = [snippet] + comp.category = child.group + + return true + } + + return false + }) + + const fileName = Array.isArray(comp.component) ? comp.component[0] : comp.component + const componentPath = path.join(process.cwd(), materialsDir, 'components', `${toPascalCase(fileName)}.json`) + + fs.outputJsonSync(componentPath, comp, { spaces: 2 }) + }) + + blocks.forEach((block) => { + const blockPath = path.join(process.cwd(), materialsDir, 'blocks', `${block.label}.json`) + + fs.outputJsonSync(blockPath, block, { spaces: 2 }) + }) + + logger.success('拆分物料资产包完成') + } catch (error) { + logger.error(`拆分物料资产包失败: ${error}`) + } +} + +splitMaterials()