419 lines
12 KiB
Vue
419 lines
12 KiB
Vue
<template>
|
||
<el-dialog
|
||
:model-value="visible"
|
||
:title="dialogTitle"
|
||
width="560px"
|
||
:close-on-click-modal="false"
|
||
destroy-on-close
|
||
@update:model-value="$emit('update:visible', $event)"
|
||
@closed="handleClosed"
|
||
>
|
||
<el-form ref="formRef" :model="form" label-width="110px" size="small">
|
||
<!-- ===== 基础信息 ===== -->
|
||
<el-divider content-position="left">基础信息</el-divider>
|
||
|
||
<el-form-item v-if="showKeyField" label="字段英文名">
|
||
<el-input v-model="form.nodeKey" placeholder="字段名称(key)" clearable />
|
||
</el-form-item>
|
||
<el-form-item v-if="!isArrayElement" label="字段中文名">
|
||
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
|
||
</el-form-item>
|
||
<el-form-item v-if="!isArrayElement" label="字段描述">
|
||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="字段类型">
|
||
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
|
||
<el-option label="字符串" value="string" />
|
||
<el-option label="数字" value="number" />
|
||
<el-option label="布尔" value="boolean" />
|
||
<el-option label="空值" value="null" />
|
||
<el-option label="对象" value="object" />
|
||
<el-option label="数组" value="array" />
|
||
</el-select>
|
||
<div class="form-hint">JSON 数据类型</div>
|
||
</el-form-item>
|
||
<!-- ===== 表单属性 & 约束规则(仅标量类型) ===== -->
|
||
<template v-if="jsonTypeIsScalar">
|
||
<el-divider content-position="left">表单属性</el-divider>
|
||
|
||
<el-form-item label="表单显示">
|
||
<el-switch v-model="form.isForm" />
|
||
<span class="form-hint">{{ form.isForm ? '在表单中展示' : '隐藏字段' }}</span>
|
||
</el-form-item>
|
||
<template v-if="form.isForm">
|
||
<el-form-item label="表单控件">
|
||
<el-select v-model="form.fieldType" @change="onTypeChange">
|
||
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
<div class="form-hint">此字段在表单中以什么组件展示</div>
|
||
</el-form-item>
|
||
<el-form-item label="必填项">
|
||
<el-switch v-model="form.required" />
|
||
</el-form-item>
|
||
</template>
|
||
<el-form-item label="默认值">
|
||
<el-input v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
|
||
</el-form-item>
|
||
|
||
<el-divider content-position="left">约束规则</el-divider>
|
||
|
||
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
|
||
<div class="options-editor">
|
||
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
|
||
<el-input v-model="form.options[idx]" size="small" placeholder="选项值" />
|
||
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
|
||
</div>
|
||
<el-button size="small" class="add-option-btn" @click="form.options.push('')"
|
||
><el-icon><Plus /></el-icon> 添加选项</el-button
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
|
||
<el-form-item label="最小长度"
|
||
><el-input-number
|
||
v-model="form.constraint.minLength"
|
||
:min="0"
|
||
:step="100"
|
||
controls-position="right"
|
||
placeholder="不限"
|
||
style="width: 100%"
|
||
/></el-form-item>
|
||
<el-form-item label="最大长度"
|
||
><el-input-number
|
||
v-model="form.constraint.maxLength"
|
||
:min="0"
|
||
:step="1000"
|
||
controls-position="right"
|
||
placeholder="不限"
|
||
style="width: 100%"
|
||
/></el-form-item>
|
||
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
|
||
</template>
|
||
|
||
<template v-if="form.fieldType === 'upload'">
|
||
<el-form-item label="总数量">
|
||
<el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
|
||
</el-form-item>
|
||
<el-form-item label="总容量(MB)">
|
||
<el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
|
||
</el-form-item>
|
||
<el-divider content-position="left">按格式限制(可选)</el-divider>
|
||
<div class="upload-rules">
|
||
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
|
||
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg)" style="width: 90px" />
|
||
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width: 140px" />
|
||
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width: 110px" />
|
||
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
|
||
</div>
|
||
<el-button size="small" class="add-rule-btn" @click="addUploadRule"
|
||
><el-icon><Plus /></el-icon> 添加规则</el-button
|
||
>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="form.fieldType === 'number'">
|
||
<el-form-item label="最小值"
|
||
><el-input-number
|
||
v-model="form.constraint.min"
|
||
:min="Number.MIN_SAFE_INTEGER"
|
||
:max="Number.MAX_SAFE_INTEGER"
|
||
controls-position="right"
|
||
style="width: 100%"
|
||
/></el-form-item>
|
||
<el-form-item label="最大值"
|
||
><el-input-number
|
||
v-model="form.constraint.max"
|
||
:min="Number.MIN_SAFE_INTEGER"
|
||
:max="Number.MAX_SAFE_INTEGER"
|
||
controls-position="right"
|
||
style="width: 100%"
|
||
/></el-form-item>
|
||
<el-form-item label="数值类型"
|
||
><el-select v-model="form.constraint.numberType" clearable placeholder="不限"
|
||
><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select
|
||
></el-form-item>
|
||
</template>
|
||
</template>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
|
||
<el-button @click="handleCancel">取消</el-button>
|
||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, watch } from 'vue';
|
||
import { ElMessageBox } from 'element-plus';
|
||
import { Plus } from '@element-plus/icons-vue';
|
||
|
||
interface UploadRule {
|
||
format: string;
|
||
maxSize?: number;
|
||
maxCount?: number;
|
||
}
|
||
|
||
interface FieldFormData {
|
||
nodeKey: string;
|
||
nodeId: string;
|
||
jsonType: string;
|
||
fieldType: string;
|
||
label: string;
|
||
description: string;
|
||
role: string;
|
||
isForm: boolean;
|
||
required: boolean;
|
||
defaultValue: string;
|
||
|
||
options: string[];
|
||
constraint: {
|
||
minLength?: number;
|
||
maxLength?: number;
|
||
pattern?: string;
|
||
min?: number;
|
||
max?: number;
|
||
numberType?: string;
|
||
accept?: string;
|
||
maxSize?: number;
|
||
maxCount?: number;
|
||
uploadRules?: UploadRule[];
|
||
uploadTotalMaxCount?: number;
|
||
uploadTotalMaxSize?: number;
|
||
};
|
||
_createParentId?: string;
|
||
_isArrayParent?: boolean;
|
||
_childrenCount?: number;
|
||
}
|
||
|
||
const props = defineProps<{
|
||
visible: boolean;
|
||
formData: FieldFormData | null;
|
||
fieldTypeOptions: { label: string; value: string }[];
|
||
hasConfig: boolean;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:visible', v: boolean): void;
|
||
(e: 'save', form: FieldFormData): void;
|
||
(e: 'remove'): void;
|
||
}>();
|
||
|
||
const form = reactive<FieldFormData>({
|
||
nodeKey: '',
|
||
nodeId: '',
|
||
jsonType: 'string',
|
||
fieldType: 'string',
|
||
label: '',
|
||
description: '',
|
||
role: '',
|
||
isForm: true,
|
||
required: false,
|
||
defaultValue: '',
|
||
|
||
options: [],
|
||
constraint: {},
|
||
});
|
||
|
||
const uploadRules = ref<UploadRule[]>([]);
|
||
const prevJsonType = ref('');
|
||
|
||
const isCreateMode = computed(() => !!form._createParentId);
|
||
const jsonTypeIsScalar = computed(() => form.jsonType !== 'object' && form.jsonType !== 'array');
|
||
const isArrayElement = computed(() => isCreateMode.value && form._isArrayParent);
|
||
const showKeyField = computed(() => !isArrayElement.value);
|
||
const dialogTitle = computed(() => {
|
||
return isCreateMode.value ? (form._isArrayParent ? '添加元素' : '添加字段 —— ' + (form.nodeKey || '新字段')) : '字段配置 —— ' + form.nodeKey;
|
||
});
|
||
|
||
|
||
|
||
function resetForm() {
|
||
form.nodeKey = '';
|
||
form.nodeId = '';
|
||
form.jsonType = 'string';
|
||
form.fieldType = 'string';
|
||
form.label = '';
|
||
form.description = '';
|
||
form.role = '';
|
||
form.isForm = true;
|
||
form.required = false;
|
||
form.defaultValue = '';
|
||
form.options = [];
|
||
form.constraint = {};
|
||
// @ts-ignore
|
||
form._createParentId = undefined;
|
||
// @ts-ignore
|
||
form._isArrayParent = false;
|
||
uploadRules.value = [];
|
||
}
|
||
|
||
function loadForm(data: FieldFormData | null) {
|
||
resetForm();
|
||
if (!data) return;
|
||
form.nodeId = data.nodeId;
|
||
form.nodeKey = data.nodeKey || '';
|
||
prevJsonType.value = data.jsonType || 'string';
|
||
form.jsonType = data.jsonType || 'string';
|
||
form.fieldType = data.fieldType;
|
||
form.label = data.label || '';
|
||
form.description = data.description || '';
|
||
form.role = data.role || '';
|
||
form.isForm = data.isForm ?? true;
|
||
form.required = data.required || false;
|
||
form.defaultValue = data.defaultValue || '';
|
||
form.options = data.options ? [...data.options] : [];
|
||
form.constraint = data.constraint ? { ...data.constraint } : {};
|
||
// @ts-ignore
|
||
form._createParentId = data._createParentId;
|
||
// @ts-ignore
|
||
form._isArrayParent = data._isArrayParent ?? false;
|
||
// @ts-ignore
|
||
form._childrenCount = data._childrenCount ?? 0;
|
||
|
||
// Load upload rules — support both old (accept string) and new format
|
||
if (data.constraint?.uploadRules && data.constraint.uploadRules.length > 0) {
|
||
uploadRules.value = data.constraint.uploadRules.map((r) => ({ ...r }));
|
||
} else if (data.constraint?.accept) {
|
||
const formats = data.constraint.accept
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
if (formats.length > 0) {
|
||
uploadRules.value = formats.map((f) => ({
|
||
format: f,
|
||
maxSize: data.constraint?.maxSize,
|
||
maxCount: data.constraint?.maxCount,
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
function syncUploadRulesToConstraint() {
|
||
if (uploadRules.value.length > 0 && uploadRules.value.some((r) => r.format)) {
|
||
form.constraint.uploadRules = uploadRules.value
|
||
.filter((r) => r.format)
|
||
.map((r) => ({ format: r.format, maxSize: r.maxSize, maxCount: r.maxCount }));
|
||
} else {
|
||
delete form.constraint.uploadRules;
|
||
}
|
||
}
|
||
|
||
function addUploadRule() {
|
||
uploadRules.value.push({ format: '', maxSize: undefined, maxCount: undefined });
|
||
}
|
||
|
||
function removeUploadRule(idx: number) {
|
||
uploadRules.value.splice(idx, 1);
|
||
}
|
||
|
||
function onJsonTypeChange(val: string) {
|
||
const childrenCount = (form as any)._childrenCount || 0;
|
||
const prevType = prevJsonType.value;
|
||
|
||
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch', null: 'string', object: 'string', array: 'string' };
|
||
form.fieldType = m[val] || 'string';
|
||
|
||
if (!form._createParentId && childrenCount > 0 && prevType && prevType !== val) {
|
||
ElMessageBox.confirm('更改字段类型将删除该节点下所有子节点数据,是否继续?', '确认操作', {
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
draggable: true,
|
||
})
|
||
.then(() => {
|
||
prevJsonType.value = val;
|
||
})
|
||
.catch(() => {
|
||
form.jsonType = prevType;
|
||
});
|
||
} else {
|
||
prevJsonType.value = val;
|
||
}
|
||
}
|
||
|
||
function onTypeChange() {
|
||
if (form.fieldType !== 'upload') {
|
||
delete form.constraint.accept;
|
||
delete form.constraint.maxSize;
|
||
delete form.constraint.maxCount;
|
||
delete form.constraint.uploadRules;
|
||
uploadRules.value = [];
|
||
}
|
||
if (form.fieldType !== 'string' && form.fieldType !== 'textarea') {
|
||
delete form.constraint.minLength;
|
||
delete form.constraint.maxLength;
|
||
delete form.constraint.pattern;
|
||
}
|
||
if (form.fieldType !== 'number') {
|
||
delete form.constraint.min;
|
||
delete form.constraint.max;
|
||
delete form.constraint.numberType;
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.visible,
|
||
(val) => {
|
||
if (val) loadForm(props.formData);
|
||
}
|
||
);
|
||
|
||
function handleSave() {
|
||
syncUploadRulesToConstraint();
|
||
emit('save', { ...form, constraint: { ...form.constraint } });
|
||
}
|
||
|
||
function handleRemove() {
|
||
emit('remove');
|
||
}
|
||
function handleCancel() {
|
||
emit('update:visible', false);
|
||
}
|
||
function handleClosed() {
|
||
resetForm();
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
|
||
.form-hint {
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.options-editor {
|
||
width: 100%;
|
||
.option-row {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.add-option-btn {
|
||
font-size: 12px;
|
||
margin-top: 4px;
|
||
}
|
||
}
|
||
|
||
.upload-rules {
|
||
width: 100%;
|
||
.upload-rule-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
margin-bottom: 6px;
|
||
}
|
||
.add-rule-btn {
|
||
font-size: 12px;
|
||
margin-top: 2px;
|
||
}
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 12px;
|
||
}
|
||
</style>
|