将旧的分级 upLevel/remove 联动规则改为基于完整路径的平铺结构。
新增特性:
- 标量字段(string/number/boolean)配置面板中增加"容器字段"开关
- 开关打开后展示"联动隐藏"下拉菜单,列出当前字段同级及以上所有可选字段
- 下拉展示格式:"字段key — 完整路径"(如 type — messages.attrs[0].content.attrs[0].type)
- 选中后以 { key: path } 格式输出,渲染器按路径直接定位隐藏字段
核心改动:
- index.vue: 新增 getAvailableFields / getNodeJsonPath 替代旧的分级遍历逻辑
- getAvailableFields: 从当前字段向上遍历,跳过数组层级,根级只含容器自身
- getNodeJsonPath: inAttrs 状态机生成完整路径(messages.attrs[0].content.attrs[0].type)
- linkRules 类型从 Array<{ upLevel, remove[] }> 改为 Array<Record<string, string>>
- formToConfig / hasAnyConfig 同步更新
- FieldConfigDialog.vue: 重构联动隐藏UI,单 el-select multiple 替代分级 checkbox
- JsonNode.vue: 类型同步 + 摘要显示容器触发器及规则数
数据格式示例:
"isContainer": true,
"linkRules": [
{ "type": "messages.attrs[0].content.attrs[0].type" }
]
266 lines
12 KiB
Vue
266 lines
12 KiB
Vue
<template>
|
|
<div class="json-node">
|
|
<template v-if="node.type === 'object'">
|
|
<div class="node-row node-row-object" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
|
<span class="collapse-btn" @click="toggleCollapse">
|
|
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
|
</span>
|
|
<span class="key-label">{{ node.key }}</span>
|
|
<span class="colon">:</span>
|
|
<span class="type-label">object</span>
|
|
<span class="node-badge badge-object">{...}</span>
|
|
<span class="node-count">{{ node.children.length }} 项</span>
|
|
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
|
<el-icon><Plus /></el-icon>
|
|
</el-button>
|
|
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
|
|
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
|
</el-button>
|
|
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
|
<el-icon><Delete /></el-icon>
|
|
</el-button>
|
|
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
|
|
</div>
|
|
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
|
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'object'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="node.type === 'array'">
|
|
<div class="node-row node-row-array" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
|
<span class="collapse-btn" @click="toggleCollapse">
|
|
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
|
</span>
|
|
<span class="key-label">{{ node.key }}</span>
|
|
<span class="colon">:</span>
|
|
<span class="type-label">array</span>
|
|
<span class="node-badge badge-array">[...]</span>
|
|
<span class="node-count">{{ node.children.length }} 项</span>
|
|
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
|
<el-icon><Plus /></el-icon>
|
|
</el-button>
|
|
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
|
|
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
|
</el-button>
|
|
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
|
<el-icon><Delete /></el-icon>
|
|
</el-button>
|
|
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
|
|
</div>
|
|
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
|
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'array'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="node-row node-row-primitive" :style="{ paddingLeft: depth * 20 + 'px' }">
|
|
<span class="indent-placeholder" v-if="depth > 0"></span>
|
|
<span class="key-label">{{ node.key }}</span>
|
|
<span class="colon">:</span>
|
|
<span class="type-label">{{ node.type }}</span>
|
|
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
|
|
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
|
</el-button>
|
|
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click="deleteSelf">
|
|
<el-icon><Delete /></el-icon>
|
|
</el-button>
|
|
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, inject } from 'vue';
|
|
import { ElMessage } from 'element-plus';
|
|
import { Plus, Delete, CaretRight, CaretBottom, Setting } from '@element-plus/icons-vue';
|
|
|
|
defineOptions({ name: 'JsonNode' });
|
|
|
|
interface JsonNodeData {
|
|
_id: string; key: string; keyEditable: boolean;
|
|
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
|
primitiveValue: string | number | boolean | null;
|
|
children: JsonNodeData[];
|
|
config?: {
|
|
fieldType?: string;
|
|
label?: string;
|
|
description?: string;
|
|
role?: string;
|
|
isForm?: boolean;
|
|
required?: boolean;
|
|
isContainer?: boolean;
|
|
linkRules?: Array<Record<string, string>>;
|
|
defaultValue?: string | number;
|
|
options?: Array<{ label: string; value: string }>;
|
|
constraint?: Record<string, any>;
|
|
};
|
|
}
|
|
|
|
function hasConfig(node: JsonNodeData): boolean {
|
|
const c = node.config;
|
|
if (!c) return false;
|
|
if (c.label || c.description || c.role || c.defaultValue) return true;
|
|
if (c.options?.length) return true;
|
|
if (c.required) return true;
|
|
if (c.isForm === false) return true;
|
|
if (c.isContainer) return true;
|
|
if (c.linkRules?.length) return true;
|
|
if (c.constraint && Object.keys(c.constraint).length > 0) return true;
|
|
const defaultTypes: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
|
|
if (c.fieldType && c.fieldType !== defaultTypes[node.type]) return true;
|
|
return false;
|
|
}
|
|
|
|
function configSummary(node: JsonNodeData): string {
|
|
const c = node.config;
|
|
if (!c) return '';
|
|
const parts: string[] = [];
|
|
if (c.label) parts.push(c.label);
|
|
if (c.required) parts.push('必填');
|
|
if (c.fieldType) {
|
|
const m: Record<string, string> = {
|
|
string: '文本框', textarea: '多行文本', number: '数字',
|
|
switch: '开关', select: '下拉选择', upload: '文件上传'
|
|
};
|
|
parts.push(m[c.fieldType] || c.fieldType);
|
|
}
|
|
if (c.options?.length) parts.push(`${c.options.length}个选项`);
|
|
if (c.defaultValue !== undefined && c.defaultValue !== '' && c.defaultValue !== null) {
|
|
parts.push(`默认=${c.defaultValue}`);
|
|
}
|
|
if (c.isContainer) {
|
|
parts.push("容器触发器");
|
|
if (c.linkRules?.length) parts.push(`联动 ${c.linkRules.length} 条规则`);
|
|
}
|
|
const con = c.constraint;
|
|
if (con) {
|
|
if (con.minLength || con.maxLength) {
|
|
if (con.minLength && con.maxLength) parts.push(`${con.minLength}-${con.maxLength}字`);
|
|
else if (con.minLength) parts.push(`≥${con.minLength}字`);
|
|
else if (con.maxLength) parts.push(`≤${con.maxLength}字`);
|
|
}
|
|
if (con.min !== undefined || con.max !== undefined) {
|
|
if (con.min !== undefined && con.max !== undefined) parts.push(`${con.min}-${con.max}`);
|
|
else if (con.min !== undefined) parts.push(`≥${con.min}`);
|
|
else if (con.max !== undefined) parts.push(`≤${con.max}`);
|
|
}
|
|
if (con.pattern) parts.push('正则');
|
|
if (con.numberType === 'integer') parts.push('整数');
|
|
if (con.numberType === 'float') parts.push('浮点');
|
|
}
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
const props = defineProps<{
|
|
node: JsonNodeData; depth: number; isRoot?: boolean;
|
|
parentType?: 'object' | 'array' | 'root'; parentCanDelete?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'delete-node'): void;
|
|
(e: 'change'): void;
|
|
(e: 'add-requested', parentNodeId: string): void;
|
|
}>();
|
|
const onOpenFieldDialog = inject<((nodeId: string) => void) | undefined>("onOpenFieldDialog");
|
|
|
|
const collapsed = ref(false);
|
|
function toggleCollapse() { collapsed.value = !collapsed.value; }
|
|
|
|
function nodeToValue(node: JsonNodeData): any {
|
|
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
|
|
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
|
|
if (node.type === 'null') return null;
|
|
if (node.type === 'number') return Number(node.primitiveValue);
|
|
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
|
|
return String(node.primitiveValue);
|
|
}
|
|
|
|
function copyNodeJson() {
|
|
const val = nodeToValue(props.node);
|
|
const json = JSON.stringify(val, null, 2);
|
|
navigator.clipboard.writeText(json).then(() => {
|
|
ElMessage.success('JSON 已复制到剪贴板');
|
|
}).catch(() => {
|
|
// Fallback
|
|
const ta = document.createElement('textarea');
|
|
ta.value = json;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
ElMessage.success('JSON 已复制到剪贴板');
|
|
});
|
|
}
|
|
|
|
function removeChild(childId: string) {
|
|
const n = props.node;
|
|
const idx = n.children.findIndex((c) => c._id === childId);
|
|
if (idx === -1) return;
|
|
n.children.splice(idx, 1);
|
|
if (n.type === 'array') {
|
|
n.children.forEach((child, i) => { child.key = String(i); });
|
|
}
|
|
emitChange();
|
|
}
|
|
|
|
function deleteSelf() { emit('delete-node'); }
|
|
function emitChange() { emit('change'); }
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.json-node {
|
|
.node-row {
|
|
display: flex; align-items: center; gap: 4px;
|
|
padding: 3px 4px; border-radius: 3px; transition: background 0.15s;
|
|
min-height: 30px; flex-wrap: nowrap;
|
|
&:hover { background: #f5f7fa;
|
|
.node-action-btn:not(.always-visible) { opacity: 1; }
|
|
}
|
|
.collapse-btn {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 18px; height: 18px; cursor: pointer; color: #94a3b8;
|
|
flex-shrink: 0; font-size: 12px;
|
|
&:hover { color: #3b82f6; }
|
|
}
|
|
.indent-placeholder { display: inline-block; width: 18px; flex-shrink: 0; }
|
|
.key-label { font-family: 'Consolas', monospace; font-size: 13px; color: #881391; font-weight: 500; white-space: nowrap; min-width: 20px; max-width: 180px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; cursor: default; }
|
|
.colon { color: #94a3b8; font-weight: 600; flex-shrink: 0; margin-right: 2px; }
|
|
.type-label {
|
|
font-family: 'Consolas', monospace; font-size: 11px; color: #64748b;
|
|
background: #f1f5f9; padding: 1px 5px; border-radius: 3px;
|
|
flex-shrink: 0; line-height: 18px;
|
|
}
|
|
.node-badge {
|
|
font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
|
|
&.badge-object { color: #2563eb; background: #eff6ff; }
|
|
&.badge-array { color: #7c3aed; background: #f5f3ff; }
|
|
}
|
|
.node-count { font-size: 11px; color: #94a3b8; flex-shrink: 0; }
|
|
.config-summary {
|
|
font-size: 11px; color: #64748b; margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
|
}
|
|
.field-btn-configured {
|
|
color: #2563eb !important;
|
|
}
|
|
.node-action-btn:not(.always-visible) { opacity: 0; transition: opacity 0.15s; margin-left: 2px; flex-shrink: 0; font-size: 14px; padding: 2px; }
|
|
.node-action-btn.field-btn { opacity: 0.4; }
|
|
.node-row:hover .node-action-btn.field-btn { opacity: 1; }
|
|
.node-action-btn.always-visible { opacity: 1; }
|
|
&:hover .node-action-btn { opacity: 1; }
|
|
.node-action-delete { color: #ef4444; }
|
|
}
|
|
.node-row-object, .node-row-array {
|
|
background: #fafbfc; border: 1px solid transparent;
|
|
&:hover { border-color: #e2e8f0; }
|
|
&.is-collapsed { border-color: transparent; background: transparent; }
|
|
}
|
|
.node-children {
|
|
border-left: 1px solid #e2e8f0;
|
|
}
|
|
}
|
|
</style>
|