- 字典管理:左右分栏主从布局,20 个字典类型,el-scrollbar 滚动 - 组织管理:树形表格 + 上级组织 tree-select,级联删除下级 - ty-table 新增树形支持(row-key/tree-props/default-expand-all) - ty-form 新增 tree-select 控件(format/treeData/treeProps) - JsonSchemaProperty 扩展 tree-select 相关字段 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
5.3 KiB
Vue
185 lines
5.3 KiB
Vue
<template>
|
||
<el-form ref="formRef" :inline="inline" :model="modelValue" :rules="rules" :label-width="inline ? 'auto' : '90px'">
|
||
<el-form-item
|
||
v-for="(prop, key) in visibleProperties"
|
||
:key="key"
|
||
:label="prop.title ?? key"
|
||
:prop="key"
|
||
>
|
||
<el-select
|
||
v-if="prop.enum"
|
||
v-model="modelValue[key]"
|
||
:placeholder="prop.placeholder ?? '请选择'"
|
||
:clearable="!isRequired(key)"
|
||
:disabled="isReadonly(key, prop)"
|
||
:style="widgetStyle(prop)"
|
||
>
|
||
<el-option
|
||
v-for="(v, i) in prop.enum"
|
||
:key="String(v)"
|
||
:label="prop.enumNames?.[i] ?? String(v)"
|
||
:value="v"
|
||
/>
|
||
</el-select>
|
||
|
||
<el-tree-select
|
||
v-else-if="prop.format === 'tree-select'"
|
||
v-model="modelValue[key]"
|
||
:data="prop.treeData ?? []"
|
||
:node-key="prop.treeProps?.value ?? 'value'"
|
||
:props="{
|
||
label: prop.treeProps?.label ?? 'label',
|
||
children: prop.treeProps?.children ?? 'children',
|
||
}"
|
||
check-strictly
|
||
:placeholder="prop.placeholder ?? '请选择'"
|
||
:clearable="!isRequired(key)"
|
||
:disabled="isReadonly(key, prop)"
|
||
:style="widgetStyle(prop)"
|
||
/>
|
||
|
||
<el-switch v-else-if="prop.type === 'boolean'" v-model="modelValue[key]" :disabled="isReadonly(key, prop)" />
|
||
|
||
<el-input-number
|
||
v-else-if="prop.type === 'integer' || prop.type === 'number'"
|
||
v-model="modelValue[key]"
|
||
:min="prop.minimum"
|
||
:max="prop.maximum"
|
||
:disabled="isReadonly(key, prop)"
|
||
:style="widgetStyle(prop)"
|
||
/>
|
||
|
||
<el-input
|
||
v-else-if="prop.format === 'password'"
|
||
v-model="modelValue[key]"
|
||
type="password"
|
||
show-password
|
||
:placeholder="prop.placeholder"
|
||
:disabled="isReadonly(key, prop)"
|
||
:style="widgetStyle(prop)"
|
||
/>
|
||
|
||
<el-input
|
||
v-else-if="prop.format === 'textarea'"
|
||
v-model="modelValue[key]"
|
||
type="textarea"
|
||
:placeholder="prop.placeholder"
|
||
:disabled="isReadonly(key, prop)"
|
||
:style="widgetStyle(prop)"
|
||
/>
|
||
|
||
<el-input
|
||
v-else
|
||
v-model="modelValue[key]"
|
||
:placeholder="prop.placeholder"
|
||
:disabled="isReadonly(key, prop)"
|
||
:style="widgetStyle(prop)"
|
||
@keyup.enter="onEnter"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<slot />
|
||
</el-form>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import type { FormInstance, FormRules } from 'element-plus'
|
||
import type { JsonSchema, JsonSchemaProperty } from '@/types/crud'
|
||
|
||
defineOptions({ name: 'TyForm' })
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 表单 JSON Schema。 */
|
||
schema: JsonSchema
|
||
/** 表单数据(就地绑定,会直接修改传入对象)。 */
|
||
modelValue: Record<string, any>
|
||
/** 是否行内布局(用于搜索)。 */
|
||
inline?: boolean
|
||
/** 表单模式(create/edit),用于 showOn 字段过滤。 */
|
||
mode?: 'create' | 'edit'
|
||
/** 输入框回车触发 submit 事件。 */
|
||
enterToSubmit?: boolean
|
||
/** 强制只读的字段名(如编辑模式下的 username)。 */
|
||
readonlyKeys?: string[]
|
||
}>(),
|
||
{
|
||
inline: false,
|
||
mode: undefined,
|
||
enterToSubmit: false,
|
||
readonlyKeys: () => [],
|
||
},
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'submit'): void
|
||
}>()
|
||
|
||
const formRef = ref<FormInstance>()
|
||
|
||
function onEnter(): void {
|
||
if (props.enterToSubmit) emit('submit')
|
||
}
|
||
|
||
/** 按 mode 过滤 showOn 字段。 */
|
||
const visibleProperties = computed(() => {
|
||
const result: Record<string, JsonSchemaProperty> = {}
|
||
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
||
if (!props.mode || !prop.showOn || prop.showOn.includes(props.mode)) {
|
||
result[key] = prop
|
||
}
|
||
}
|
||
return result
|
||
})
|
||
|
||
const rules = computed<FormRules>(() => {
|
||
const result: FormRules = {}
|
||
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
||
const itemRules: Record<string, unknown>[] = []
|
||
if (props.schema.required?.includes(key)) {
|
||
itemRules.push({ required: true, message: `${prop.title ?? key}不能为空`, trigger: 'blur' })
|
||
}
|
||
if (prop.minLength) {
|
||
itemRules.push({ min: prop.minLength, message: `长度不能少于 ${prop.minLength} 位`, trigger: 'blur' })
|
||
}
|
||
if (prop.format === 'email') {
|
||
itemRules.push({ type: 'email', message: '邮箱格式不正确', trigger: 'blur' })
|
||
}
|
||
if (itemRules.length) result[key] = itemRules
|
||
}
|
||
return result
|
||
})
|
||
|
||
function isRequired(key: string): boolean {
|
||
return props.schema.required?.includes(key) ?? false
|
||
}
|
||
|
||
function isReadonly(key: string, prop: { readOnly?: boolean }): boolean {
|
||
return prop.readOnly === true || props.readonlyKeys.includes(key)
|
||
}
|
||
|
||
function widgetStyle(prop: JsonSchemaProperty): Record<string, string> {
|
||
return { width: prop.width ?? (props.inline ? '200px' : '100%') }
|
||
}
|
||
|
||
async function submit(): Promise<Record<string, any>> {
|
||
await formRef.value?.validate()
|
||
return { ...props.modelValue }
|
||
}
|
||
|
||
defineExpose({
|
||
submit,
|
||
validate: () => formRef.value?.validate(),
|
||
resetFields: () => formRef.value?.resetFields(),
|
||
clearValidate: () => formRef.value?.clearValidate(),
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
/* inline 模式下让 el-form 成为 flex 容器,使「查询/重置」按钮可通过 ml-auto 贴右 */
|
||
.el-form--inline {
|
||
@apply flex flex-wrap items-center;
|
||
}
|
||
</style>
|