22 | git push -f git@github.com:muwoo/kaer-form-render.git master:gh-pages
23 |
24 | cd -
25 |
--------------------------------------------------------------------------------
/docs/docs/component/boolean.md:
--------------------------------------------------------------------------------
1 | ### boolean
2 | `boolean` 类型可以有多种表现形式,比如基础的 `checkbox` 类型,或者 `switch` 这样的开关,下面我们会详细介绍`Vue3 form render`
3 | 所涵盖的一些 `UI` 格式:
4 |
5 | ### checkbox
6 | 通过 `type` 指定 `boolean` UI 交互。默认是 `checkbox`
7 |
8 | ```json
9 | {
10 | "title": "是否通过",
11 | "type": "boolean"
12 | }
13 | ```
14 |
15 |
16 |
17 | ### switch
18 | 通过`"ui:widget"` 可以指定 UI 渲染的表现形式,可以设置 `"ui:widget": "switch"` 来渲染 `switch` 样式
19 | ```json
20 | {
21 | "title": "开关控制",
22 | "type": "boolean",
23 | "ui:widget": "switch"
24 | }
25 | ```
26 |
27 |
--------------------------------------------------------------------------------
/docs/docs/component/number.md:
--------------------------------------------------------------------------------
1 | ### Number
2 | `number` 类型可以有多种表现形式,比如基础的数字输入框`number`类型,或者`slider`这样的滑动条,下面我们会详细介绍`Vue3 form render`
3 | 所涵盖的一些 `UI` 格式:
4 |
5 | ### 基础的 number
6 | 通过 `type` 指定 `number` UI 交互,通过控制 `min` 和 `max` 可以控制数字框的最大或者最小的值。
7 |
8 | ```json
9 | {
10 | "title": "数字输入框",
11 | "description": "1 - 1000",
12 | "type": "number",
13 | "min": 1,
14 | "max": 1000
15 | }
16 | ```
17 |
18 |
19 |
20 | ### slider
21 | 通过`"ui:widget"` 可以指定 UI 渲染的表现形式,可以设置 `"ui:widget": "slider"` 来渲染 `slider` 样式
22 | ```json
23 | {
24 | "title": "带滑动条",
25 | "type": "number",
26 | "ui:widget": "slider"
27 | }
28 | ```
29 |
30 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= htmlWebpackPlugin.options.title %>
9 |
10 |
11 |
12 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/docs/docs/.vuepress/components/list.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/docs/.vuepress/components/obj.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/docs/.vuepress/components/array.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/docs/.vuepress/components/number.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/docs/.vuepress/components/string.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/docs/.vuepress/components/boolean.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/examples/demos/demoIndex.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
15 |
16 |
17 |
18 |
19 |
25 |
36 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin');
3 |
4 |
5 | module.exports = {
6 | // 修改 src 为 examples
7 | publicPath: "./",
8 | outputDir: "docs/docs/.vuepress/public/dist",
9 | pages: {
10 | index: {
11 | entry: 'examples/main.js',
12 | template: 'public/index.html',
13 | filename: 'index.html'
14 | }
15 | },
16 | configureWebpack: {
17 | plugins: [
18 | new AntdDayjsWebpackPlugin({
19 | preset: 'antdv3'
20 | })
21 | ]
22 | },
23 | // 扩展 webpack 配置,使 packages 加入编译
24 | chainWebpack: config => {
25 | config.module
26 | .rule('eslint')
27 | .exclude.add(path.resolve('lib'))
28 | .end()
29 | .exclude.add(path.resolve('examples/docs'))
30 | .end();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/styles/common.less:
--------------------------------------------------------------------------------
1 | .flex {
2 | display: flex;
3 | }
4 |
5 | .align-center {
6 | align-items: center;
7 | }
8 |
9 | .justify-center {
10 | justify-content: center;
11 | }
12 |
13 | .flex1 {
14 | flex: 1
15 | }
16 |
17 | .ml-10 {
18 | margin-left: 10px;
19 | }
20 |
21 | .form-item {
22 | margin-bottom: 20px;
23 | .form-item-title {
24 | margin-bottom: 10px;
25 | }
26 | .handle {
27 | cursor: move;
28 | }
29 | }
30 | .object {
31 | .content {
32 | padding-left: 10px;
33 | }
34 | .title {
35 | border-bottom: 1px solid #ddd;
36 | margin-bottom: 10px;
37 | padding-bottom: 5px;
38 | font-weight: bold;
39 | display: flex;
40 | justify-content: space-between;
41 | }
42 | .upload-excel {
43 | color: #408bf5;
44 | cursor: pointer;
45 | font-weight: normal;
46 | padding: 0 10px;
47 | }
48 | }
49 | .drag-content {
50 | border: 1px solid #ddd;
51 | }
52 |
--------------------------------------------------------------------------------
/examples/demos/richText.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
51 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 木偶
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/examples/docs/boolean.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/docs/demo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 | import router from './router';
4 |
5 | import {
6 | Input,
7 | Select,
8 | DatePicker,
9 | Radio,
10 | Checkbox,
11 | Button,
12 | Card,
13 | Image,
14 | InputNumber,
15 | Slider,
16 | Switch,
17 | Popover,
18 | Upload,
19 | Collapse
20 | } from 'ant-design-vue';
21 | import {
22 | FileImageOutlined,
23 | UploadOutlined,
24 | PlusOutlined,
25 | BarsOutlined,
26 | DeleteOutlined,
27 | DragOutlined,
28 | } from '@ant-design/icons-vue';
29 |
30 | import 'ant-design-vue/dist/antd.css';
31 |
32 | const app = createApp(App);
33 |
34 | app.use(Input);
35 | app.use(Select);
36 | app.use(DatePicker);
37 | app.use(Radio);
38 | app.use(Checkbox);
39 | app.use(Button);
40 | app.use(Card);
41 | app.use(Image);
42 | app.use(InputNumber);
43 | app.use(Slider);
44 | app.use(Switch);
45 | app.use(Popover);
46 | app.use(Upload);
47 | app.use(Collapse);
48 |
49 | app.component('FileImageOutlined', FileImageOutlined)
50 | app.component('UploadOutlined', UploadOutlined)
51 | app.component('PlusOutlined', PlusOutlined)
52 | app.component('BarsOutlined', BarsOutlined)
53 | app.component('DeleteOutlined', DeleteOutlined)
54 | app.component('DragOutlined', DragOutlined)
55 |
56 | app.use(router);
57 | app.mount('#app');
58 |
--------------------------------------------------------------------------------
/examples/docs/number.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/docs/object.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/packages/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import {toRefs, watch, reactive} from 'vue';
3 | import { resolve, clone, getValidateList } from './utils/index';
4 | import {widgets, mapping} from './widgets';
5 |
6 | export default {
7 | props: {
8 | schema: Object,
9 | formData: Object
10 | },
11 |
12 | setup(props, {emit}) {
13 | if (!props.schema) return null;
14 |
15 | const {formData, schema} = toRefs(props)
16 |
17 | let data = resolve(props.schema, formData.value);
18 | emit('on-change', data);
19 | watch(formData,() => {
20 | data = resolve(props.schema, formData.value);
21 | emit('on-validate', getValidateList(data, props.schema));
22 | });
23 |
24 | watch(schema.value,() => {
25 | data = resolve(props.schema, formData.value);
26 | emit('on-change', data);
27 | });
28 |
29 | const handleChange = (key, val) => {
30 | emit('on-change', clone(val));
31 | };
32 | return () => {
33 | const Field = widgets[mapping[`${props.schema.type}${props.schema.format ? `:${props.schema.format}` : ''}`]];
34 | return (
35 |
36 |
43 |
44 | )
45 | }
46 | }
47 | }
--------------------------------------------------------------------------------
/examples/demos/array.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
67 |
--------------------------------------------------------------------------------
/packages/widgets/color.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from "vue";
2 | import '../styles/common.less';
3 |
4 | export default {
5 | props: {
6 | schema: Object,
7 | formData: Object,
8 | name: String,
9 | onChange: Function,
10 | value: [String, Number, Boolean, Object],
11 | disabled: Boolean,
12 | readOnly: Boolean,
13 | invalidText: String
14 | },
15 | setup(props) {
16 | let {
17 | onChange,
18 | name,
19 | value
20 | } = toRefs(props);
21 | const handleChange = (e) => {
22 | onChange.value(name.value, e.target.value);
23 | }
24 | return () => {
25 | return (
26 |
27 |
28 | {props.schema.title}
29 | {props.invalidText && props.invalidText}
32 |
33 |
47 |
48 |
49 | )
50 | }
51 | }
52 | }
--------------------------------------------------------------------------------
/docs/docs/guide/README.md:
--------------------------------------------------------------------------------
1 | # 教程
2 | ## 介绍
3 | 我们在写一些常规后台页面的时候,避免不了是需要经常和表单打交道。所以可以想偷懒的小伙伴可能会考虑有没有办法不去做`表单工程师`?用代码解决重复的人肉工作,没错,我们可以通过 [JSON Schema](https://json-schema.org/understanding-json-schema/) 来描述我们的表单信息,这比重复的写表单控件可方便多了。
4 |
5 | 但是 `JSON Schema` 到表单的映射,则是需要我们去关注的,so... 业界有没有比较好的方案呢?答案是肯定的,比如以下几个表单渲染工具:
6 |
7 | * [Form Render](https://x-render.gitee.io/form-render)
8 | * [Formliy](https://formilyjs.org/#/bdCRC5/dzUZU8il)
9 | * ...
10 |
11 | `Formliy` 是一款比较强大的表单渲染器,目前对 React 支持最友好,Vue 相关的有一个 [vue-formly](https://github.com/formly-js/vue-formly) 但也仅仅是 Vue 2.x 的。还有就是 `Formliy` 过于强大,不仅仅支持 JSON Schema 还支持 JSX Schema 渲染表单。而我们只是单纯需要像 [Form Render](https://x-render.gitee.io/form-render) 这样的 JSON Schema 标准的轻量易用型框架。
12 |
13 | 所以 有了这个 基于 [Vue 3.x 的 Form render](https://github.com/muwoo/vue-form-render)
14 |
15 |
16 | ## 基于 form-render
17 | `vue3.x form render` 是基于 `form render` 的 `vue 3.x` 版本的实现,所以可以有 `form render` 的一切优势,比如极简API:
18 | ```vue
19 |
25 | ```
26 |
27 |
28 | ## 规范协议
29 | 在 `schema` 的设计上,我们也是依赖于国际标准的 `JSON Schema` 规范,并在此基础上添加几条简单约定,满足表单 UI 更丰富表达:
30 |
31 | * JSON schema 是 `vue-form-render` 的 schema 的一个子集,可以无缝接入
32 | * 有别于 JSON schema 的扩展的字段,都用 ui: 开头
33 | * 所有表单元素都有的 ui 属性各给一个独立字段,例如ui:disabled、ui:hidden
34 | * 只有某些表单元素用的到的 ui 属性统一存放在 ui:options,详见[uiSchema]() 配置
35 |
36 | 随着form-render 表单设计器的接入,协议层对于用户已经可看做实现细节,通过表单设计器,大伙可以轻松搭建表单,生成对应 schema
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/packages/widgets/multiCheckbox.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from 'vue';
2 | import '../styles/common.less';
3 |
4 | export default {
5 | props: {
6 | schema: Object,
7 | formData: Object,
8 | name: String,
9 | onChange: Function,
10 | value: [String, Number, Boolean, Object],
11 | disabled: Boolean,
12 | readOnly: Boolean,
13 | invalidText: String
14 | },
15 | setup(props) {
16 | let {
17 | onChange,
18 | name,
19 | value
20 | } = toRefs(props);
21 | const handleChange = (v) => {
22 | onChange.value(name.value, v);
23 | }
24 | return () => {
25 | return (
26 |
27 |
28 | {props.schema.title}
29 | {props.invalidText && props.invalidText}
32 |
33 |
37 | {
38 | props.schema.enum.map((item, index) => (
39 |
43 | {props.schema.enumNames ? props.schema.enumNames[index] || props.schema.enum[index] : props.schema.enum[index]}
44 |
45 | ))
46 | }
47 |
48 |
49 | )
50 | }
51 | }
52 | }
--------------------------------------------------------------------------------
/packages/widgets/boolean.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from "vue";
2 | import '../styles/common.less';
3 |
4 | export default {
5 | props: {
6 | schema: Object,
7 | formData: Object,
8 | name: String,
9 | onChange: Function,
10 | value: [String, Number, Boolean, Object],
11 | disabled: Boolean,
12 | readOnly: Boolean,
13 | invalidText: String
14 | },
15 | setup(props) {
16 | let {
17 | onChange,
18 | name,
19 | value
20 | } = toRefs(props);
21 | const handleChange = (v) => {
22 | onChange.value(name.value, v);
23 | }
24 | return () => {
25 | return (
26 |
27 | {
28 | props.schema["ui:widget"] === 'switch' ? (
29 |
30 |
31 | {props.schema.title}
32 | {props.invalidText && props.invalidText}
35 |
36 |
40 |
41 | ) : (
42 |
handleChange(!value.value)}
45 | >
46 | {props.schema.title}
47 |
48 | )
49 | }
50 |
51 |
52 | )
53 | }
54 | }
55 | }
--------------------------------------------------------------------------------
/packages/widgets/range.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from 'vue';
2 | import moment from 'moment';
3 | import '../styles/common.less';
4 | import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
5 |
6 | export default {
7 | props: {
8 | schema: Object,
9 | formData: Object,
10 | name: String,
11 | onChange: Function,
12 | value: [String, Number, Boolean, Object],
13 | disabled: Boolean,
14 | readOnly: Boolean,
15 | invalidText: String
16 | },
17 | setup(props) {
18 | let {
19 | onChange,
20 | name,
21 | value
22 | } = toRefs(props);
23 | const handleChange = (moment, str) => {
24 | onChange.value(name.value, str);
25 | }
26 | const options = props.schema["ui:options"] || {};
27 |
28 | const getRangeValue = (value, format) => {
29 | if (!value) return [];
30 | const startTime = value[0] ? moment(value[0], format) : '';
31 | const endTime = value[1] ? moment(value[1], format) : '';
32 | return [startTime, endTime];
33 | }
34 |
35 | return () => {
36 | return (
37 |
38 |
39 | {props.schema.title}
40 | {props.invalidText && props.invalidText}
43 |
44 |
50 |
51 | )
52 | }
53 | }
54 | }
--------------------------------------------------------------------------------
/packages/widgets/multiSelect.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from 'vue';
2 | import '../styles/common.less';
3 |
4 | export default {
5 | props: {
6 | schema: Object,
7 | formData: Object,
8 | name: String,
9 | onChange: Function,
10 | value: [String, Number, Boolean, Object],
11 | disabled: Boolean,
12 | readOnly: Boolean,
13 | invalidText: String
14 | },
15 | setup(props) {
16 | let {
17 | onChange,
18 | name,
19 | value
20 | } = toRefs(props);
21 | const handleChange = (v) => {
22 | onChange.value(name.value, v);
23 | }
24 | return () => {
25 | return (
26 |
27 |
28 | {props.schema.title}
29 | {props.invalidText && props.invalidText}
32 |
33 |
40 | {
41 | props.schema.enum.map((item, index) => (
42 |
46 | {props.schema.enumNames ? props.schema.enumNames[index] || props.schema.enum[index] : props.schema.enum[index]}
47 |
48 | ))
49 | }
50 |
51 |
52 | )
53 | }
54 | }
55 | }
--------------------------------------------------------------------------------
/examples/docs/list.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/docs/docs/component/array.md:
--------------------------------------------------------------------------------
1 | ### array
2 |
3 | ### multiSelect
4 | 多选框,可以通过设置`ui:widget` 来指定具体的 UI 交互
5 | ```json
6 | {
7 | "title": "多选",
8 | "description": "下拉多选",
9 | "type": "array",
10 | "items": {
11 | "type": "string"
12 | },
13 | "enum": [
14 | "A",
15 | "B",
16 | "C",
17 | "D"
18 | ],
19 | "enumNames": [
20 | "杭州",
21 | "武汉",
22 | "湖州",
23 | "贵阳"
24 | ],
25 | "ui:widget": "multiSelect"
26 | }
27 | ```
28 |
29 | ```json
30 | {
31 | "title": "多选",
32 | "description": "checkbox",
33 | "type": "array",
34 | "items": {
35 | "type": "string"
36 | },
37 | "enum": [
38 | "A",
39 | "B",
40 | "C",
41 | "D"
42 | ],
43 | "enumNames": [
44 | "杭州",
45 | "武汉",
46 | "湖州",
47 | "贵阳"
48 | ]
49 | }
50 | ```
51 |
52 |
53 |
54 | ### list
55 | 支持 导入 和 导出 `excel` 文件。可以先导出一个模板,再通过修改模板 excel 后上传。对于一些数据量较大的情况是非常有用的操作。
56 | 也支持常规的排序调整
57 | ```json
58 | {
59 | "title": "对象数组",
60 | "description": "对象数组嵌套功能",
61 | "type": "array",
62 | "minItems": 1,
63 | "maxItems": 3,
64 | "items": {
65 | "type": "object",
66 | "properties": {
67 | "input1": {
68 | "title": "简单输入框",
69 | "type": "string",
70 | "ui:hidden": "{{rootValue.selet1 !== 'b'}}"
71 | },
72 | "selet1": {
73 | "title": "单选",
74 | "type": "string",
75 | "enum": [
76 | "a",
77 | "b",
78 | "c"
79 | ],
80 | "enumNames": [
81 | "早",
82 | "中",
83 | "晚"
84 | ]
85 | }
86 | }
87 | }
88 | }
89 | ```
90 |
91 |
--------------------------------------------------------------------------------
/docs/docs/.vuepress/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | title: 'Vue 3.x From Render',
3 | description: 'Just playing around',
4 | base: '/vue-form-render/',
5 | themeConfig: {
6 | logo: 'https://user-images.githubusercontent.com/21073039/104408849-ef6d2280-559f-11eb-93e4-fed00748e4b6.png',
7 | nav: [
8 | { text: '教程', link: '/guide/' },
9 | { text: '示例', link: 'https://muwoo.github.io/vue-form-render/dist/#/' },
10 | ],
11 | sidebar: [
12 | {
13 | title: '教程', // 必要的
14 | path: '/guide/', // 可选的, 标题的跳转链接,应为绝对路径且必须存在
15 | sidebarDepth: 1, // 可选的, 默认值是 1
16 | },
17 | {
18 | title: '开始使用',
19 | path: '/start/',
20 | },
21 | {
22 | title: '相关组件',
23 | children: [
24 | {
25 | title: 'string',
26 | path: '/component/string'
27 | },
28 | {
29 | title: 'number',
30 | path: '/component/number'
31 | },
32 | {
33 | title: 'boolean',
34 | path: '/component/boolean'
35 | },
36 | {
37 | title: 'array',
38 | path: '/component/array'
39 | },
40 | {
41 | title: 'object',
42 | path: '/component/object'
43 | }
44 | ]
45 | },
46 | ],
47 | // 假定是 GitHub. 同时也可以是一个完整的 GitLab URL
48 | repo: 'muwoo/vue-form-render',
49 | // 自定义仓库链接文字。默认从 `themeConfig.repo` 中自动推断为
50 | // "GitHub"/"GitLab"/"Bitbucket" 其中之一,或是 "Source"。
51 | repoLabel: 'Github',
52 | // 默认是 false, 设置为 true 来启用
53 | editLinks: true,
54 | // 默认为 "Edit this page"
55 | editLinkText: '帮助我们改善此页面!'
56 | }
57 | }
--------------------------------------------------------------------------------
/examples/demos/simple.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
76 |
--------------------------------------------------------------------------------
/examples/demos/object.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
78 |
--------------------------------------------------------------------------------
/packages/widgets/number.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from 'vue';
2 | export default {
3 | props: {
4 | schema: Object,
5 | formData: Object,
6 | name: String,
7 | onChange: Function,
8 | value: [String, Number, Boolean, Object],
9 | disabled: Boolean,
10 | readOnly: Boolean,
11 | invalidText: String
12 | },
13 | setup(props) {
14 | let {
15 | schema,
16 | onChange,
17 | name,
18 | value,
19 | disabled,
20 | readOnly,
21 | } = toRefs(props);
22 | const { format = 'text', max, min } = schema.value;
23 | const type = ['image', 'email'].indexOf(format) > -1 ? 'text' : format; // TODO: 这里要是添加新的input类型,注意是一个坑啊,每次不想用html的默认都要补上
24 |
25 | const handleChange = v => {
26 | onChange.value(name.value, v);
27 | };
28 |
29 | const options = props.schema["ui:options"] || {};
30 |
31 | return () => {
32 | return (
33 |
34 |
35 | {props.schema.title}
36 | {props.invalidText && props.invalidText}
39 |
40 | {
41 | props.schema["ui:widget"] === 'slider' ? (
42 |
50 | ) : (
51 |
60 | )
61 | }
62 |
63 |
64 | )
65 | };
66 | }
67 | }
--------------------------------------------------------------------------------
/examples/docs/array.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/packages/utils/validate.js:
--------------------------------------------------------------------------------
1 | import {validate, convertValue} from './index';
2 |
3 | // 值是是否为空
4 | const isEmptyValue = (value, schema) => {
5 | // 多选组件的值为 [] 时,也判断为空值
6 | if (schema.type === 'array' && schema.enum) {
7 | return !value || value.length === 0;
8 | }
9 | // boolean里的false, number里的0, 都不要认为是空值
10 | if (value === 0 || value === false) {
11 | return false;
12 | }
13 | return !value;
14 | };
15 |
16 | const dealTypeValidate = (key, value, schema = {}, _formData) => {
17 | const checkList = [];
18 | const { type, items } = schema;
19 | const obj = {
20 | value,
21 | schema,
22 | };
23 | if (type === 'object') {
24 | const list = getValidateList(value, schema, _formData); // eslint-disable-line
25 | checkList.push(...list);
26 | } else if (type === 'array') {
27 | value.forEach(v => {
28 | const list = dealTypeValidate(key, v, items, _formData);
29 | checkList.push(...list);
30 | });
31 | }
32 | if (validate(obj)) {
33 | checkList.push(key);
34 | }
35 | return checkList;
36 | };
37 |
38 |
39 | export const getValidateList = (val = {}, schema = {}, formData) => {
40 | const _formData = formData || val;
41 | const checkList = [];
42 | const { properties, required } = schema;
43 | // 校验必填(required 属性只在 type:object 下存在)
44 | if (required && required.length > 0) {
45 | required.forEach(key => {
46 | const schema = (properties && properties[key]) || {};
47 | const hidden = schema['ui:hidden'];
48 | const itemValue = val && val[key];
49 | const _hidden = convertValue(hidden, _formData, val);
50 | if (isEmptyValue(itemValue, schema) && !_hidden) {
51 | checkList.push(key);
52 | }
53 | });
54 | }
55 |
56 | if (properties && val && Object.keys(val) && Object.keys(val).length > 0) {
57 | Object.keys(val).forEach(key => {
58 | const value = val[key];
59 | const schema = properties[key] || {};
60 | const list = dealTypeValidate(key, value, schema, _formData);
61 | checkList.push(...list);
62 | });
63 | }
64 |
65 | return checkList;
66 | };
67 |
--------------------------------------------------------------------------------
/packages/utils/utils.js:
--------------------------------------------------------------------------------
1 | export function isUrl(string) {
2 | const protocolRE = /^(?:\w+:)?\/\/(\S+)$/;
3 | // const domainRE = /^[^\s\.]+\.\S{2,}$/;
4 | if (typeof string !== 'string') return false;
5 | return protocolRE.test(string);
6 | }
7 |
8 | export const isEmail = value => {
9 | const regex = '^[.a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)+$';
10 | if (value && new RegExp(regex).test(value)) {
11 | return true;
12 | }
13 | return false;
14 | };
15 |
16 | export function hasRepeat(list) {
17 | return list.find(
18 | (x, i, self) =>
19 | i !== self.findIndex(y => JSON.stringify(x) === JSON.stringify(y))
20 | );
21 | }
22 |
23 | function toKey(value) {
24 | if (typeof value === 'string') {
25 | return value;
26 | }
27 | const result = `${value}`;
28 | return result == '0' && 1 / value == -INFINITY ? '-0' : result; // eslint-disable-line
29 | }
30 |
31 | const reIsDeepProp = /\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/; // eslint-disable-line
32 | const reIsPlainProp = /^\w*$/;
33 |
34 | function isKey(value, object) {
35 | if (Array.isArray(value)) {
36 | return false;
37 | }
38 | const type = typeof value;
39 | if (type === 'number' || type === 'boolean' || value == null) {
40 | return true;
41 | }
42 | return (
43 | reIsPlainProp.test(value) ||
44 | !reIsDeepProp.test(value) ||
45 | (object != null && value in Object(object))
46 | );
47 | }
48 |
49 | function castPath(value, object) {
50 | if (Array.isArray(value)) {
51 | return value;
52 | }
53 | return isKey(value, object) ? [value] : value.match(/([^\.\[\]"']+)/g); // eslint-disable-line
54 | }
55 |
56 | export const isEmptyObject = obj =>
57 | Object.keys(obj).length === 0 && obj.constructor === Object;
58 |
59 |
60 | // getValueByKey(formData, 'a.b.c')
61 | export function baseGet(object, path) {
62 | path = castPath(path, object);
63 |
64 | let index = 0;
65 | const length = path.length;
66 |
67 | while (object != null && index < length) {
68 | object = object[toKey(path[index++])];
69 | }
70 | return index && index == length ? object : undefined;
71 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue3-form-render",
3 | "version": "1.0.3",
4 | "scripts": {
5 | "serve": "vue-cli-service serve",
6 | "build:example": "vue-cli-service build",
7 | "build": "vue-cli-service build --target lib --name vue3-form-render --dest lib packages/index.jsx",
8 | "lint": "vue-cli-service lint"
9 | },
10 | "keywords": [
11 | "FormRender",
12 | "Render",
13 | "Vue 3.x",
14 | "Json Schema",
15 | "Ant Design"
16 | ],
17 | "contributors": [
18 | {
19 | "name": "muwoo",
20 | "email": "2424880409@qq.com"
21 | }
22 | ],
23 | "files": [
24 | "lib"
25 | ],
26 | "main": "lib/vue3-form-render.common.js",
27 | "dependencies": {
28 | "ant-design-vue": "^2.0.0-rc.7",
29 | "core-js": "^3.6.5",
30 | "dayjs": "^1.10.3",
31 | "moment": "^2.29.1",
32 | "validator": "^13.5.2",
33 | "vue": "^3.0.0",
34 | "vue3-form-render-vuedraggable": "^4.0.1-beta.1",
35 | "wangeditor": "^4.6.2",
36 | "xlsx": "^0.16.9"
37 | },
38 | "devDependencies": {
39 | "@ant-design-vue/babel-plugin-jsx": "^1.0.0-rc.1",
40 | "@vue/babel-preset-jsx": "^1.2.4",
41 | "@vue/cli-plugin-babel": "~4.5.0",
42 | "@vue/cli-plugin-eslint": "~4.5.0",
43 | "@vue/cli-service": "~4.5.0",
44 | "@vue/compiler-sfc": "^3.0.0",
45 | "antd-dayjs-webpack-plugin": "^1.0.4",
46 | "babel-eslint": "^10.1.0",
47 | "babel-plugin-import": "^1.13.3",
48 | "eslint": "^6.7.2",
49 | "eslint-plugin-vue": "^7.0.0-0",
50 | "less": "^4.0.0",
51 | "less-loader": "^7.2.1",
52 | "prismjs": "^1.23.0",
53 | "vue-prism-editor": "^2.0.0-alpha.2",
54 | "vue-router": "^4.0.2",
55 | "webpack-bundle-analyzer": "^4.3.0"
56 | },
57 | "eslintConfig": {
58 | "root": true,
59 | "env": {
60 | "node": true
61 | },
62 | "extends": [
63 | "plugin:vue/vue3-essential",
64 | "eslint:recommended"
65 | ],
66 | "parserOptions": {
67 | "parser": "babel-eslint"
68 | },
69 | "rules": {}
70 | },
71 | "browserslist": [
72 | "> 1%",
73 | "last 2 versions",
74 | "not dead"
75 | ]
76 | }
77 |
--------------------------------------------------------------------------------
/packages/widgets/richText.jsx:
--------------------------------------------------------------------------------
1 | import {onMounted, onBeforeUnmount, ref, toRefs} from 'vue';
2 | import WangEditor from 'wangeditor';
3 |
4 | import '../styles/common.less';
5 |
6 | export default {
7 | props: {
8 | schema: Object,
9 | formData: Object,
10 | name: String,
11 | onChange: Function,
12 | value: [String, Number, Boolean, Object],
13 | disabled: Boolean,
14 | readOnly: Boolean,
15 | invalidText: String
16 | },
17 | setup(props) {
18 | let {
19 | onChange,
20 | name,
21 | value,
22 | } = toRefs(props);
23 |
24 | const handleChange = (v) => {
25 | onChange.value(name.value, v);
26 | }
27 |
28 | const editor = ref();
29 |
30 | let instance;
31 | onMounted(() => {
32 | instance = new WangEditor(editor.value);
33 | Object.assign(instance.config, {
34 | onchange(newHtml) {
35 | handleChange(newHtml);
36 | },
37 | });
38 | instance.config.menus = [
39 | 'head',
40 | 'bold',
41 | 'fontSize',
42 | 'fontName',
43 | 'italic',
44 | 'underline',
45 | 'strikeThrough',
46 | 'indent',
47 | 'lineHeight',
48 | 'foreColor',
49 | 'backColor',
50 | 'link',
51 | 'list',
52 | 'todo',
53 | 'justify',
54 | 'quote',
55 | 'image',
56 | 'video',
57 | 'table',
58 | 'code',
59 | 'splitLine',
60 | 'undo',
61 | 'redo',
62 | ]
63 | instance.create();
64 | instance.txt.html(value.value)
65 | });
66 | onBeforeUnmount(() => {
67 | instance.destroy();
68 | instance = null;
69 | });
70 |
71 | return () => {
72 | return (
73 |
74 |
75 | {props.schema.title}
76 | {props.invalidText && props.invalidText}
79 |
80 |
81 |
82 | )
83 | }
84 | }
85 | }
--------------------------------------------------------------------------------
/packages/widgets/date.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from 'vue';
2 | import moment from 'moment';
3 | import '../styles/common.less';
4 | import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
5 |
6 | const DatePicker = (props) => {
7 | return (
8 |
14 | )
15 | }
16 |
17 | const MonthPicker = (props) => {
18 | return (
19 |
25 | )
26 | }
27 |
28 | const WeekPicker = (props) => {
29 | return (
30 |
36 | )
37 | }
38 |
39 | const Map = {
40 | DatePicker,
41 | MonthPicker,
42 | WeekPicker,
43 | }
44 |
45 | export default {
46 | props: {
47 | schema: Object,
48 | formData: Object,
49 | name: String,
50 | onChange: Function,
51 | value: [String, Number, Boolean, Object],
52 | disabled: Boolean,
53 | readOnly: Boolean,
54 | invalidText: String
55 | },
56 | setup(props) {
57 | let {
58 | onChange,
59 | name,
60 | value
61 | } = toRefs(props);
62 | const handleChange = (moment, str) => {
63 | onChange.value(name.value, str);
64 | }
65 | const options = props.schema["ui:options"] || {};
66 | const Picker = Map[options.type || 'DatePicker'];
67 | return () => {
68 |
69 | return (
70 |
71 |
72 | {props.schema.title}
73 | {props.invalidText && props.invalidText}
76 |
77 |
82 |
83 | )
84 | }
85 | }
86 | }
--------------------------------------------------------------------------------
/packages/widgets/image.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from "vue";
2 | import '../styles/common.less';
3 |
4 | export default {
5 | props: {
6 | schema: Object,
7 | formData: Object,
8 | name: String,
9 | onChange: Function,
10 | value: [String, Number, Boolean, Object],
11 | disabled: Boolean,
12 | readOnly: Boolean,
13 | invalidText: String
14 | },
15 | setup(props) {
16 | let {
17 | onChange,
18 | name,
19 | value
20 | } = toRefs(props);
21 | const handleChange = (e) => {
22 | onChange.value(name.value, e.target.value);
23 | }
24 |
25 | const upload = ({file}) => {
26 | try {
27 | const imgSrc = file.response.data[0];
28 | onChange.value(name.value, imgSrc);
29 | } catch (e) {
30 | // ignore
31 | }
32 | }
33 |
34 | return () => {
35 | return (
36 |
37 |
38 | {props.schema.title}
39 | {props.invalidText && props.invalidText}
42 |
43 |
44 | {
45 | props.schema.action &&
55 |
56 |
57 |
58 |
59 | }
60 |
66 |
(
68 |
69 | )
70 | }}>
71 |
74 |
75 |
76 |
77 | )
78 | }
79 | }
80 | }
--------------------------------------------------------------------------------
/examples/demos/multi.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
89 |
--------------------------------------------------------------------------------
/examples/docs/string.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/docs/docs/component/string.md:
--------------------------------------------------------------------------------
1 | ### String
2 | `String` 类型可以有多种表现形式,比如基础的字符串`Input`类型,或者`RichText`这样的富文本格式,下面我们会详细介绍`Vue3 form render`
3 | 所涵盖的一些 `UI` 格式:
4 |
5 | ### Input
6 | ```json
7 | {
8 | "title": "简单输入框",
9 | "type": "string"
10 | }
11 | ```
12 |
13 |
14 |
15 | ### textarea
16 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "textarea"` 后即可展示 `textarea` :
17 |
18 | ```json
19 | {
20 | "title": "简单输入框",
21 | "type": "string",
22 | "format": "textarea"
23 | }
24 | ```
25 |
26 |
27 |
28 | ### url
29 | url 支持链接测试,以及基本的链接格式校验。
30 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "url"` 后即可展示 `url` :
31 |
32 | ```json
33 | {
34 | "title": "URL",
35 | "type": "string",
36 | "format": "url"
37 | }
38 | ```
39 |
40 |
41 |
42 | ### email
43 | email 支持基本的邮箱格式校验,
44 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "email"` 后即可展示 `email` :
45 |
46 | ```json
47 | {
48 | "title": "邮箱",
49 | "type": "string",
50 | "format": "email"
51 | }
52 | ```
53 |
54 |
55 |
56 | ### color
57 | color 支持基本的颜色格式校验,
58 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "color"` 后即可展示 `color` :
59 |
60 | ```json
61 | {
62 | "title": "颜色选择",
63 | "type": "string",
64 | "format": "color"
65 | }
66 | ```
67 |
68 |
69 |
70 | ### datePicker
71 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "date"` 后即可展示 `datePicker` :
72 |
73 | ```json
74 | {
75 | "title": "日期选择",
76 | "type": "string",
77 | "format": "date",
78 | "ui:options": {
79 | "placeholder": "请选择日期",
80 | "show-time": true,
81 | "format": "YYYY/MM/DD HH:mm:ss",
82 | "type": "DatePicker"
83 | }
84 | }
85 | ```
86 |
87 | 如果要自定义`datePicker`的一些其他属性,可以通过`ui:options`选项来设置,详细的属性可以参考[Antd DatePicker](https://2x.antdv.com/components/date-picker-cn#API)
88 |
89 |
90 |
91 |
92 | ### image
93 | `image` 支持上传图片以及图片预览,
94 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "image"` 后即可展示 `image` :
95 |
96 | ```json
97 | {
98 | "title": "图片展示",
99 | "type": "string",
100 | "format": "image"
101 | }
102 | ```
103 |
104 |
105 |
106 | ### select
107 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "select"` 后即可展示 `select` :
108 |
109 |
110 | ```json
111 | {
112 | "title": "单选",
113 | "type": "string",
114 | "enum": [
115 | "a",
116 | "b",
117 | "c"
118 | ],
119 | "enumNames": [
120 | "选项1",
121 | "选项2",
122 | "选项3"
123 | ]
124 | }
125 |
126 | ```
127 |
128 |
129 |
130 | ### richText
131 | 通过指定`format`参数,可以设置 `string` 类型的 UI 表现形式。当设置 `"format": "richText"` 后即可展示 `select` :
132 |
133 | ```json
134 | {
135 | "title": "富文本编辑器",
136 | "type": "string",
137 | "format": "richText"
138 | }
139 | ```
140 |
141 |
--------------------------------------------------------------------------------
/examples/demos/string.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
114 |
--------------------------------------------------------------------------------
/examples/router/index.js:
--------------------------------------------------------------------------------
1 | import Simple from '../demos/simple';
2 | import Object from '../demos/object';
3 | import String from '../demos/string';
4 | import Multi from '../demos/multi';
5 | import All from '../demos/all';
6 | import Array from '../demos/array'
7 | import RichText from '../demos/richText'
8 | import Links from '../demos/links'
9 | import DemoIndex from '../demos/demoIndex';
10 |
11 |
12 | import DocsIndex from '../docs/docsIndex';
13 | import Demo from '../docs/demo';
14 | import DocsString from '../docs/string';
15 | import DocsNumber from '../docs/number';
16 | import DocsBoolean from '../docs/boolean';
17 | import DocsArray from '../docs/array';
18 | import DocsList from '../docs/list';
19 | import DocsObject from '../docs/object';
20 | import { createRouter, createWebHashHistory } from 'vue-router'
21 |
22 | const routes = [
23 | {
24 | path: '/',
25 | name: 'demos',
26 | component: DemoIndex,
27 | children: [
28 | {
29 | path: '',
30 | name: 'simple',
31 | component: Simple
32 | },
33 | {
34 | path: '/all',
35 | name: 'all',
36 | component: All
37 | },
38 | {
39 | path: '/links',
40 | name: 'links',
41 | component: Links
42 | },
43 | {
44 | path: '/object',
45 | name: 'object',
46 | component: Object
47 | },
48 | {
49 | path: '/string',
50 | name: 'string',
51 | component: String
52 | },
53 | {
54 | path: '/multi',
55 | name: 'multi',
56 | component: Multi
57 | },
58 | {
59 | path: '/array',
60 | name: 'array',
61 | component: Array
62 | },
63 | {
64 | path: '/richText',
65 | name: 'richText',
66 | component: RichText
67 | },
68 | {
69 | path: '/object',
70 | name: 'object',
71 | component: DocsObject
72 | },
73 |
74 | {
75 | path: '/:pathMatch(.*)*',
76 | component: Simple
77 | },
78 | ]
79 | },
80 | {
81 | name: 'docs',
82 | path: '/docs',
83 | component: DocsIndex,
84 | children: [
85 | {
86 | path: 'demo',
87 | component: Demo
88 | },
89 | {
90 | path: 'string',
91 | component: DocsString
92 | },
93 | {
94 | path: 'number',
95 | component: DocsNumber
96 | },
97 | {
98 | path: 'boolean',
99 | component: DocsBoolean
100 | },
101 | {
102 | path: 'array',
103 | component: DocsArray
104 | },
105 | {
106 | path: 'list',
107 | component: DocsList
108 | },
109 | {
110 | path: 'object',
111 | component: DocsObject
112 | }
113 | ]
114 | }
115 | ]
116 |
117 | const router = createRouter({
118 | routes, // short for `routes: routes`
119 | history: createWebHashHistory(),
120 | });
121 |
122 | export default router;
123 |
--------------------------------------------------------------------------------
/docs/docs/start/README.md:
--------------------------------------------------------------------------------
1 | # 开始使用
2 |
3 | ## 安装
4 | ```shell
5 | npm i vue3-form-render --save
6 | # or
7 | yarn add vue3-form-render
8 | ```
9 |
10 | 目前仅支持[Ant Design of Vue](https://2x.antdv.com/docs/vue/introduce-cn/) 组件库,由于`vue3 form render`没有内置`antd`所以我们需要手动安装:
11 | ```shell
12 | npm i --save ant-design-vue@next
13 | # or
14 | yarn add ant-design-vue@next
15 | ```
16 |
17 | ## 使用
18 | 引入 `Ant Design of Vue`
19 | ```js
20 | import { createApp } from 'vue'
21 | import App from './App.vue'
22 |
23 | import Antd from 'ant-design-vue';
24 | import 'ant-design-vue/dist/antd.css';
25 |
26 | // import icons
27 |
28 | import {
29 | FileImageOutlined,
30 | UploadOutlined,
31 | PlusOutlined,
32 | BarsOutlined,
33 | DeleteOutlined,
34 | DragOutlined,
35 | } from '@ant-design/icons-vue';
36 |
37 | const app = createApp(App);
38 |
39 | app.component('FileImageOutlined', FileImageOutlined)
40 | app.component('UploadOutlined', UploadOutlined)
41 | app.component('PlusOutlined', PlusOutlined)
42 | app.component('BarsOutlined', BarsOutlined)
43 | app.component('DeleteOutlined', DeleteOutlined)
44 | app.component('DragOutlined', DragOutlined)
45 |
46 | app.use(Antd);
47 | app.mount('#app');
48 | ```
49 |
50 | 然后在需要使用的组件中,即可使用`vue3 form render`:
51 |
52 | ```vue
53 |
54 |
55 |
61 |
62 |
63 |
64 |
113 |
114 | ```
115 |
116 |
117 |
118 | ## API
119 |
120 | ### Props
121 | | 参数 | 说明 | 类型 | 默认值|
122 | | ---- | ---- | ---- | ---- |
123 | | schame | JSON Schema | object | -- |
124 | | formData | 表单的数据 | object | -- |
125 |
126 | ### Events
127 |
128 | | 事件名 | 说明 | 回调函数 |
129 | | ---- | ---- | ---- |
130 | | on-change | 用户触发表单更新的回调函数 | function(value: formData) |
131 | | on-validate | 用户触发表单更新的校验回调函数 | function(value: validates) |
132 |
133 |
--------------------------------------------------------------------------------
/examples/demos/demo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
13 | {{error}}
14 |
15 |
16 |
17 |
24 |
25 |
26 |
27 |
28 |
111 |
112 |
113 |
144 |
--------------------------------------------------------------------------------
/examples/demos/links.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
134 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | vue-form-render
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Base on Vue 3.x, Quickly generates custom form configuration interfaces using JSON Schema.
21 | ## examples
22 |
23 | [form-render live Demo](https://muwoo.github.io/vue-form-render/)
24 |
25 | 
26 |
27 |
28 | ## install
29 | ```shell
30 | npm i vue3-form-render --save
31 | ```
32 | `vue3 form render` depend on [Ant Design of Vue](https://2x.antdv.com/docs/vue/introduce-cn/)
33 | to render from items.so before we use `vue3 form render` we need to install `Ant Design of Vue` and import it to our project:
34 | ```js
35 | import { createApp } from 'vue'
36 | import App from './App.vue'
37 |
38 | import Antd from 'ant-design-vue';
39 | import 'ant-design-vue/dist/antd.css';
40 |
41 | const app = createApp(App);
42 | app.use(Antd);
43 | app.mount('#app');
44 | ```
45 |
46 | ## easy demo
47 |
48 | ```vue
49 |
50 |
51 |
57 |
58 |
59 |
60 |
109 |
110 | ```
111 |
112 | ## Documentation
113 | For extensive documentation see the examples folder or read it on [vue3-form-render](https://muwoo.github.io/vue-form-render)
114 |
115 |
116 | ## API
117 |
118 | ### Props
119 | | Property | Description | Type | Default|
120 | | ---- | ---- | ---- | ---- |
121 | | schame | JSON Schema | object | -- |
122 | | formData | formData | object | -- |
123 |
124 | ### Events
125 |
126 | | Events Name | Description | Arguments |
127 | | ---- | ---- | ---- |
128 | | on-change | Callback function for user to trigger form update | function(value: formData) |
129 | | on-validate | Validation callback function for user to trigger form update | function(value: validates) |
130 |
131 | ## Contribution
132 | If you like this project, you can support it by contributing. If you find a bug, please let me know, applying a pull request is welcome. This project needs your support. You can fix typos, add new examples, or build with me new features.
133 |
134 | Support this project by giving it a Star ⭐
135 |
136 | ## Special thanks
137 |
138 | this Project inspiration from [form-render](https://x-render.gitee.io/form-render/guide/design)
139 | but There is no similar framework for Vue 3.x
140 |
141 |
142 | ## License
143 | This project is licensed under the MIT License - see the [LICENSE.md](https://github.com/muwoo/vue-form-render/blob/master/LICENSE) file for details.
144 |
--------------------------------------------------------------------------------
/packages/widgets/input.jsx:
--------------------------------------------------------------------------------
1 | import {toRefs} from 'vue';
2 | import { isUrl } from '../utils/utils';
3 |
4 | const TestNode = ({ value }) => {
5 | const useUrl = isUrl(value);
6 | if (useUrl) {
7 | return (
8 |
9 | 测试链接
10 |
11 | );
12 | }
13 | return 测试链接
;
14 | };
15 |
16 | const Select = ({value, handleChange, props, type, options}) => {
17 | if (type === 'radio') {
18 | return (
19 | handleChange(e.target.value)} value={value}>
20 | {
21 | props.schema.enum.map((item, index) => (
22 |
26 | {props.schema.enumNames ? props.schema.enumNames[index] || props.schema.enum[index] : props.schema.enum[index]}
27 |
28 | ))
29 | }
30 |
31 | )
32 | }
33 | return (
34 |
39 | {
40 | props.schema.enum.map((item, index) => (
41 |
45 | {props.schema.enumNames ? props.schema.enumNames[index] || props.schema.enum[index] : props.schema.enum[index]}
46 |
47 | ))
48 | }
49 |
50 | )
51 | }
52 |
53 | export default {
54 | props: {
55 | schema: Object,
56 | formData: Object,
57 | name: String,
58 | onChange: Function,
59 | value: [String, Number, Boolean, Object],
60 | disabled: Boolean,
61 | readOnly: Boolean,
62 | invalidText: String
63 | },
64 | setup(props) {
65 | let {
66 | schema,
67 | onChange,
68 | name,
69 | value,
70 | } = toRefs(props);
71 |
72 | const handleChange = v => {
73 | onChange.value(name.value, v);
74 | };
75 |
76 | return () => {
77 | const { format = 'text', maxLength, 'ui:options': options } = schema.value;
78 |
79 | const type = ['image', 'email', 'url'].indexOf(format) > -1 ? 'text' : format; // TODO: 这里要是添加新的input类型,注意是一个坑啊,每次不想用html的默认都要补上
80 |
81 | const _options = { ...options };
82 | delete _options.noTrim;
83 |
84 | let suffix = undefined;
85 | try {
86 | let _value = value.value || '';
87 | if (typeof _value === 'number') {
88 | _value = String(_value);
89 | }
90 | suffix = options.suffix;
91 | if (!suffix && maxLength) {
92 | suffix = (
93 | maxLength
96 | ? { fontSize: 12, color: '#ff4d4f' }
97 | : { fontSize: 12, color: '#999' }
98 | }
99 | >
100 | {_value.length + ' / ' + maxLength}
101 |
102 | );
103 | }
104 | } catch (error) {
105 | // ignore
106 | }
107 | const config = {
108 | ..._options,
109 | maxLength,
110 | suffix,
111 | };
112 |
113 | let addonAfter = _options.addonAfter;
114 | if (format === 'url' && !addonAfter) {
115 | addonAfter =
116 | }
117 | return (
118 |
119 |
120 | {props.schema.title}
121 | {props.invalidText && props.invalidText}
124 |
125 | {
126 | props.schema.enum ? (
127 |
134 | ) : (
135 | type === 'textarea' ? (
136 |
handleChange(e.target.value)}
142 | addonAfter={addonAfter}
143 | />
144 | ) : (
145 | handleChange(e.target.value)}
151 | addonAfter={addonAfter}
152 | />
153 | )
154 |
155 | )
156 | }
157 |
158 | )
159 | };
160 | }
161 | }
--------------------------------------------------------------------------------
/examples/demos/all.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
244 |
--------------------------------------------------------------------------------
/packages/utils/index.js:
--------------------------------------------------------------------------------
1 | import {getValidateList} from './validate';
2 | import isLength from 'validator/lib/isLength';
3 | import {isEmail, isUrl, baseGet, hasRepeat, isEmptyObject} from './utils';
4 |
5 | // 判断schema的值是是否是“函数”
6 | // JSON无法使用函数值的参数,所以使用"{{...}}"来标记为函数,也可使用@标记,不推荐。
7 | function isFunction(func) {
8 | if (typeof func === 'function') {
9 | return true;
10 | }
11 | if (typeof func === 'string' && func.substring(0, 1) === '@') {
12 | return func.substring(1);
13 | }
14 | if (
15 | typeof func === 'string' &&
16 | func.substring(0, 2) === '{{' &&
17 | func.substring(func.length - 2, func.length) === '}}'
18 | ) {
19 | return func.substring(2, func.length - 2);
20 | }
21 | return false;
22 | }
23 |
24 | // 克隆对象
25 | function clone(data) {
26 | try {
27 | return JSON.parse(JSON.stringify(data));
28 | } catch (e) {
29 | return data;
30 | }
31 | }
32 |
33 | // 获取当前字段默认值
34 | function getDefaultValue(schema) {
35 | const { default: def, enum: enums = [], type } = schema;
36 | const defaultValue = {
37 | array: [],
38 | boolean: false,
39 | integer: '',
40 | null: null,
41 | number: '',
42 | object: {},
43 | string: '',
44 | range: null,
45 | };
46 |
47 | if (isFunction(def)) {
48 | return defaultValue[type];
49 | }
50 | if (isFunction(enums)) {
51 | if (type === 'array') {
52 | return [];
53 | }
54 | if (type === 'string' || type === 'number') {
55 | return '';
56 | }
57 | }
58 |
59 | // 如果设置默认值,优先从默认值中获取
60 | if (typeof def !== 'undefined') {
61 | return def;
62 | }
63 | // array且enum的情况,为多选框,默认值[]
64 | if (type === 'array' && enums.length) {
65 | return [];
66 | }
67 | // 如果enum是表达式,不处理
68 | // 如果设置枚举值,其次从枚举值中获取
69 | if (Array.isArray(enums) && enums[0] && typeof enums[0] !== 'undefined') {
70 | if (schema.hasOwnProperty('default')) { // eslint-disable-line
71 | return schema.default; // 就算default: undefined, 也用 undefined, 这样就可以清空了
72 | }
73 | return enums[0];
74 | }
75 | // 最后使用对应基础类型的默认值
76 | return defaultValue[type];
77 | }
78 |
79 | function resolve(schema, data, options = {}) {
80 | const {
81 | // 类型
82 | type,
83 | // 对象子集
84 | properties,
85 | // 数组子集
86 | items,
87 | // 必选值,对象的子集
88 | default: def,
89 | required = [],
90 | 'ui:widget': widget,
91 | } = schema;
92 | const {
93 | // 按照required规则做数据补全
94 | checkRequired = false,
95 | } = options;
96 |
97 | const value =
98 | typeof data === 'undefined' ? getDefaultValue(schema) : clone(data);
99 |
100 | if (type === 'object') {
101 | // 如果自定义组件
102 | if (widget) {
103 | if (def && typeof def === 'object') {
104 | return def;
105 | }
106 | return value;
107 | }
108 | const subs = properties || {};
109 | const ret = {};
110 | Object.keys(subs).forEach(name => {
111 | const checkAndPass =
112 | checkRequired && [].concat(required).indexOf(name) !== -1;
113 | if (!checkRequired || checkAndPass) {
114 | ret[name] = resolve(subs[name], value[name], options);
115 | }
116 | });
117 | return ret;
118 | }
119 | if (type === 'array') {
120 | // 如果没有value且default有值,用default
121 | if (def && Array.isArray(def) && !value) {
122 | return def;
123 | }
124 | // 如果自定义组件
125 | if (widget) return value;
126 |
127 | const subs = [].concat(items || []);
128 | const ret = [];
129 | value.forEach &&
130 | value.forEach((item, idx) => {
131 | ret[idx] = resolve(subs[idx] || subs[0], item, options);
132 | });
133 | return ret;
134 | }
135 | return value;
136 | }
137 |
138 | // 对于数组或对象类型,获取其子集schema
139 | function getSubSchemas(schema = {}) {
140 | const {
141 | // object subset
142 | properties,
143 | // array subset
144 | items,
145 | // as subset's parent
146 | ...$parent
147 | } = schema;
148 | const { type } = $parent;
149 | // no subset
150 | if (!properties && !items) {
151 | return [];
152 | }
153 | let children = {};
154 | if (type === 'object') {
155 | children = properties;
156 | }
157 | if (type === 'array') {
158 | children = [].concat(items);
159 | }
160 | return Object.keys(children).map(name => ({
161 | schema: children[name],
162 | name,
163 | // parent propsSchema
164 | $parent,
165 | }));
166 | }
167 |
168 | const validate = ({name, schema, value, required = []}) => {
169 | const {
170 | type,
171 | 'ui:options': options,
172 | message,
173 | maxLength,
174 | minLength,
175 | format,
176 | pattern,
177 | maximum,
178 | minimum,
179 | maxItems,
180 | minItems,
181 | uniqueItems,
182 | } = schema;
183 | // schema 里面没有内容的,直接退出
184 | if (isEmptyObject(schema)) {
185 | return false;
186 | }
187 | if (required.indexOf(name) >= 0 && (!value || !value.length)) {
188 | return '不能为空'
189 | }
190 | // 正则
191 | const usePattern = pattern && ['string', 'number'].indexOf(type) > -1;
192 | // 字符串
193 | if (type === 'string') {
194 | // TODO: 考虑了下,目前先允许 string 类的填入值是 undefined null 和 数字,校验的时候先转成 string
195 | let _finalValue = value;
196 | if (typeof value !== 'string') {
197 | if (value === null || value === undefined) {
198 | _finalValue = '';
199 | } else {
200 | _finalValue = String(value);
201 | // return '内容不是字符串,请修改'; // 这里可以强制提示,但旧项目有修改成本
202 | }
203 | }
204 |
205 | const noTrim = options && options.noTrim; // 配置项,不需要trim
206 | const trimedValue = _finalValue.trim();
207 | if (trimedValue !== _finalValue && !noTrim) {
208 | return (message && message.trim) || `输入的内容有多余空格`;
209 | }
210 | if (_finalValue && maxLength) {
211 | if (!isLength(_finalValue, 0, parseInt(maxLength, 10))) {
212 | return (message && message.maxLength) || `长度不能大于 ${maxLength}`;
213 | }
214 | }
215 | if (_finalValue && (minLength || minLength === 0)) {
216 | if (
217 | !_finalValue ||
218 | !isLength(_finalValue, parseInt(minLength, 10), undefined)
219 | ) {
220 | return (message && message.minLength) || `长度不能小于 ${minLength}`;
221 | }
222 | }
223 | if (format === 'color') {
224 | if (value === '') return '请填写正确的颜色格式';
225 | }
226 | if (format === 'image') {
227 | const imagePattern =
228 | '([/|.|w|s|-])*.(?:jpg|gif|png|bmp|apng|webp|jpeg|json)';
229 | // image 里也可以填写网络链接
230 | const _isUrl = isUrl(value);
231 | const _isImg = new RegExp(imagePattern).test(value);
232 | if (usePattern) {
233 | // ignore
234 | } else if (value && !_isUrl && !_isImg) {
235 | return (message && message.image) || '请输入正确的图片格式';
236 | }
237 | }
238 |
239 | if (format === 'url') {
240 | if (usePattern) {
241 | // ignore
242 | } else if (value && !isUrl(value)) {
243 | return (message && message.url) || '请输入正确的url格式';
244 | }
245 | }
246 | if (format === 'email') {
247 | if (usePattern) {
248 | // ignore
249 | } else if (value && !isEmail(value)) {
250 | return (message && message.email) || '请输入正确的email格式';
251 | }
252 | }
253 | }
254 |
255 | // 数字相关校验
256 | if (type === 'number') {
257 | if (typeof value !== 'number') {
258 | return '请填写数字';
259 | }
260 | if (maximum && parseFloat(value, 10) > maximum) {
261 | return (message && message.maximum) || `数值不能大于 ${maximum}`;
262 | }
263 | if ((minimum || minimum === 0) && parseFloat(value, 10) < minimum) {
264 | return (message && message.minimum) || `数值不能小于 ${minimum}`;
265 | }
266 | }
267 |
268 | // 正则只对数字和字符串有效果
269 | // value 有值的时候才去算 pattern。从场景反馈还是这样好
270 | if (value && usePattern && !new RegExp(pattern).test(value)) {
271 | return (message && message.pattern) || '格式不匹配';
272 | }
273 |
274 | // 数组项目相关校验
275 | if (type === 'array') {
276 | if (maxItems && value && value.length > maxItems) {
277 | return (message && message.maxItems) || `数组长度不能大于 ${maxItems}`;
278 | }
279 |
280 | if (
281 | (minItems || minItems === 0) &&
282 | value &&
283 | value.length < minItems
284 | ) {
285 | return (message && message.minItems) || `数组长度不能小于 ${minItems}`;
286 | }
287 |
288 | if (uniqueItems && Array.isArray(value) && value.length > 1) {
289 | if (typeof uniqueItems === 'boolean') {
290 | if (hasRepeat(value)) {
291 | return '存在重复元素';
292 | }
293 | }
294 | if (typeof uniqueItems === 'string') {
295 | try {
296 | const nameList = value.map(item => baseGet(item, uniqueItems));
297 | // 只考虑非object的情况
298 | const isRepeat = nameList.find(
299 | (x, index) => nameList.indexOf(x) !== index
300 | );
301 | if (isRepeat) {
302 | return uniqueItems + ' 的值存在重复的';
303 | }
304 | } catch (e) {
305 | // ignore
306 | }
307 | }
308 | }
309 | }
310 | return ''
311 | }
312 |
313 |
314 | function safeEval(code) {
315 | return Function(`"use strict"; ${code}`)();
316 | }
317 |
318 | const evaluateString = (string, formData, rootValue) =>
319 | safeEval(`
320 | const rootValue =${JSON.stringify(rootValue)};
321 | const formData = ${JSON.stringify(formData)};
322 | return (${string})
323 | `);
324 |
325 | const convertValue = (item, formData, rootValue) => {
326 | if (typeof item === 'function') {
327 | return item(formData, rootValue);
328 | } else if (typeof item === 'string' && isFunction(item) !== false) {
329 | const _item = isFunction(item);
330 | try {
331 | return evaluateString(_item, formData, rootValue);
332 | } catch (error) {
333 | console.error(error.message);
334 | console.error(`happen at ${item}`);
335 | return item;
336 | }
337 | }
338 | return item;
339 | };
340 |
341 | export {
342 | resolve,
343 | getSubSchemas,
344 | clone,
345 | getValidateList,
346 | validate,
347 | evaluateString,
348 | convertValue,
349 | };
--------------------------------------------------------------------------------
/packages/widgets/index.jsx:
--------------------------------------------------------------------------------
1 | import Draggable from "vue3-form-render-vuedraggable";
2 | import XLSX from 'xlsx';
3 | import {getSubSchemas, resolve, clone, validate, convertValue} from '../utils';
4 | import input from './input';
5 | import url from './url'
6 | import color from './color';
7 | import date from './date';
8 | import image from './image';
9 | import number from './number';
10 | import boolean from './boolean';
11 | import range from './range';
12 | import multiSelect from './multiSelect'
13 | import multiCheckbox from './multiCheckbox'
14 | import richText from './richText'
15 |
16 | const reader = new FileReader();
17 |
18 | const index = {
19 | props: {
20 | schema: Object,
21 | formData: Object,
22 | value: [String, Number, Boolean, Object],
23 | onChange: Function,
24 | name: String,
25 | },
26 | setup(props) {
27 | return () => {
28 | const childrenSchemas = getSubSchemas(props.schema);
29 |
30 | return (
31 |
32 | {props.schema.title &&
{props.schema.title}
}
33 |
34 | {
35 | Object.keys(props.value).map((name, index) => {
36 | const schema = childrenSchemas[index].schema;
37 | const Field = widgets[mapping[`${schema.type}${schema.format ? `:${schema.format}` : ''}`]];
38 | if (!Field) return null;
39 | if (convertValue(schema['ui:hidden'], props.value[name], props.value)) return null;
40 | const invalidText = validate({
41 | name,
42 | schema,
43 | value: props.value[name],
44 | required: props.schema.required
45 | });
46 | return (
47 | {
53 | const value = {
54 | ...props.value,
55 | [key]: val,
56 | };
57 | props.onChange(props.name, value);
58 | }}
59 | />
60 | )
61 | })
62 | }
63 |
64 |
65 | )
66 | }
67 | }
68 | }
69 |
70 | const array = {
71 | props: {
72 | schema: Object,
73 | formData: Object,
74 | value: [String, Number, Boolean, Object],
75 | onChange: Function,
76 | name: String,
77 | invalidText: String,
78 | },
79 | setup(props) {
80 | const parseExcel = (file) => {
81 | const childrenSchemas = getSubSchemas(props.schema);
82 | reader.readAsArrayBuffer(file);
83 | // 第二步 监听读取完成后的回调
84 | reader.onload = function(e){
85 | const data = e.target.result;
86 | const wb = XLSX.read(data,{
87 | type:'array'
88 | });
89 | // 通过SheetNames[0]得到第一个sheet的名称
90 | const sheet1name = wb.SheetNames[0];
91 | // 取出第一个sheet
92 | const sheet1 = wb.Sheets[sheet1name];
93 | // 调用XLSX.utils.sheet_to_json方法将sheet转化为json;
94 | let value = [];
95 | const originValue = props.value[0] ? clone(props.value)[0] : resolve(childrenSchemas[0].schema);
96 | if (typeof originValue !== "object") {
97 | value = XLSX.utils.sheet_to_json(sheet1, {header:1});
98 | value = value.map(v => v[0]);
99 | } else {
100 | value = XLSX.utils.sheet_to_json(sheet1);
101 | }
102 |
103 | props.onChange(props.name, value);
104 | }
105 |
106 | return false;
107 | }
108 |
109 | const exportExcel = () => {
110 | const childrenSchemas = getSubSchemas(props.schema);
111 | let data = props.value.length ? clone(props.value) : [resolve(childrenSchemas[0].schema)];
112 | if (typeof data[0] !== "object") data = [data]
113 | const ws = XLSX.utils.json_to_sheet(data);
114 | const wb = XLSX.utils.book_new();
115 | XLSX.utils.book_append_sheet(wb, ws);
116 | XLSX.writeFile(wb, `${props.schema.title}.xlsx`);
117 | }
118 |
119 | return () => {
120 | const childrenSchemas = getSubSchemas(props.schema);
121 |
122 | const type = props.schema["ui:widget"];
123 |
124 | if (type === 'multiSelect') {
125 | return (
126 | {
132 | props.onChange(props.name, val);
133 | }}
134 | />
135 | )
136 | }
137 |
138 | if (props.schema.enum) {
139 | return (
140 | {
146 | props.onChange(props.name, val);
147 | }}
148 | />
149 | )
150 | }
151 |
152 | return (
153 |
154 | {
155 | props.schema.title && (
156 |
157 | {props.schema.title}
158 |
{props.invalidText && props.invalidText}
161 |
162 |
168 | 导入 excel
169 |
170 |
下载 excel
171 | {props.schema.maxLength && props.schema.maxLength <= props.value.length ? null :
172 |
{
174 | const value = [
175 | ...props.value,
176 | ];
177 | value.push(value[0] || resolve(childrenSchemas[0].schema));
178 | props.onChange(props.name, value);
179 | }}
180 | />}
181 |
182 |
183 |
184 | )
185 | }
186 |
187 |
{
193 | const value = [
194 | ...clone(v),
195 | ];
196 | props.onChange(props.name, value);
197 | }}
198 | v-slots={{
199 | item: ({index}) => {
200 | const v = props.value[index];
201 |
202 | const schema = childrenSchemas[index]?.schema || childrenSchemas[0].schema;
203 | const Field = widgets[mapping[`${schema.type}${schema.format ? `:${schema.format}` : ''}`]];
204 | if (!Field) return null;
205 | return (
206 |
207 | ,
211 | extra: () => (
212 | props.schema.minLength && props.schema.minLength >= props.value.length ? null :
213 | {
215 | const value = clone(props.value);
216 | value.splice(index, 1);
217 | props.onChange(props.name, value);
218 | }}
219 | />
220 | )
221 | }}
222 | >
223 | {
229 | const value = [
230 | ...props.value,
231 | ];
232 | value[key] = val;
233 | props.onChange(props.name, value);
234 | }}
235 | />
236 |
237 |
238 | )
239 | }
240 | }}
241 | >
242 |
243 |
244 |
245 | )
246 | }
247 | },
248 | components: {
249 | Draggable,
250 | }
251 | }
252 |
253 | const mapping = {
254 | default: 'input',
255 | string: 'input',
256 | object: 'map',
257 | array: 'array',
258 | number: 'number',
259 | boolean: 'boolean',
260 | multiSelect: 'multiSelect',
261 | multiCheckbox: 'multiCheckbox',
262 | 'range:dateTime': 'range',
263 | 'string:email': 'input',
264 | 'string:textarea': 'input',
265 | 'string:url': 'url',
266 | 'string:color': 'color',
267 | 'string:image': 'image',
268 | 'string:date': 'date',
269 | 'string:richText': 'richText',
270 | }
271 |
272 | const widgets = {
273 | input,
274 | map: index,
275 | url,
276 | color,
277 | date,
278 | array,
279 | image,
280 | number,
281 | boolean,
282 | range,
283 | multiSelect,
284 | multiCheckbox,
285 | richText,
286 | }
287 |
288 | export {
289 | widgets,
290 | mapping
291 | }
--------------------------------------------------------------------------------