formforgetest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/index.js +45 -0
- package/package.json +31 -0
- package/src/components/base/EmptyFallback.vue +27 -0
- package/src/components/base/LcButton.vue +120 -0
- package/src/components/base/LcContainer.vue +41 -0
- package/src/components/base/LcInput.vue +105 -0
- package/src/components/base/LcSelect.vue +124 -0
- package/src/components/business/FormForgePage.vue +67 -0
- package/src/components/business/PageNodeStack.vue +19 -0
- package/src/components/business/SchemaRenderer.vue +42 -0
- package/src/constants/editor.js +5 -0
- package/src/utils/props.js +58 -0
- package/src/widgets/index.js +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# formforgetest
|
|
2
|
+
|
|
3
|
+
FormForge **表单渲染引擎**(Vue 2.7)。解析 `pageSchema` JSON,递归渲染 LcInput / LcButton / LcSelect / LcContainer 等物料。
|
|
4
|
+
|
|
5
|
+
**不包含**编辑器(画布、属性面板、拖拽)。
|
|
6
|
+
|
|
7
|
+
npm 包名 **`formforgetest`**(因 `@formforge` 已被占用,本书案例使用此名称发布)。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# 本地 monorepo(file 协议)
|
|
13
|
+
npm install file:../pagecraft/packages/formforge-runtime
|
|
14
|
+
|
|
15
|
+
# 或从 npm 安装
|
|
16
|
+
npm install formforgetest
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 发布到 npm
|
|
20
|
+
|
|
21
|
+
完整步骤见 **[PUBLISH.md](./PUBLISH.md)**(`npm login` → `npm publish` → 业务项目 `npm install formforgetest`)。
|
|
22
|
+
|
|
23
|
+
## 使用
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
// main.js
|
|
27
|
+
import Vue from 'vue'
|
|
28
|
+
import FormForgeRuntime from 'formforgetest'
|
|
29
|
+
|
|
30
|
+
Vue.use(FormForgeRuntime)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```vue
|
|
34
|
+
<!-- App.vue -->
|
|
35
|
+
<template>
|
|
36
|
+
<FormForgePage :schema="pageSchema" />
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script>
|
|
40
|
+
import pageSchema from './schema.json'
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
data() {
|
|
44
|
+
return { pageSchema }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
也可按需引入:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
import { SchemaRenderer, PageNodeStack, EDITOR_MODE } from 'formforgetest'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## pageSchema 结构
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"title": "登录页",
|
|
61
|
+
"nodes": [
|
|
62
|
+
{
|
|
63
|
+
"id": "n1",
|
|
64
|
+
"type": "input",
|
|
65
|
+
"props": { "label": "用户名", "fieldKey": "username" }
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
详见 `examples/formforge-consumer` 模拟业务项目。
|
package/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import SchemaRenderer from './src/components/business/SchemaRenderer.vue'
|
|
2
|
+
import PageNodeStack from './src/components/business/PageNodeStack.vue'
|
|
3
|
+
import FormForgePage from './src/components/business/FormForgePage.vue'
|
|
4
|
+
import LcButton from './src/components/base/LcButton.vue'
|
|
5
|
+
import LcInput from './src/components/base/LcInput.vue'
|
|
6
|
+
import LcContainer from './src/components/base/LcContainer.vue'
|
|
7
|
+
import LcSelect from './src/components/base/LcSelect.vue'
|
|
8
|
+
import EmptyFallback from './src/components/base/EmptyFallback.vue'
|
|
9
|
+
|
|
10
|
+
export { EDITOR_MODE } from './src/constants/editor'
|
|
11
|
+
export * from './src/utils/props'
|
|
12
|
+
export * from './src/widgets'
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
SchemaRenderer,
|
|
16
|
+
PageNodeStack,
|
|
17
|
+
FormForgePage,
|
|
18
|
+
LcButton,
|
|
19
|
+
LcInput,
|
|
20
|
+
LcContainer,
|
|
21
|
+
LcSelect,
|
|
22
|
+
EmptyFallback
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pluginComponents = [
|
|
26
|
+
SchemaRenderer,
|
|
27
|
+
PageNodeStack,
|
|
28
|
+
FormForgePage,
|
|
29
|
+
LcButton,
|
|
30
|
+
LcInput,
|
|
31
|
+
LcContainer,
|
|
32
|
+
LcSelect,
|
|
33
|
+
EmptyFallback
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/** Vue.use(FormForgeRuntime) — 全局注册渲染引擎组件 */
|
|
37
|
+
export default {
|
|
38
|
+
install(Vue) {
|
|
39
|
+
pluginComponents.forEach(comp => {
|
|
40
|
+
if (comp.name) {
|
|
41
|
+
Vue.component(comp.name, comp)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "formforgetest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "FormForge 表单渲染引擎 — SchemaRenderer + widget 物料(Vue 2.7)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"vue2",
|
|
9
|
+
"form",
|
|
10
|
+
"low-code",
|
|
11
|
+
"schema",
|
|
12
|
+
"formforge",
|
|
13
|
+
"renderer"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/your-org/vue2-book.git",
|
|
18
|
+
"directory": "examples/pagecraft/packages/formforge-runtime"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"vue": "^2.7.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
export default {
|
|
3
|
+
name: 'EmptyFallback',
|
|
4
|
+
functional: true,
|
|
5
|
+
props: {
|
|
6
|
+
type: { type: String, default: '' }
|
|
7
|
+
},
|
|
8
|
+
render(h, { props }) {
|
|
9
|
+
return h(
|
|
10
|
+
'div',
|
|
11
|
+
{ class: 'lc-fallback' },
|
|
12
|
+
`未知物料:${props.type || '?'}`
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<style>
|
|
19
|
+
.lc-fallback {
|
|
20
|
+
padding: 8px 12px;
|
|
21
|
+
font-size: 12px;
|
|
22
|
+
color: #999;
|
|
23
|
+
background: #fff5f5;
|
|
24
|
+
border: 1px dashed #e57373;
|
|
25
|
+
border-radius: 4px;
|
|
26
|
+
}
|
|
27
|
+
</style>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<button
|
|
3
|
+
class="lc-btn"
|
|
4
|
+
:class="'lc-btn--' + type"
|
|
5
|
+
v-bind="$attrs"
|
|
6
|
+
:disabled="disabled || loading"
|
|
7
|
+
@click="handleClick"
|
|
8
|
+
>
|
|
9
|
+
<slot>{{ loading ? '请求中…' : label }}</slot>
|
|
10
|
+
</button>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
import { EDITOR_MODE } from '../../constants/editor'
|
|
15
|
+
import { isApiBinding, isButtonVariant } from '../../utils/props'
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
name: 'LcButton',
|
|
19
|
+
inheritAttrs: false,
|
|
20
|
+
inject: {
|
|
21
|
+
editorContext: { default: () => ({ mode: EDITOR_MODE.PREVIEW }) },
|
|
22
|
+
formContext: { default: null }
|
|
23
|
+
},
|
|
24
|
+
props: {
|
|
25
|
+
label: { type: String, default: '按钮' },
|
|
26
|
+
type: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: 'primary',
|
|
29
|
+
validator: isButtonVariant
|
|
30
|
+
},
|
|
31
|
+
disabled: { type: Boolean, default: false },
|
|
32
|
+
apiBinding: {
|
|
33
|
+
type: Object,
|
|
34
|
+
default: () => ({ enabled: false, url: '', method: 'POST', params: [] }),
|
|
35
|
+
validator: isApiBinding
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
data() {
|
|
39
|
+
return {
|
|
40
|
+
loading: false
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
methods: {
|
|
44
|
+
handleClick(event) {
|
|
45
|
+
this.$emit('click', event)
|
|
46
|
+
if (this.editorContext.mode === EDITOR_MODE.EDIT) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (this.apiBinding.enabled && this.apiBinding.url) {
|
|
50
|
+
this.invokeApi()
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
buildRequestPayload() {
|
|
54
|
+
const payload = {}
|
|
55
|
+
;(this.apiBinding.params || []).forEach(item => {
|
|
56
|
+
if (!item.name) return
|
|
57
|
+
if (item.source === 'field') {
|
|
58
|
+
payload[item.name] = this.formContext?.getValue(item.fieldKey) ?? ''
|
|
59
|
+
} else {
|
|
60
|
+
payload[item.name] = item.value
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
return payload
|
|
64
|
+
},
|
|
65
|
+
async invokeApi() {
|
|
66
|
+
const { url, method } = this.apiBinding
|
|
67
|
+
const payload = this.buildRequestPayload()
|
|
68
|
+
this.loading = true
|
|
69
|
+
try {
|
|
70
|
+
let requestUrl = url
|
|
71
|
+
const options = { method }
|
|
72
|
+
if (method === 'GET') {
|
|
73
|
+
const qs = new URLSearchParams(payload).toString()
|
|
74
|
+
if (qs) {
|
|
75
|
+
requestUrl += (url.includes('?') ? '&' : '?') + qs
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
options.headers = { 'Content-Type': 'application/json' }
|
|
79
|
+
options.body = JSON.stringify(payload)
|
|
80
|
+
}
|
|
81
|
+
const response = await fetch(requestUrl, options)
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new Error(`HTTP ${response.status}`)
|
|
84
|
+
}
|
|
85
|
+
const data = await response.json()
|
|
86
|
+
this.$emit('api-success', data)
|
|
87
|
+
window.alert(`接口请求成功:${JSON.stringify(data).slice(0, 160)}…`)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
this.$emit('api-error', error)
|
|
90
|
+
window.alert(`接口请求失败:${error.message}`)
|
|
91
|
+
} finally {
|
|
92
|
+
this.loading = false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<style scoped>
|
|
100
|
+
.lc-btn {
|
|
101
|
+
padding: 8px 16px;
|
|
102
|
+
border: none;
|
|
103
|
+
border-radius: 4px;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
font-size: 14px;
|
|
106
|
+
font-family: inherit;
|
|
107
|
+
}
|
|
108
|
+
.lc-btn--primary {
|
|
109
|
+
background: #41b883;
|
|
110
|
+
color: #fff;
|
|
111
|
+
}
|
|
112
|
+
.lc-btn--default {
|
|
113
|
+
background: #f0f0f0;
|
|
114
|
+
color: #333;
|
|
115
|
+
}
|
|
116
|
+
.lc-btn:disabled {
|
|
117
|
+
opacity: 0.5;
|
|
118
|
+
cursor: not-allowed;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="lc-grid" :style="gridStyle">
|
|
3
|
+
<slot />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script>
|
|
8
|
+
export default {
|
|
9
|
+
name: 'LcContainer',
|
|
10
|
+
props: {
|
|
11
|
+
columns: {
|
|
12
|
+
type: Number,
|
|
13
|
+
default: 2,
|
|
14
|
+
validator: v => v >= 1 && v <= 4
|
|
15
|
+
},
|
|
16
|
+
gap: { type: Number, default: 12 },
|
|
17
|
+
padding: { type: Number, default: 12 }
|
|
18
|
+
},
|
|
19
|
+
computed: {
|
|
20
|
+
gridStyle() {
|
|
21
|
+
return {
|
|
22
|
+
padding: `${this.padding}px`,
|
|
23
|
+
gap: `${this.gap}px`,
|
|
24
|
+
gridTemplateColumns: `repeat(${this.columns}, minmax(0, 1fr))`
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<style scoped>
|
|
32
|
+
.lc-grid {
|
|
33
|
+
display: grid;
|
|
34
|
+
width: 100%;
|
|
35
|
+
border: 1px dashed #ccc;
|
|
36
|
+
border-radius: 4px;
|
|
37
|
+
min-height: 56px;
|
|
38
|
+
background: #fafafa;
|
|
39
|
+
box-sizing: border-box;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="lc-input-wrap">
|
|
3
|
+
<label v-if="label" class="lc-input__label" :for="inputId">{{ label }}</label>
|
|
4
|
+
<input
|
|
5
|
+
:id="inputId"
|
|
6
|
+
class="lc-input"
|
|
7
|
+
:value="innerValue"
|
|
8
|
+
:placeholder="placeholder"
|
|
9
|
+
:disabled="disabled"
|
|
10
|
+
:name="fieldKey || undefined"
|
|
11
|
+
v-bind="$attrs"
|
|
12
|
+
@input="onInput"
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script>
|
|
18
|
+
import { EDITOR_MODE } from '../../constants/editor'
|
|
19
|
+
|
|
20
|
+
let inputSeed = 0
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
name: 'LcInput',
|
|
24
|
+
inheritAttrs: false,
|
|
25
|
+
inject: {
|
|
26
|
+
editorContext: { default: () => ({ mode: EDITOR_MODE.PREVIEW }) },
|
|
27
|
+
formContext: { default: null }
|
|
28
|
+
},
|
|
29
|
+
props: {
|
|
30
|
+
label: { type: String, default: '' },
|
|
31
|
+
fieldKey: { type: String, default: '' },
|
|
32
|
+
value: { type: String, default: '' },
|
|
33
|
+
placeholder: { type: String, default: '' },
|
|
34
|
+
disabled: { type: Boolean, default: false }
|
|
35
|
+
},
|
|
36
|
+
data() {
|
|
37
|
+
return {
|
|
38
|
+
inputId: `lc-input-${++inputSeed}`,
|
|
39
|
+
localValue: this.value
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
computed: {
|
|
43
|
+
innerValue() {
|
|
44
|
+
if (this.formContext && this.fieldKey) {
|
|
45
|
+
return this.formContext.getValue(this.fieldKey)
|
|
46
|
+
}
|
|
47
|
+
return this.localValue
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
watch: {
|
|
51
|
+
value(next) {
|
|
52
|
+
this.localValue = next
|
|
53
|
+
this.syncFormContext(next)
|
|
54
|
+
},
|
|
55
|
+
fieldKey(next, prev) {
|
|
56
|
+
if (prev && this.formContext) {
|
|
57
|
+
this.formContext.setValue(prev, '')
|
|
58
|
+
}
|
|
59
|
+
this.syncFormContext(this.innerValue)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
mounted() {
|
|
63
|
+
this.syncFormContext(this.value)
|
|
64
|
+
},
|
|
65
|
+
methods: {
|
|
66
|
+
syncFormContext(val) {
|
|
67
|
+
if (this.formContext && this.fieldKey) {
|
|
68
|
+
this.formContext.setValue(this.fieldKey, val)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
onInput(event) {
|
|
72
|
+
const val = event.target.value
|
|
73
|
+
this.localValue = val
|
|
74
|
+
this.$emit('input', val)
|
|
75
|
+
this.syncFormContext(val)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<style scoped>
|
|
82
|
+
.lc-input-wrap {
|
|
83
|
+
width: 100%;
|
|
84
|
+
}
|
|
85
|
+
.lc-input__label {
|
|
86
|
+
display: block;
|
|
87
|
+
margin-bottom: 6px;
|
|
88
|
+
font-size: 13px;
|
|
89
|
+
font-weight: 500;
|
|
90
|
+
color: #333;
|
|
91
|
+
}
|
|
92
|
+
.lc-input {
|
|
93
|
+
width: 100%;
|
|
94
|
+
padding: 8px 12px;
|
|
95
|
+
border: 1px solid #ddd;
|
|
96
|
+
border-radius: 4px;
|
|
97
|
+
font-size: 14px;
|
|
98
|
+
font-family: inherit;
|
|
99
|
+
box-sizing: border-box;
|
|
100
|
+
}
|
|
101
|
+
.lc-input:focus {
|
|
102
|
+
outline: none;
|
|
103
|
+
border-color: #41b883;
|
|
104
|
+
}
|
|
105
|
+
</style>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="lc-select-wrap">
|
|
3
|
+
<label v-if="label" class="lc-select__label" :for="selectId">{{ label }}</label>
|
|
4
|
+
<select
|
|
5
|
+
:id="selectId"
|
|
6
|
+
class="lc-select"
|
|
7
|
+
:value="innerValue"
|
|
8
|
+
:disabled="disabled"
|
|
9
|
+
:name="fieldKey || undefined"
|
|
10
|
+
v-bind="$attrs"
|
|
11
|
+
@change="onChange"
|
|
12
|
+
>
|
|
13
|
+
<option v-if="placeholder" value="" disabled hidden>{{ placeholder }}</option>
|
|
14
|
+
<option
|
|
15
|
+
v-for="(opt, idx) in options"
|
|
16
|
+
:key="opt.value + '-' + idx"
|
|
17
|
+
:value="opt.value"
|
|
18
|
+
>
|
|
19
|
+
{{ opt.label }}
|
|
20
|
+
</option>
|
|
21
|
+
</select>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
import { EDITOR_MODE } from '../../constants/editor'
|
|
27
|
+
import { isSelectOptions } from '../../utils/props'
|
|
28
|
+
|
|
29
|
+
let selectSeed = 0
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
name: 'LcSelect',
|
|
33
|
+
inheritAttrs: false,
|
|
34
|
+
inject: {
|
|
35
|
+
editorContext: { default: () => ({ mode: EDITOR_MODE.PREVIEW }) },
|
|
36
|
+
formContext: { default: null }
|
|
37
|
+
},
|
|
38
|
+
props: {
|
|
39
|
+
label: { type: String, default: '' },
|
|
40
|
+
fieldKey: { type: String, default: '' },
|
|
41
|
+
value: { type: String, default: '' },
|
|
42
|
+
placeholder: { type: String, default: '请选择' },
|
|
43
|
+
disabled: { type: Boolean, default: false },
|
|
44
|
+
options: {
|
|
45
|
+
type: Array,
|
|
46
|
+
default: () => [],
|
|
47
|
+
validator: isSelectOptions
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
data() {
|
|
51
|
+
return {
|
|
52
|
+
selectId: `lc-select-${++selectSeed}`,
|
|
53
|
+
localValue: this.value
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
computed: {
|
|
57
|
+
innerValue() {
|
|
58
|
+
if (this.formContext && this.fieldKey) {
|
|
59
|
+
return this.formContext.getValue(this.fieldKey)
|
|
60
|
+
}
|
|
61
|
+
return this.localValue
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
watch: {
|
|
65
|
+
value(next) {
|
|
66
|
+
this.localValue = next
|
|
67
|
+
this.syncFormContext(next)
|
|
68
|
+
},
|
|
69
|
+
fieldKey(next, prev) {
|
|
70
|
+
if (prev && this.formContext) {
|
|
71
|
+
this.formContext.setValue(prev, '')
|
|
72
|
+
}
|
|
73
|
+
this.syncFormContext(this.innerValue)
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
mounted() {
|
|
77
|
+
this.syncFormContext(this.value)
|
|
78
|
+
},
|
|
79
|
+
methods: {
|
|
80
|
+
syncFormContext(val) {
|
|
81
|
+
if (this.formContext && this.fieldKey) {
|
|
82
|
+
this.formContext.setValue(this.fieldKey, val)
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
onChange(event) {
|
|
86
|
+
const val = event.target.value
|
|
87
|
+
this.localValue = val
|
|
88
|
+
this.$emit('input', val)
|
|
89
|
+
this.syncFormContext(val)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<style scoped>
|
|
96
|
+
.lc-select-wrap {
|
|
97
|
+
width: 100%;
|
|
98
|
+
}
|
|
99
|
+
.lc-select__label {
|
|
100
|
+
display: block;
|
|
101
|
+
margin-bottom: 6px;
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
font-weight: 500;
|
|
104
|
+
color: #333;
|
|
105
|
+
}
|
|
106
|
+
.lc-select {
|
|
107
|
+
width: 100%;
|
|
108
|
+
padding: 8px 12px;
|
|
109
|
+
border: 1px solid #ddd;
|
|
110
|
+
border-radius: 4px;
|
|
111
|
+
font-size: 14px;
|
|
112
|
+
font-family: inherit;
|
|
113
|
+
box-sizing: border-box;
|
|
114
|
+
background: #fff;
|
|
115
|
+
}
|
|
116
|
+
.lc-select:focus {
|
|
117
|
+
outline: none;
|
|
118
|
+
border-color: #41b883;
|
|
119
|
+
}
|
|
120
|
+
.lc-select:disabled {
|
|
121
|
+
opacity: 0.6;
|
|
122
|
+
cursor: not-allowed;
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="formforge-page">
|
|
3
|
+
<p v-if="!schema.nodes || !schema.nodes.length" class="formforge-page__empty">
|
|
4
|
+
{{ emptyText }}
|
|
5
|
+
</p>
|
|
6
|
+
<PageNodeStack v-else>
|
|
7
|
+
<SchemaRenderer
|
|
8
|
+
v-for="node in schema.nodes"
|
|
9
|
+
:key="node.id"
|
|
10
|
+
:node="node"
|
|
11
|
+
/>
|
|
12
|
+
</PageNodeStack>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script>
|
|
17
|
+
import { EDITOR_MODE } from '../../constants/editor'
|
|
18
|
+
import SchemaRenderer from './SchemaRenderer.vue'
|
|
19
|
+
import PageNodeStack from './PageNodeStack.vue'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 开箱即用:传入 pageSchema JSON 即可渲染表单页。
|
|
23
|
+
* 业务项目 Vue.use(formforgetest) 后可直接 <FormForgePage :schema="..." />。
|
|
24
|
+
*/
|
|
25
|
+
export default {
|
|
26
|
+
name: 'FormForgePage',
|
|
27
|
+
components: { SchemaRenderer, PageNodeStack },
|
|
28
|
+
props: {
|
|
29
|
+
schema: {
|
|
30
|
+
type: Object,
|
|
31
|
+
required: true,
|
|
32
|
+
validator(s) {
|
|
33
|
+
return s && Array.isArray(s.nodes)
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
emptyText: {
|
|
37
|
+
type: String,
|
|
38
|
+
default: '暂无表单节点'
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
data() {
|
|
42
|
+
return {
|
|
43
|
+
formValues: {}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
provide() {
|
|
47
|
+
return {
|
|
48
|
+
editorContext: { mode: EDITOR_MODE.PREVIEW },
|
|
49
|
+
formContext: {
|
|
50
|
+
getValue: key => this.formValues[key] ?? '',
|
|
51
|
+
setValue: (key, val) => {
|
|
52
|
+
this.$set(this.formValues, key, val)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<style scoped>
|
|
61
|
+
.formforge-page__empty {
|
|
62
|
+
text-align: center;
|
|
63
|
+
color: #999;
|
|
64
|
+
padding: 32px 16px;
|
|
65
|
+
font-size: 14px;
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="lc-page-stack">
|
|
3
|
+
<slot />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script>
|
|
8
|
+
export default {
|
|
9
|
+
name: 'PageNodeStack'
|
|
10
|
+
}
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<style scoped>
|
|
14
|
+
.lc-page-stack {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 12px;
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="componentType"
|
|
4
|
+
v-bind="boundProps"
|
|
5
|
+
>
|
|
6
|
+
<SchemaRenderer
|
|
7
|
+
v-for="child in node.children || []"
|
|
8
|
+
:key="child.id"
|
|
9
|
+
:node="child"
|
|
10
|
+
/>
|
|
11
|
+
</component>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script>
|
|
15
|
+
import { resolveWidgetSync } from '../../widgets'
|
|
16
|
+
import EmptyFallback from '../base/EmptyFallback.vue'
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
name: 'SchemaRenderer',
|
|
20
|
+
components: { EmptyFallback },
|
|
21
|
+
props: {
|
|
22
|
+
node: {
|
|
23
|
+
type: Object,
|
|
24
|
+
required: true,
|
|
25
|
+
validator(node) {
|
|
26
|
+
return node && typeof node.type === 'string' && node.props != null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
computed: {
|
|
31
|
+
componentType() {
|
|
32
|
+
return resolveWidgetSync(this.node.type) || EmptyFallback
|
|
33
|
+
},
|
|
34
|
+
boundProps() {
|
|
35
|
+
if (resolveWidgetSync(this.node.type)) {
|
|
36
|
+
return this.node.props
|
|
37
|
+
}
|
|
38
|
+
return { type: this.node.type }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** 按钮样式变体校验 */
|
|
2
|
+
export function isButtonVariant(value) {
|
|
3
|
+
return value === 'primary' || value === 'default'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function isApiParam(item) {
|
|
7
|
+
if (!item || typeof item !== 'object') return false
|
|
8
|
+
if (typeof item.name !== 'string') return false
|
|
9
|
+
if (item.source !== 'field' && item.source !== 'static') return false
|
|
10
|
+
if (item.source === 'field' && typeof item.fieldKey !== 'string') return false
|
|
11
|
+
if (item.source === 'static' && typeof item.value !== 'string') return false
|
|
12
|
+
return true
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 接口绑定对象校验 */
|
|
16
|
+
export function isApiBinding(value) {
|
|
17
|
+
if (!value || typeof value !== 'object') return false
|
|
18
|
+
const method = value.method || 'GET'
|
|
19
|
+
if (typeof value.enabled !== 'boolean') return false
|
|
20
|
+
if (typeof value.url !== 'string') return false
|
|
21
|
+
if (method !== 'GET' && method !== 'POST') return false
|
|
22
|
+
if (value.params != null) {
|
|
23
|
+
if (!Array.isArray(value.params)) return false
|
|
24
|
+
if (!value.params.every(isApiParam)) return false
|
|
25
|
+
}
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createDefaultApiParam() {
|
|
30
|
+
return { name: '', source: 'field', fieldKey: '', value: '' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createDefaultApiBinding() {
|
|
34
|
+
return {
|
|
35
|
+
enabled: false,
|
|
36
|
+
url: '',
|
|
37
|
+
method: 'POST',
|
|
38
|
+
params: []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isSelectOption(item) {
|
|
43
|
+
if (!item || typeof item !== 'object') return false
|
|
44
|
+
return typeof item.label === 'string' && typeof item.value === 'string'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 下拉 options 校验 */
|
|
48
|
+
export function isSelectOptions(value) {
|
|
49
|
+
if (!Array.isArray(value)) return false
|
|
50
|
+
return value.every(isSelectOption)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createDefaultSelectOptions() {
|
|
54
|
+
return [
|
|
55
|
+
{ label: '选项 A', value: 'a' },
|
|
56
|
+
{ label: '选项 B', value: 'b' }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import LcButton from '../components/base/LcButton.vue'
|
|
2
|
+
import LcInput from '../components/base/LcInput.vue'
|
|
3
|
+
import LcContainer from '../components/base/LcContainer.vue'
|
|
4
|
+
import LcSelect from '../components/base/LcSelect.vue'
|
|
5
|
+
import { createDefaultApiBinding, createDefaultSelectOptions } from '../utils/props'
|
|
6
|
+
|
|
7
|
+
export const widgetRegistry = {
|
|
8
|
+
button: {
|
|
9
|
+
component: LcButton,
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'button',
|
|
12
|
+
title: '按钮',
|
|
13
|
+
defaultProps: {
|
|
14
|
+
label: '按钮',
|
|
15
|
+
type: 'primary',
|
|
16
|
+
disabled: false,
|
|
17
|
+
apiBinding: createDefaultApiBinding()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
input: {
|
|
22
|
+
component: LcInput,
|
|
23
|
+
meta: {
|
|
24
|
+
type: 'input',
|
|
25
|
+
title: '输入框',
|
|
26
|
+
defaultProps: {
|
|
27
|
+
label: '用户名',
|
|
28
|
+
fieldKey: 'username',
|
|
29
|
+
value: '',
|
|
30
|
+
placeholder: '请输入',
|
|
31
|
+
disabled: false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
container: {
|
|
36
|
+
component: LcContainer,
|
|
37
|
+
meta: {
|
|
38
|
+
type: 'container',
|
|
39
|
+
title: '栅格容器',
|
|
40
|
+
defaultProps: { columns: 2, gap: 12, padding: 12 }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
select: {
|
|
44
|
+
component: LcSelect,
|
|
45
|
+
meta: {
|
|
46
|
+
type: 'select',
|
|
47
|
+
title: '下拉菜单',
|
|
48
|
+
defaultProps: {
|
|
49
|
+
label: '下拉菜单',
|
|
50
|
+
fieldKey: 'selectField',
|
|
51
|
+
value: '',
|
|
52
|
+
placeholder: '请选择',
|
|
53
|
+
disabled: false,
|
|
54
|
+
options: createDefaultSelectOptions()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveWidgetSync(type) {
|
|
61
|
+
return widgetRegistry[type]?.component || null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getWidgetMeta(type) {
|
|
65
|
+
return widgetRegistry[type]?.meta || null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildNodeProps(type, overrides = {}) {
|
|
69
|
+
const meta = getWidgetMeta(type)
|
|
70
|
+
return { ...(meta?.defaultProps || {}), ...overrides }
|
|
71
|
+
}
|