From ffc7e21d4a0fdc39d6ba8f5a8f0d4a0422ccf8ba Mon Sep 17 00:00:00 2001 From: puhui999 Date: Wed, 14 May 2025 16:07:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20ele=20infra=20?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E8=AE=BE=E8=AE=A1=E5=99=A8=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-ele/package.json | 2 + apps/web-ele/src/bootstrap.ts | 4 + .../form-create/components/dict-select.vue | 78 +++++ .../form-create/components/use-api-select.tsx | 288 ++++++++++++++++++ .../components/use-images-upload.tsx | 25 ++ .../src/components/form-create/helpers.ts | 182 +++++++++++ .../src/components/form-create/index.ts | 3 + .../src/components/form-create/rules/data.ts | 182 +++++++++++ .../src/components/form-create/rules/index.ts | 6 + .../form-create/rules/use-dict-select.ts | 69 +++++ .../form-create/rules/use-editor-rule.ts | 36 +++ .../form-create/rules/use-select-rule.ts | 45 +++ .../form-create/rules/use-upload-file-rule.ts | 84 +++++ .../rules/use-upload-image-rule.ts | 93 ++++++ .../rules/use-upload-images-rule.ts | 89 ++++++ .../src/components/form-create/typing.ts | 60 ++++ apps/web-ele/src/plugins/form-create/index.ts | 103 +++++++ apps/web-ele/src/views/infra/build/index.vue | 182 +++++++++++ pnpm-lock.yaml | 92 ++++++ 19 files changed, 1623 insertions(+) create mode 100644 apps/web-ele/src/components/form-create/components/dict-select.vue create mode 100644 apps/web-ele/src/components/form-create/components/use-api-select.tsx create mode 100644 apps/web-ele/src/components/form-create/components/use-images-upload.tsx create mode 100644 apps/web-ele/src/components/form-create/helpers.ts create mode 100644 apps/web-ele/src/components/form-create/index.ts create mode 100644 apps/web-ele/src/components/form-create/rules/data.ts create mode 100644 apps/web-ele/src/components/form-create/rules/index.ts create mode 100644 apps/web-ele/src/components/form-create/rules/use-dict-select.ts create mode 100644 apps/web-ele/src/components/form-create/rules/use-editor-rule.ts create mode 100644 apps/web-ele/src/components/form-create/rules/use-select-rule.ts create mode 100644 apps/web-ele/src/components/form-create/rules/use-upload-file-rule.ts create mode 100644 apps/web-ele/src/components/form-create/rules/use-upload-image-rule.ts create mode 100644 apps/web-ele/src/components/form-create/rules/use-upload-images-rule.ts create mode 100644 apps/web-ele/src/components/form-create/typing.ts create mode 100644 apps/web-ele/src/plugins/form-create/index.ts create mode 100644 apps/web-ele/src/views/infra/build/index.vue diff --git a/apps/web-ele/package.json b/apps/web-ele/package.json index ccd69cf0..ac8b9f22 100644 --- a/apps/web-ele/package.json +++ b/apps/web-ele/package.json @@ -26,6 +26,8 @@ "#/*": "./src/*" }, "dependencies": { + "@form-create/designer": "^3.2.6", + "@form-create/element-ui": "^3.2.11", "@tinymce/tinymce-vue": "catalog:", "@vben/access": "workspace:*", "@vben/common-ui": "workspace:*", diff --git a/apps/web-ele/src/bootstrap.ts b/apps/web-ele/src/bootstrap.ts index be054f80..b82949fb 100644 --- a/apps/web-ele/src/bootstrap.ts +++ b/apps/web-ele/src/bootstrap.ts @@ -11,6 +11,7 @@ import { useTitle } from '@vueuse/core'; import { ElLoading } from 'element-plus'; import { $t, setupI18n } from '#/locales'; +import { setupFormCreate } from '#/plugins/form-create'; import { initComponentAdapter } from './adapter/component'; import App from './app.vue'; @@ -58,6 +59,9 @@ async function bootstrap(namespace: string) { const { MotionPlugin } = await import('@vben/plugins/motion'); app.use(MotionPlugin); + // formCreate + setupFormCreate(app); + // 动态更新标题 watchEffect(() => { if (preferences.app.dynamicTitle) { diff --git a/apps/web-ele/src/components/form-create/components/dict-select.vue b/apps/web-ele/src/components/form-create/components/dict-select.vue new file mode 100644 index 00000000..b4e65d06 --- /dev/null +++ b/apps/web-ele/src/components/form-create/components/dict-select.vue @@ -0,0 +1,78 @@ + + + + diff --git a/apps/web-ele/src/components/form-create/components/use-api-select.tsx b/apps/web-ele/src/components/form-create/components/use-api-select.tsx new file mode 100644 index 00000000..6eee727d --- /dev/null +++ b/apps/web-ele/src/components/form-create/components/use-api-select.tsx @@ -0,0 +1,288 @@ +import type { ApiSelectProps } from '#/components/form-create/typing'; + +import { defineComponent, onMounted, ref, useAttrs } from 'vue'; + +import { isEmpty } from '@vben/utils'; + +import { + ElCheckbox, + ElCheckboxGroup, + ElOption, + ElRadio, + ElRadioGroup, + ElSelect, +} from 'element-plus'; + +import { requestClient } from '#/api/request'; + +export const useApiSelect = (option: ApiSelectProps) => { + return defineComponent({ + name: option.name, + props: { + // 选项标签 + labelField: { + type: String, + default: () => option.labelField ?? 'label', + }, + // 选项的值 + valueField: { + type: String, + default: () => option.valueField ?? 'value', + }, + // api 接口 + url: { + type: String, + default: () => option.url ?? '', + }, + // 请求类型 + method: { + type: String, + default: 'GET', + }, + // 选项解析函数 + parseFunc: { + type: String, + default: '', + }, + // 请求参数 + data: { + type: String, + default: '', + }, + // 选择器类型,下拉框 select、多选框 checkbox、单选框 radio + selectType: { + type: String, + default: 'select', + }, + // 是否多选 + multiple: { + type: Boolean, + default: false, + }, + // 是否远程搜索 + remote: { + type: Boolean, + default: false, + }, + // 远程搜索时携带的参数 + remoteField: { + type: String, + default: 'label', + }, + }, + setup(props) { + const attrs = useAttrs(); + const options = ref([]); // 下拉数据 + const loading = ref(false); // 是否正在从远程获取数据 + const queryParam = ref(); // 当前输入的值 + const getOptions = async () => { + options.value = []; + // 接口选择器 + if (isEmpty(props.url)) { + return; + } + + switch (props.method) { + case 'GET': { + let url: string = props.url; + if (props.remote && queryParam.value !== undefined) { + url = url.includes('?') + ? `${url}&${props.remoteField}=${queryParam.value}` + : `${url}?${props.remoteField}=${queryParam.value}`; + } + parseOptions(await requestClient.get(url)); + break; + } + case 'POST': { + const data: any = JSON.parse(props.data); + if (props.remote) { + data[props.remoteField] = queryParam.value; + } + parseOptions(await requestClient.post(props.url, data)); + break; + } + } + }; + + function parseOptions(data: any) { + // 情况一:如果有自定义解析函数优先使用自定义解析 + if (!isEmpty(props.parseFunc)) { + options.value = parseFunc()?.(data); + return; + } + // 情况二:返回的直接是一个列表 + if (Array.isArray(data)) { + parseOptions0(data); + return; + } + // 情况二:返回的是分页数据,尝试读取 list + data = data.list; + if (!!data && Array.isArray(data)) { + parseOptions0(data); + return; + } + // 情况三:不是 yudao-vue-pro 标准返回 + console.warn( + `接口[${props.url}] 返回结果不是 yudao-vue-pro 标准返回建议采用自定义解析函数处理`, + ); + } + + function parseOptions0(data: any[]) { + if (Array.isArray(data)) { + options.value = data.map((item: any) => ({ + label: parseExpression(item, props.labelField), + value: parseExpression(item, props.valueField), + })); + return; + } + console.warn(`接口[${props.url}] 返回结果不是一个数组`); + } + + function parseFunc() { + let parse: any = null; + if (props.parseFunc) { + // 解析字符串函数 + // eslint-disable-next-line no-new-func + parse = new Function(`return ${props.parseFunc}`)(); + } + return parse; + } + + function parseExpression(data: any, template: string) { + // 检测是否使用了表达式 + if (!template.includes('${')) { + return data[template]; + } + // 正则表达式匹配模板字符串中的 ${...} + const pattern = /\$\{([^}]*)\}/g; + // 使用replace函数配合正则表达式和回调函数来进行替换 + return template.replaceAll(pattern, (_, expr) => { + // expr 是匹配到的 ${} 内的表达式(这里是属性名),从 data 中获取对应的值 + const result = data[expr.trim()]; // 去除前后空白,以防用户输入带空格的属性名 + if (!result) { + console.warn( + `接口选择器选项模版[${template}][${expr.trim()}] 解析值失败结果为[${result}], 请检查属性名称是否存在于接口返回值中,存在则忽略此条!!!`, + ); + } + return result; + }); + } + + const remoteMethod = async (query: any) => { + if (!query) { + return; + } + loading.value = true; + try { + queryParam.value = query; + await getOptions(); + } finally { + loading.value = false; + } + }; + + onMounted(async () => { + await getOptions(); + }); + + const buildSelect = () => { + if (props.multiple) { + // fix:多写此步是为了解决 multiple 属性问题 + return ( + + {options.value.map( + (item: { label: any; value: any }, index: any) => ( + + ), + )} + + ); + } + return ( + + {options.value.map( + (item: { label: any; value: any }, index: any) => ( + + ), + )} + + ); + }; + const buildCheckbox = () => { + if (isEmpty(options.value)) { + options.value = [ + { label: '选项1', value: '选项1' }, + { label: '选项2', value: '选项2' }, + ]; + } + return ( + + {options.value.map( + (item: { label: any; value: any }, index: any) => ( + + {item.label} + + ), + )} + + ); + }; + const buildRadio = () => { + if (isEmpty(options.value)) { + options.value = [ + { label: '选项1', value: '选项1' }, + { label: '选项2', value: '选项2' }, + ]; + } + return ( + + {options.value.map( + (item: { label: any; value: any }, index: any) => ( + + {item.label} + + ), + )} + + ); + }; + return () => ( + <> + {(() => { + switch (props.selectType) { + case 'checkbox': { + return buildCheckbox(); + } + case 'radio': { + return buildRadio(); + } + case 'select': { + return buildSelect(); + } + default: { + return buildSelect(); + } + } + })()} + + ); + }, + }); +}; diff --git a/apps/web-ele/src/components/form-create/components/use-images-upload.tsx b/apps/web-ele/src/components/form-create/components/use-images-upload.tsx new file mode 100644 index 00000000..4e821c6c --- /dev/null +++ b/apps/web-ele/src/components/form-create/components/use-images-upload.tsx @@ -0,0 +1,25 @@ +import { defineComponent } from 'vue'; + +import ImageUpload from '#/components/upload/image-upload.vue'; + +export const useImagesUpload = () => { + return defineComponent({ + name: 'ImagesUpload', + props: { + multiple: { + type: Boolean, + default: true, + }, + maxNumber: { + type: Number, + default: 5, + }, + }, + setup() { + // TODO: @dhb52 其实还是靠 props 默认参数起作用,没能从 formCreate 传递 + return (props: { maxNumber?: number; multiple?: boolean }) => ( + + ); + }, + }); +}; diff --git a/apps/web-ele/src/components/form-create/helpers.ts b/apps/web-ele/src/components/form-create/helpers.ts new file mode 100644 index 00000000..c647711c --- /dev/null +++ b/apps/web-ele/src/components/form-create/helpers.ts @@ -0,0 +1,182 @@ +import type { Ref } from 'vue'; + +import type { Menu } from '#/components/form-create/typing'; + +import { nextTick, onMounted } from 'vue'; + +import { apiSelectRule } from '#/components/form-create/rules/data'; + +import { + useDictSelectRule, + useEditorRule, + useSelectRule, + useUploadFileRule, + useUploadImageRule, + useUploadImagesRule, +} from './rules'; + +export function makeRequiredRule() { + return { + type: 'Required', + field: 'formCreate$required', + title: '是否必填', + }; +} + +export const localeProps = ( + t: (msg: string) => any, + prefix: string, + rules: any[], +) => { + return rules.map((rule: { field: string; title: any }) => { + if (rule.field === 'formCreate$required') { + rule.title = t('props.required') || rule.title; + } else if (rule.field && rule.field !== '_optionType') { + rule.title = t(`components.${prefix}.${rule.field}`) || rule.title; + } + return rule; + }); +}; + +/** + * 解析表单组件的 field, title 等字段(递归,如果组件包含子组件) + * + * @param rule 组件的生成规则 https://www.form-create.com/v3/guide/rule + * @param fields 解析后表单组件字段 + * @param parentTitle 如果是子表单,子表单的标题,默认为空 + */ +export const parseFormFields = ( + rule: Record, + fields: Array> = [], + parentTitle: string = '', +) => { + const { type, field, $required, title: tempTitle, children } = rule; + if (field && tempTitle) { + let title = tempTitle; + if (parentTitle) { + title = `${parentTitle}.${tempTitle}`; + } + let required = false; + if ($required) { + required = true; + } + fields.push({ + field, + title, + type, + required, + }); + // TODO 子表单 需要处理子表单字段 + // if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) { + // // 解析子表单的字段 + // rule.props.rule.forEach((item) => { + // parseFields(item, fieldsPermission, title) + // }) + // } + } + if (children && Array.isArray(children)) { + children.forEach((rule) => { + parseFormFields(rule, fields); + }); + } +}; + +/** + * 表单设计器增强 hook + * 新增 + * - 文件上传 + * - 单图上传 + * - 多图上传 + * - 字典选择器 + * - 用户选择器 + * - 部门选择器 + * - 富文本 + */ +export const useFormCreateDesigner = async (designer: Ref) => { + const editorRule = useEditorRule(); + const uploadFileRule = useUploadFileRule(); + const uploadImageRule = useUploadImageRule(); + const uploadImagesRule = useUploadImagesRule(); + + /** + * 构建表单组件 + */ + const buildFormComponents = () => { + // 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代 + designer.value?.removeMenuItem('upload'); + // 移除自带的富文本组件规则,使用 editorRule 替代 + designer.value?.removeMenuItem('fc-editor'); + const components = [ + editorRule, + uploadFileRule, + uploadImageRule, + uploadImagesRule, + ]; + components.forEach((component) => { + // 插入组件规则 + designer.value?.addComponent(component); + // 插入拖拽按钮到 `main` 分类下 + designer.value?.appendMenuItem('main', { + icon: component.icon, + name: component.name, + label: component.label, + }); + }); + }; + + const userSelectRule = useSelectRule({ + name: 'UserSelect', + label: '用户选择器', + icon: 'icon-eye', + }); + const deptSelectRule = useSelectRule({ + name: 'DeptSelect', + label: '部门选择器', + icon: 'icon-tree', + }); + const dictSelectRule = useDictSelectRule(); + const apiSelectRule0 = useSelectRule({ + name: 'ApiSelect', + label: '接口选择器', + icon: 'icon-json', + props: [...apiSelectRule], + event: ['click', 'change', 'visibleChange', 'clear', 'blur', 'focus'], + }); + + /** + * 构建系统字段菜单 + */ + const buildSystemMenu = () => { + // 移除自带的下拉选择器组件,使用 currencySelectRule 替代 + // designer.value?.removeMenuItem('select') + // designer.value?.removeMenuItem('radio') + // designer.value?.removeMenuItem('checkbox') + const components = [ + userSelectRule, + deptSelectRule, + dictSelectRule, + apiSelectRule0, + ]; + const menu: Menu = { + name: 'system', + title: '系统字段', + list: components.map((component) => { + // 插入组件规则 + designer.value?.addComponent(component); + // 插入拖拽按钮到 `system` 分类下 + return { + icon: component.icon, + name: component.name, + label: component.label, + }; + }), + }; + designer.value?.addMenu(menu); + }; + + onMounted(async () => { + await nextTick(); + buildFormComponents(); + buildSystemMenu(); + }); +}; diff --git a/apps/web-ele/src/components/form-create/index.ts b/apps/web-ele/src/components/form-create/index.ts new file mode 100644 index 00000000..b311e79e --- /dev/null +++ b/apps/web-ele/src/components/form-create/index.ts @@ -0,0 +1,3 @@ +export { useApiSelect } from './components/use-api-select'; + +export { useFormCreateDesigner } from './helpers'; diff --git a/apps/web-ele/src/components/form-create/rules/data.ts b/apps/web-ele/src/components/form-create/rules/data.ts new file mode 100644 index 00000000..2c6cee2c --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/data.ts @@ -0,0 +1,182 @@ +/* eslint-disable no-template-curly-in-string */ +const selectRule = [ + { + type: 'select', + field: 'selectType', + title: '选择器类型', + value: 'select', + options: [ + { label: '下拉框', value: 'select' }, + { label: '单选框', value: 'radio' }, + { label: '多选框', value: 'checkbox' }, + ], + // 参考 https://www.form-create.com/v3/guide/control 组件联动,单选框和多选框不需要多选属性 + control: [ + { + value: 'select', + condition: '==', + method: 'hidden', + rule: [ + 'multiple', + 'clearable', + 'collapseTags', + 'multipleLimit', + 'allowCreate', + 'filterable', + 'noMatchText', + 'remote', + 'remoteMethod', + 'reserveKeyword', + 'defaultFirstOption', + 'automaticDropdown', + ], + }, + ], + }, + { + type: 'switch', + field: 'filterable', + title: '是否可搜索', + }, + { type: 'switch', field: 'multiple', title: '是否多选' }, + { + type: 'switch', + field: 'disabled', + title: '是否禁用', + }, + { type: 'switch', field: 'clearable', title: '是否可以清空选项' }, + { + type: 'switch', + field: 'collapseTags', + title: '多选时是否将选中值按文字的形式展示', + }, + { + type: 'inputNumber', + field: 'multipleLimit', + title: '多选时用户最多可以选择的项目数,为 0 则不限制', + props: { min: 0 }, + }, + { + type: 'input', + field: 'autocomplete', + title: 'autocomplete 属性', + }, + { type: 'input', field: 'placeholder', title: '占位符' }, + { type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' }, + { + type: 'input', + field: 'noMatchText', + title: '搜索条件无匹配时显示的文字', + }, + { type: 'input', field: 'noDataText', title: '选项为空时显示的文字' }, + { + type: 'switch', + field: 'reserveKeyword', + title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词', + }, + { + type: 'switch', + field: 'defaultFirstOption', + title: '在输入框按下回车,选择第一个匹配项', + }, + { + type: 'switch', + field: 'popperAppendToBody', + title: '是否将弹出框插入至 body 元素', + value: true, + }, + { + type: 'switch', + field: 'automaticDropdown', + title: '对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单', + }, +]; + +const apiSelectRule = [ + { + type: 'input', + field: 'url', + title: 'url 地址', + props: { + placeholder: '/system/user/simple-list', + }, + }, + { + type: 'select', + field: 'method', + title: '请求类型', + value: 'GET', + options: [ + { label: 'GET', value: 'GET' }, + { label: 'POST', value: 'POST' }, + ], + control: [ + { + value: 'GET', + condition: '!=', + method: 'hidden', + rule: [ + { + type: 'input', + field: 'data', + title: '请求参数 JSON 格式', + props: { + autosize: true, + type: 'textarea', + placeholder: '{"type": 1}', + }, + }, + ], + }, + ], + }, + { + type: 'input', + field: 'labelField', + title: 'label 属性', + info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}', + props: { + placeholder: 'nickname', + }, + }, + { + type: 'input', + field: 'valueField', + title: 'value 属性', + info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}', + props: { + placeholder: 'id', + }, + }, + { + type: 'input', + field: 'parseFunc', + title: '选项解析函数', + info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表 + (data: any)=>{ label: string; value: any }[]`, + props: { + autosize: true, + rows: { minRows: 2, maxRows: 6 }, + type: 'textarea', + placeholder: ` + function (data) { + console.log(data) + return data.list.map(item=> ({label: item.nickname,value: item.id})) + }`, + }, + }, + { + type: 'switch', + field: 'remote', + info: '是否可搜索', + title: '其中的选项是否从服务器远程加载', + }, + { + type: 'input', + field: 'remoteField', + title: '请求参数', + info: '远程请求时请求携带的参数名称,如:name', + }, +]; + +export { apiSelectRule, selectRule }; diff --git a/apps/web-ele/src/components/form-create/rules/index.ts b/apps/web-ele/src/components/form-create/rules/index.ts new file mode 100644 index 00000000..db306da3 --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/index.ts @@ -0,0 +1,6 @@ +export { useDictSelectRule } from './use-dict-select'; +export { useEditorRule } from './use-editor-rule'; +export { useSelectRule } from './use-select-rule'; +export { useUploadFileRule } from './use-upload-file-rule'; +export { useUploadImageRule } from './use-upload-image-rule'; +export { useUploadImagesRule } from './use-upload-images-rule'; diff --git a/apps/web-ele/src/components/form-create/rules/use-dict-select.ts b/apps/web-ele/src/components/form-create/rules/use-dict-select.ts new file mode 100644 index 00000000..c9c438e8 --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/use-dict-select.ts @@ -0,0 +1,69 @@ +import { onMounted, ref } from 'vue'; + +import { buildUUID, cloneDeep } from '@vben/utils'; + +import * as DictDataApi from '#/api/system/dict/type'; +import { + localeProps, + makeRequiredRule, +} from '#/components/form-create/helpers'; +import { selectRule } from '#/components/form-create/rules/data'; + +/** + * 字典选择器规则,如果规则使用到动态数据则需要单独配置不能使用 useSelectRule + */ +export const useDictSelectRule = () => { + const label = '字典选择器'; + const name = 'DictSelect'; + const rules = cloneDeep(selectRule); + const dictOptions = ref<{ label: string; value: string }[]>([]); // 字典类型下拉数据 + onMounted(async () => { + const data = await DictDataApi.getSimpleDictTypeList(); + if (!data || data.length === 0) { + return; + } + dictOptions.value = + data?.map((item: DictDataApi.SystemDictTypeApi.DictType) => ({ + label: item.name, + value: item.type, + })) ?? []; + }); + return { + icon: 'icon-descriptions', + label, + name, + rule() { + return { + type: name, + field: buildUUID(), + title: label, + info: '', + $required: false, + }; + }, + props(_: any, { t }: any) { + return localeProps(t, `${name}.props`, [ + makeRequiredRule(), + { + type: 'select', + field: 'dictType', + title: '字典类型', + value: '', + options: dictOptions.value, + }, + { + type: 'select', + field: 'valueType', + title: '字典值类型', + value: 'str', + options: [ + { label: '数字', value: 'int' }, + { label: '字符串', value: 'str' }, + { label: '布尔值', value: 'bool' }, + ], + }, + ...rules, + ]); + }, + }; +}; diff --git a/apps/web-ele/src/components/form-create/rules/use-editor-rule.ts b/apps/web-ele/src/components/form-create/rules/use-editor-rule.ts new file mode 100644 index 00000000..556baf0a --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/use-editor-rule.ts @@ -0,0 +1,36 @@ +import { buildUUID } from '@vben/utils'; + +import { + localeProps, + makeRequiredRule, +} from '#/components/form-create/helpers'; + +export const useEditorRule = () => { + const label = '富文本'; + const name = 'Tinymce'; + return { + icon: 'icon-editor', + label, + name, + rule() { + return { + type: name, + field: buildUUID(), + title: label, + info: '', + $required: false, + }; + }, + props(_: any, { t }: any) { + return localeProps(t, `${name}.props`, [ + makeRequiredRule(), + { + type: 'input', + field: 'height', + title: '高度', + }, + { type: 'switch', field: 'readonly', title: '是否只读' }, + ]); + }, + }; +}; diff --git a/apps/web-ele/src/components/form-create/rules/use-select-rule.ts b/apps/web-ele/src/components/form-create/rules/use-select-rule.ts new file mode 100644 index 00000000..fd421378 --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/use-select-rule.ts @@ -0,0 +1,45 @@ +import type { SelectRuleOption } from '#/components/form-create/typing'; + +import { buildUUID, cloneDeep } from '@vben/utils'; + +import { + localeProps, + makeRequiredRule, +} from '#/components/form-create/helpers'; +import { selectRule } from '#/components/form-create/rules/data'; + +/** + * 通用选择器规则 hook + * + * @param option 规则配置 + */ +export const useSelectRule = (option: SelectRuleOption) => { + const label = option.label; + const name = option.name; + const rules = cloneDeep(selectRule); + return { + icon: option.icon, + label, + name, + event: option.event, + rule() { + return { + type: name, + field: buildUUID(), + title: label, + info: '', + $required: false, + }; + }, + props(_: any, { t }: any) { + if (!option.props) { + option.props = []; + } + return localeProps(t, `${name}.props`, [ + makeRequiredRule(), + ...option.props, + ...rules, + ]); + }, + }; +}; diff --git a/apps/web-ele/src/components/form-create/rules/use-upload-file-rule.ts b/apps/web-ele/src/components/form-create/rules/use-upload-file-rule.ts new file mode 100644 index 00000000..55f5bea3 --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/use-upload-file-rule.ts @@ -0,0 +1,84 @@ +import { buildUUID } from '@vben/utils'; + +import { + localeProps, + makeRequiredRule, +} from '#/components/form-create/helpers'; + +export const useUploadFileRule = () => { + const label = '文件上传'; + const name = 'FileUpload'; + return { + icon: 'icon-upload', + label, + name, + rule() { + return { + type: name, + field: buildUUID(), + title: label, + info: '', + $required: false, + }; + }, + props(_: any, { t }: any) { + return localeProps(t, `${name}.props`, [ + makeRequiredRule(), + { + type: 'select', + field: 'fileType', + title: '文件类型', + value: ['doc', 'xls', 'ppt', 'txt', 'pdf'], + options: [ + { label: 'doc', value: 'doc' }, + { label: 'xls', value: 'xls' }, + { label: 'ppt', value: 'ppt' }, + { label: 'txt', value: 'txt' }, + { label: 'pdf', value: 'pdf' }, + ], + props: { + multiple: true, + }, + }, + { + type: 'switch', + field: 'autoUpload', + title: '是否在选取文件后立即进行上传', + value: true, + }, + { + type: 'switch', + field: 'drag', + title: '拖拽上传', + value: false, + }, + { + type: 'switch', + field: 'isShowTip', + title: '是否显示提示', + value: true, + }, + { + type: 'inputNumber', + field: 'fileSize', + title: '大小限制(MB)', + value: 5, + props: { min: 0 }, + }, + { + type: 'inputNumber', + field: 'limit', + title: '数量限制', + value: 5, + props: { min: 0 }, + }, + { + type: 'switch', + field: 'disabled', + title: '是否禁用', + value: false, + }, + ]); + }, + }; +}; diff --git a/apps/web-ele/src/components/form-create/rules/use-upload-image-rule.ts b/apps/web-ele/src/components/form-create/rules/use-upload-image-rule.ts new file mode 100644 index 00000000..70760b06 --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/use-upload-image-rule.ts @@ -0,0 +1,93 @@ +import { buildUUID } from '@vben/utils'; + +import { + localeProps, + makeRequiredRule, +} from '#/components/form-create/helpers'; + +export const useUploadImageRule = () => { + const label = '单图上传'; + const name = 'ImageUpload'; + return { + icon: 'icon-image', + label, + name, + rule() { + return { + type: name, + field: buildUUID(), + title: label, + info: '', + $required: false, + }; + }, + props(_: any, { t }: any) { + return localeProps(t, `${name}.props`, [ + makeRequiredRule(), + { + type: 'switch', + field: 'drag', + title: '拖拽上传', + value: false, + }, + { + type: 'select', + field: 'fileType', + title: '图片类型限制', + value: ['image/jpeg', 'image/png', 'image/gif'], + options: [ + { label: 'image/apng', value: 'image/apng' }, + { label: 'image/bmp', value: 'image/bmp' }, + { label: 'image/gif', value: 'image/gif' }, + { label: 'image/jpeg', value: 'image/jpeg' }, + { label: 'image/pjpeg', value: 'image/pjpeg' }, + { label: 'image/svg+xml', value: 'image/svg+xml' }, + { label: 'image/tiff', value: 'image/tiff' }, + { label: 'image/webp', value: 'image/webp' }, + { label: 'image/x-icon', value: 'image/x-icon' }, + ], + props: { + multiple: false, + }, + }, + { + type: 'inputNumber', + field: 'fileSize', + title: '大小限制(MB)', + value: 5, + props: { min: 0 }, + }, + { + type: 'input', + field: 'height', + title: '组件高度', + value: '150px', + }, + { + type: 'input', + field: 'width', + title: '组件宽度', + value: '150px', + }, + { + type: 'input', + field: 'borderradius', + title: '组件边框圆角', + value: '8px', + }, + { + type: 'switch', + field: 'disabled', + title: '是否显示删除按钮', + value: true, + }, + { + type: 'switch', + field: 'showBtnText', + title: '是否显示按钮文字', + value: true, + }, + ]); + }, + }; +}; diff --git a/apps/web-ele/src/components/form-create/rules/use-upload-images-rule.ts b/apps/web-ele/src/components/form-create/rules/use-upload-images-rule.ts new file mode 100644 index 00000000..c18a7b49 --- /dev/null +++ b/apps/web-ele/src/components/form-create/rules/use-upload-images-rule.ts @@ -0,0 +1,89 @@ +import { buildUUID } from '@vben/utils'; + +import { + localeProps, + makeRequiredRule, +} from '#/components/form-create/helpers'; + +export const useUploadImagesRule = () => { + const label = '多图上传'; + const name = 'ImagesUpload'; + return { + icon: 'icon-image', + label, + name, + rule() { + return { + type: name, + field: buildUUID(), + title: label, + info: '', + $required: false, + }; + }, + props(_: any, { t }: any) { + return localeProps(t, `${name}.props`, [ + makeRequiredRule(), + { + type: 'switch', + field: 'drag', + title: '拖拽上传', + value: false, + }, + { + type: 'select', + field: 'fileType', + title: '图片类型限制', + value: ['image/jpeg', 'image/png', 'image/gif'], + options: [ + { label: 'image/apng', value: 'image/apng' }, + { label: 'image/bmp', value: 'image/bmp' }, + { label: 'image/gif', value: 'image/gif' }, + { label: 'image/jpeg', value: 'image/jpeg' }, + { label: 'image/pjpeg', value: 'image/pjpeg' }, + { label: 'image/svg+xml', value: 'image/svg+xml' }, + { label: 'image/tiff', value: 'image/tiff' }, + { label: 'image/webp', value: 'image/webp' }, + { label: 'image/x-icon', value: 'image/x-icon' }, + ], + props: { + multiple: true, + maxNumber: 5, + }, + }, + { + type: 'inputNumber', + field: 'fileSize', + title: '大小限制(MB)', + value: 5, + props: { min: 0 }, + }, + { + type: 'inputNumber', + field: 'limit', + title: '数量限制', + value: 5, + props: { min: 0 }, + }, + { + type: 'input', + field: 'height', + title: '组件高度', + value: '150px', + }, + { + type: 'input', + field: 'width', + title: '组件宽度', + value: '150px', + }, + { + type: 'input', + field: 'borderradius', + title: '组件边框圆角', + value: '8px', + }, + ]); + }, + }; +}; diff --git a/apps/web-ele/src/components/form-create/typing.ts b/apps/web-ele/src/components/form-create/typing.ts new file mode 100644 index 00000000..13f98f97 --- /dev/null +++ b/apps/web-ele/src/components/form-create/typing.ts @@ -0,0 +1,60 @@ +import type { Rule } from '@form-create/element-ui'; // 左侧拖拽按钮 + +/** 数据字典 Select 选择器组件 Props 类型 */ +export interface DictSelectProps { + dictType: string; // 字典类型 + valueType?: 'bool' | 'int' | 'str'; // 字典值类型 TODO @芋艿:'boolean' | 'number' | 'string';需要和 vue3 一起统一! + selectType?: 'checkbox' | 'radio' | 'select'; // 选择器类型,下拉框 select、多选框 checkbox、单选框 radio + formCreateInject?: any; +} + +/** 左侧拖拽按钮 */ +export interface MenuItem { + label: string; + name: string; + icon: string; +} + +/** 左侧拖拽按钮分类 */ +export interface Menu { + title: string; + name: string; + list: MenuItem[]; +} + +export type MenuList = Array; + +// TODO @dhb52:MenuList、Menu、MenuItem、DragRule 这几个,是不是没用到呀? +// 拖拽组件的规则 +export interface DragRule { + icon: string; + name: string; + label: string; + children?: string; + inside?: true; + drag?: string | true; + dragBtn?: false; + mask?: false; + + rule(): Rule; + + props(v: any, v1: any): Rule[]; +} + +/** 通用 API 下拉组件 Props 类型 */ +export interface ApiSelectProps { + name: string; // 组件名称 + labelField?: string; // 选项标签 + valueField?: string; // 选项的值 + url?: string; // url 接口 + isDict?: boolean; // 是否字典选择器 +} + +/** 选择组件规则配置类型 */ +export interface SelectRuleOption { + label: string; // label 名称 + name: string; // 组件名称 + icon: string; // 组件图标 + props?: any[]; // 组件规则 + event?: any[]; // 事件配置 +} diff --git a/apps/web-ele/src/plugins/form-create/index.ts b/apps/web-ele/src/plugins/form-create/index.ts new file mode 100644 index 00000000..f4578b4f --- /dev/null +++ b/apps/web-ele/src/plugins/form-create/index.ts @@ -0,0 +1,103 @@ +import type { App, Component } from 'vue'; + +import FcDesigner from '@form-create/designer'; +import formCreate from '@form-create/element-ui'; +import install from '@form-create/element-ui/auto-import'; +// 👇使用 form-create 需额外全局引入 element plus 组件 +import { + ElAlert, + ElAside, + ElBadge, + ElCard, + ElCollapse, + ElCollapseItem, + ElContainer, + ElDivider, + ElDropdown, + ElDropdownItem, + ElDropdownMenu, + ElFooter, + ElHeader, + ElMain, + ElMenu, + ElMenuItem, + ElMessage, + ElPopconfirm, + ElTable, + ElTableColumn, + ElTabPane, + ElTabs, + ElTag, + ElText, + ElTransfer, +} from 'element-plus'; + +// ======================= 自定义组件 ======================= +import { useApiSelect } from '#/components/form-create'; +import DictSelect from '#/components/form-create/components/dict-select.vue'; +import { useImagesUpload } from '#/components/form-create/components/use-images-upload'; +import { Tinymce } from '#/components/tinymce'; +import { FileUpload, ImageUpload } from '#/components/upload'; + +const UserSelect = useApiSelect({ + name: 'UserSelect', + labelField: 'nickname', + valueField: 'id', + url: '/system/user/simple-list', +}); +const DeptSelect = useApiSelect({ + name: 'DeptSelect', + labelField: 'name', + valueField: 'id', + url: '/system/dept/simple-list', +}); +const ApiSelect = useApiSelect({ + name: 'ApiSelect', +}); +const ImagesUpload = useImagesUpload(); + +const components = [ + ImageUpload, + ImagesUpload, + FileUpload, + Tinymce, + DictSelect, + UserSelect, + DeptSelect, + ApiSelect, + ElAlert, + ElTransfer, + ElAside, + ElContainer, + ElDivider, + ElHeader, + ElMain, + ElPopconfirm, + ElTable, + ElTableColumn, + ElTabPane, + ElTabs, + ElDropdown, + ElDropdownMenu, + ElDropdownItem, + ElBadge, + ElTag, + ElText, + ElMenu, + ElMenuItem, + ElFooter, + ElMessage, + ElCollapse, + ElCollapseItem, + ElCard, +]; + +// 参考 http://www.form-create.com/v3/element-ui/auto-import.html 文档 +export const setupFormCreate = (app: App) => { + components.forEach((component) => { + app.component(component.name as string, component as Component); + }); + formCreate.use(install); + app.use(formCreate); + app.use(FcDesigner); +}; diff --git a/apps/web-ele/src/views/infra/build/index.vue b/apps/web-ele/src/views/infra/build/index.vue new file mode 100644 index 00000000..9ed6d1bd --- /dev/null +++ b/apps/web-ele/src/views/infra/build/index.vue @@ -0,0 +1,182 @@ + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d480ff8..d38fc1fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -762,6 +762,12 @@ importers: apps/web-ele: dependencies: + '@form-create/designer': + specifier: ^3.2.6 + version: 3.2.11(vue@3.5.13(typescript@5.8.3)) + '@form-create/element-ui': + specifier: ^3.2.11 + version: 3.2.22(vue@3.5.13(typescript@5.8.3)) '@tinymce/tinymce-vue': specifier: 'catalog:' version: 6.1.0(vue@3.5.13(typescript@5.8.3)) @@ -3630,6 +3636,27 @@ packages: '@form-create/component-antdv-upload@3.2.18': resolution: {integrity: sha512-cobjChcblnfO0ph4MunJDUiBLyRwpzekXo6MFRsB5iq9ln73UjLnyLps4YuM2KRZ/Cn9FEoWN1kYvTFf1zKdjg==} + '@form-create/component-elm-checkbox@3.2.18': + resolution: {integrity: sha512-W8v4o+MZWPEJmIIWojKmnn87tFWpxyTbaIhWJU4Ca0S99YoXR7RdHKLt06HYwJixVLZqytNRj9BMxR4UZQ6JNg==} + + '@form-create/component-elm-frame@3.2.18': + resolution: {integrity: sha512-yob3jmO1xBbKfVfFNeO/xh80o1E2IbVx8NsnpTaaK9X0ARJFvhPvW53qX6TgJxdvzCGTsm/W6P6a4SaebQJtVQ==} + + '@form-create/component-elm-group@3.2.22': + resolution: {integrity: sha512-hy6ZqLpDITqDoTMc4Es2twVNffjXuX0HW7aY36+iyicnJj1Y9hMRj2HPbT4DNlQWZ3ybOb/AlcYN0BwIpW40qw==} + + '@form-create/component-elm-radio@3.2.18': + resolution: {integrity: sha512-kkb6xFOviqgoBRRLzsoZTnqKX9GSw2jaLCWWRPkwqEwA/aLNHRX0MuBdGNvpaaLaD1ph5g+N86GekHvvanbJlQ==} + + '@form-create/component-elm-select@3.2.18': + resolution: {integrity: sha512-gqBzPgNGJ6GwQ/pK/qCuoxQeM/fflNv7IqibETt2IFgutsGVM1lXYic8QJ/51YkuI0afkDKF+wAEbfB6zqaKIA==} + + '@form-create/component-elm-tree@3.2.18': + resolution: {integrity: sha512-s+0+NPh2t500pv4CA51dtwuWWlY2wW0qbL7ZE3steTBh8Z++7s+n/6y6joGPxTeP+7FkpfruA1TfJ7+5Ntpe1Q==} + + '@form-create/component-elm-upload@3.2.18': + resolution: {integrity: sha512-FVFJYarlk5+/Kjg9kJ9ElwyP8bt+DR0m/pPUMqmkEoUtv0Sr6nkk596THLjfyXV1WRFeoZMjzIS6qCyTnqWksQ==} + '@form-create/component-subform@3.1.34': resolution: {integrity: sha512-OJcFH/7MTHx7JLEjDK/weS27qfuFWAI+OK+gXTJ2jIt9aZkGWF/EWkjetiJLt5a0KMw4Z15wOS2XCY9pVK9vlA==} @@ -3641,6 +3668,16 @@ packages: peerDependencies: vue: ^3.5.13 + '@form-create/designer@3.2.11': + resolution: {integrity: sha512-5mPyeHFOj8n01LOVhibjX8OujD6RYBH8TF2Ol7n8QxaSqIcAFTz9PADIiX982REPxiZ6I8BqZa2t0OtYQtETpA==} + peerDependencies: + vue: ^3.5.13 + + '@form-create/element-ui@3.2.22': + resolution: {integrity: sha512-6UfJloHWwCDkei4dQjigk5JzaFQiwEISpY0Tc5plSyJg8bt7JdqCp6C9+OQYmjTYaurwzdTvgD9NfbKDFC8xEQ==} + peerDependencies: + vue: ^3.5.13 + '@form-create/utils@3.2.18': resolution: {integrity: sha512-C98bFPdFVMltiHQvEZqv4rVdhcqthJgvxMbWDlniL03HS5oyusnUvxUE8jf0I9zk5dZRDGmxKOUtzE3JDWP9nQ==} @@ -13298,6 +13335,34 @@ snapshots: dependencies: '@form-create/utils': 3.2.18 + '@form-create/component-elm-checkbox@3.2.18': + dependencies: + '@form-create/utils': 3.2.18 + + '@form-create/component-elm-frame@3.2.18': + dependencies: + '@form-create/utils': 3.2.18 + + '@form-create/component-elm-group@3.2.22': + dependencies: + '@form-create/utils': 3.2.18 + + '@form-create/component-elm-radio@3.2.18': + dependencies: + '@form-create/utils': 3.2.18 + + '@form-create/component-elm-select@3.2.18': + dependencies: + '@form-create/utils': 3.2.18 + + '@form-create/component-elm-tree@3.2.18': + dependencies: + '@form-create/utils': 3.2.18 + + '@form-create/component-elm-upload@3.2.18': + dependencies: + '@form-create/utils': 3.2.18 + '@form-create/component-subform@3.1.34': {} '@form-create/component-wangeditor@3.2.14': @@ -13309,6 +13374,33 @@ snapshots: '@form-create/utils': 3.2.18 vue: 3.5.13(typescript@5.8.3) + '@form-create/designer@3.2.11(vue@3.5.13(typescript@5.8.3))': + dependencies: + '@form-create/component-wangeditor': 3.2.14 + '@form-create/element-ui': 3.2.22(vue@3.5.13(typescript@5.8.3)) + '@form-create/utils': 3.2.18 + codemirror: 6.65.7 + element-plus: 2.9.9(vue@3.5.13(typescript@5.8.3)) + js-beautify: 1.15.4 + vue: 3.5.13(typescript@5.8.3) + vuedraggable: 4.1.0(vue@3.5.13(typescript@5.8.3)) + transitivePeerDependencies: + - '@vue/composition-api' + + '@form-create/element-ui@3.2.22(vue@3.5.13(typescript@5.8.3))': + dependencies: + '@form-create/component-elm-checkbox': 3.2.18 + '@form-create/component-elm-frame': 3.2.18 + '@form-create/component-elm-group': 3.2.22 + '@form-create/component-elm-radio': 3.2.18 + '@form-create/component-elm-select': 3.2.18 + '@form-create/component-elm-tree': 3.2.18 + '@form-create/component-elm-upload': 3.2.18 + '@form-create/component-subform': 3.1.34 + '@form-create/core': 3.2.22(vue@3.5.13(typescript@5.8.3)) + '@form-create/utils': 3.2.18 + vue: 3.5.13(typescript@5.8.3) + '@form-create/utils@3.2.18': {} '@gar/promisify@1.1.3': {}