v2模型管理相关

This commit is contained in:
2026-07-14 16:30:26 +08:00
parent f179a5b755
commit a6eaf57898
8 changed files with 1634 additions and 107 deletions
+64 -29
View File
@@ -1,5 +1,28 @@
import request from '/@/utils/request';
/** Token 映射 */
export interface TokenMapping {
promptTokens: string;
completionTokens: string;
totalTokens: string;
}
/** 异步任务映射 */
export interface AsyncTaskMapping {
url: string;
httpMethod: string;
requestHeadMapping: Record<string, unknown>;
responseMapping: Record<string, unknown>;
taskId: string;
taskStatus: string;
taskStatusPending: string;
taskStatusRunning: string;
taskStatusSuccess: string;
taskStatusFailed: string;
taskStatusCancel: string;
taskStatusUnknown: string;
}
/** 列表查询参数 */
export interface ListModelManageParams {
pageNum: number;
@@ -21,16 +44,25 @@ export interface ModelManageItem {
modelName: string;
modelType: number;
baseUrl: string;
systemModel: boolean;
httpMethod: string;
PrivateModel: boolean;
ChatModel: boolean;
invokeType: number;
responseType: number;
apiKey: string;
enabled: boolean;
requestMapping: Record<string, unknown>;
responseMapping: Record<string, unknown>;
ChatModel: boolean;
maxConcurrency: number;
maxTokens: number;
tokenPredictPrice: number;
requestHeadMapping: Record<string, unknown>;
requestBodyMapping: Record<string, unknown>;
responseMapping: Record<string, unknown>;
tokenMapping?: TokenMapping;
asyncTaskMapping?: AsyncTaskMapping;
lastFrame?: string;
// 以下旧字段兼容
httpMethod?: string;
systemModel?: boolean;
PrivateModel?: boolean;
invokeType?: number;
requestMapping?: Record<string, unknown>;
}
/** 列表响应 */
@@ -59,6 +91,21 @@ export interface ModelManageTypeResponse {
};
}
/** 供应商列表项 */
export interface ModelSupplierItem {
value: number;
label: string;
}
/** 供应商列表响应 */
export interface ModelSupplierListResponse {
code: number;
message: string;
data: {
list: ModelSupplierItem[];
};
}
/**
* 获取模型配置列表(v2
*/
@@ -91,21 +138,6 @@ export function deleteModelManage(id: string) {
});
}
/** 供应商列表项 */
export interface ModelSupplierItem {
value: number;
label: string;
}
/** 供应商列表响应 */
export interface ModelSupplierListResponse {
code: number;
message: string;
data: {
list: ModelSupplierItem[];
};
}
/**
* 获取模型供应商列表
*/
@@ -122,16 +154,19 @@ export interface CreateModelManageParams {
modelName: string;
modelType: number;
baseUrl: string;
httpMethod: string;
systemModel?: boolean;
privateModel?: boolean;
responseType: number;
apiKey?: string;
enabled?: boolean;
chatModel?: boolean;
invokeType?: number;
apiKey?: string;
requestMapping?: Record<string, unknown>;
responseMapping?: Record<string, unknown>;
maxConcurrency?: number;
maxTokens?: number;
tokenPredictPrice?: number;
requestHeadMapping?: Record<string, unknown>;
requestBodyMapping?: Record<string, unknown>;
responseMapping?: Record<string, unknown>;
tokenMapping?: TokenMapping;
asyncTaskMapping?: AsyncTaskMapping;
lastFrame?: string;
}
/** 更新模型配置参数 */
@@ -0,0 +1,361 @@
<template>
<!-- panel mode: embedded sidebar -->
<template v-if="panel">
<div class="config-panel">
<div class="config-panel-title">{{ dialogTitle }}</div>
<div class="config-panel-body">
<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="object" />
<el-option label="数组" value="array" />
</el-select>
<div class="form-hint">JSON 数据类型</div>
</el-form-item>
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
<template v-if="form.jsonType === 'boolean'">
<el-select v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
</template>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
</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>
<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>
<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>
</div>
<div class="config-panel-actions">
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
<el-button @click="handleCancel">关闭</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</div>
</template>
<!-- dialog mode: popup overlay -->
<template v-else>
<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="object" />
<el-option label="数组" value="array" />
</el-select>
<div class="form-hint">JSON 数据类型</div>
</el-form-item>
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
<template v-if="form.jsonType === 'boolean'">
<el-select v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
</template>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
</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>
<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>
<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>
</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;
panel?: 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; 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; form._isArrayParent = data._isArrayParent ?? false;
// @ts-ignore
form._childrenCount = data._childrenCount ?? 0;
if (data.constraint?.uploadRules?.length) {
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) {
uploadRules.value = formats.map((f) => ({ format: f, maxSize: data.constraint?.maxSize, maxCount: data.constraint?.maxCount }));
}
}
}
function syncUploadRulesToConstraint() {
if (uploadRules.value.some((r) => r.format)) {
form.constraint.uploadRules = uploadRules.value.filter((r) => r.format).map((r) => ({ ...r }));
} else { delete form.constraint.uploadRules; }
}
function addUploadRule() { uploadRules.value.push({ format: '', maxSize: undefined, maxCount: undefined }); }
function removeUploadRule(idx: number) { uploadRules.value.splice(idx, 1); }
async 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) {
try {
await ElMessageBox.confirm('更改字段类型将删除该节点下所有子节点数据,是否继续?', '确认操作', {
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', draggable: true,
});
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); }, { immediate: true });
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">
.config-panel {
height: 100%; display: flex; flex-direction: column;
.config-panel-title {
font-size: 14px; font-weight: 600; color: #333;
padding: 12px 16px; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
.config-panel-body {
flex: 1; overflow-y: auto; padding: 12px 16px;
:deep(.el-divider__text) { font-size: 12px; }
}
.config-panel-actions {
display: flex; gap: 8px; justify-content: flex-end;
padding: 12px 16px; border-top: 1px solid #e2e8f0; flex-shrink: 0;
}
}
.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>
@@ -0,0 +1,257 @@
<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">&#123;...&#125;</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;
defaultValue?: string | number;
options?: 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.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}`);
}
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>
@@ -0,0 +1,6 @@
import JsonEditor from './index.vue'
import JsonNode from './JsonNode.vue'
import FieldConfigDialog from './FieldConfigDialog.vue'
export { JsonEditor, JsonNode, FieldConfigDialog }
export default JsonEditor
+512
View File
@@ -0,0 +1,512 @@
<template>
<div class="json-editor-split">
<div class="tree-panel">
<div class="tree-root">
<JsonNode
:node="rootNode" :depth="0" :is-root="true"
@change="emitChange"
@add-requested="openAddChildDialog"
/>
</div>
</div>
<div v-if="selectNodeId" class="config-sidebar">
<FieldConfigDialog
:key="selectNodeId"
:panel="true"
:visible="true"
:form-data="fieldDialog.formData"
:field-type-options="fieldDialog.typeOptions"
:has-config="fieldDialog.hasConfig"
@update:visible="clearSelection"
@save="handleFieldSave"
@remove="handleFieldRemove"
/>
</div>
<div v-else class="config-sidebar config-sidebar-empty">
<div class="config-placeholder">
<p>选择一个字段</p>
<p class="config-placeholder-hint">点击字段右侧的齿轮按钮进行配置</p>
</div>
</div>
<el-dialog v-model="showPasteDialog" title="粘贴 JSON" width="560px" :close-on-click-modal="false" destroy-on-close>
<div class="paste-tip">
<el-alert type="info" :closable="false" show-icon>
<template #default>
JSON 字符串粘贴到下方文本框系统将解析并<strong>替换</strong>当前节点的内容
将替换整个根节点
</template>
</el-alert>
</div>
<el-input
v-model="pasteJsonText"
type="textarea"
:rows="12"
placeholder="例如:&#10;{&#10; &quot;name&quot;: &quot;test&quot;,&#10; &quot;age&quot;: 18&#10;}"
:autosize="{ minRows: 8, maxRows: 24 }"
/>
<template #footer>
<el-button @click="showPasteDialog = false; pasteJsonText = ''">取消</el-button>
<el-button type="primary" @click="handlePasteJson">解析并替换</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, provide } from 'vue';
import { ElMessage } from 'element-plus';
import JsonNode from './JsonNode.vue';
import FieldConfigDialog from './FieldConfigDialog.vue';
defineOptions({ name: 'JsonEditor' });
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits<{
(e: 'update:modelValue', value: any): void;
(e: 'change', value: any): void;
}>();
// --- types ---
interface FieldConfig {
fieldType?: string;
label?: string;
description?: string;
role?: string;
isForm?: boolean;
required?: boolean;
defaultValue?: string | number;
options?: string[];
constraint?: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
}
interface JsonNodeData {
_id: string; key: string; keyEditable: boolean;
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
primitiveValue: string | number | boolean | null;
children: JsonNodeData[];
config?: FieldConfig;
}
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?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
_createParentId?: string;
_isArrayParent?: boolean;
_childrenCount?: number;
}
// --- helpers ---
let idCounter = 0;
function genId(): string {
return `jn_${Date.now()}_${++idCounter}_${Math.random().toString(36).slice(2, 6)}`;
}
function getDefaultPrimitive(type: string): string | number | boolean | null {
switch (type) {
case 'number': return 0;
case 'boolean': return false;
case 'null': return null;
default: return '';
}
}
function valueToNode(value: any, key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
if (value === null || value === undefined)
return { _id: id, key, keyEditable, type: 'null', primitiveValue: null, children: [] };
// Detect embedded config: { type: '<jsonType>', value/attrs: <actualValue>, ...config }
if (typeof value === 'object' && !Array.isArray(value) && 'type' in value) {
const jtype = value.type;
if (typeof jtype === 'string' && ['string','number','boolean','null','object','array'].includes(jtype)) {
const dataKey = (jtype === 'object' || jtype === 'array') ? 'attrs' : 'value';
if (dataKey in value) {
const rawVal = value[dataKey];
const cfg: Record<string, any> = {};
for (const k of Object.keys(value)) {
if (k !== 'type' && k !== dataKey) cfg[k] = (value as Record<string, any>)[k];
}
const hasCfg = Object.keys(cfg).length > 0;
if (jtype === 'object') {
const children = rawVal ? Object.entries(rawVal).map(([k, v]) => valueToNode(v, k, true)) : [];
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children, config: hasCfg ? cfg : undefined };
}
if (jtype === 'array') {
const children = Array.isArray(rawVal) ? rawVal.map((item, idx) => valueToNode(item, String(idx), false)) : [];
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children, config: hasCfg ? cfg : undefined };
}
return {
_id: id, key, keyEditable,
type: jtype as 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array',
primitiveValue: rawVal ?? getDefaultPrimitive(jtype),
children: [],
config: hasCfg ? cfg : undefined,
};
}
}
}
if (Array.isArray(value))
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children: value.map((item, idx) => valueToNode(item, String(idx), false)) };
if (typeof value === 'object')
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children: Object.entries(value).map(([k, v]) => valueToNode(v, k, true)) };
return { _id: id, key, keyEditable, type: typeof value as 'string' | 'number' | 'boolean', primitiveValue: value, children: [] };
}
function nodeToValue(node: JsonNodeData): any {
let rawValue: any;
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); rawValue = obj; }
else if (node.type === 'array') rawValue = node.children.map(c => nodeToValue(c));
else if (node.type === 'null') rawValue = null;
else if (node.type === 'number') rawValue = Number(node.primitiveValue);
else if (node.type === 'boolean') rawValue = node.primitiveValue === true || node.primitiveValue === 'true';
else rawValue = String(node.primitiveValue);
if (node.config && hasAnyConfig(node)) {
const dataKey = (node.type === 'object' || node.type === 'array') ? 'attrs' : 'value';
return { type: node.type, [dataKey]: rawValue, ...node.config };
}
return rawValue;
}
function defaultFormType(jsonType: string): string {
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
return m[jsonType] || 'string';
}
// --- paste JSON ---
const showPasteDialog = ref(false);
const pasteJsonText = ref('');
const pastingNodeId = ref<string | null>(null);
function openPasteDialog(nodeId: string | null) {
pastingNodeId.value = nodeId;
pasteJsonText.value = '';
showPasteDialog.value = true;
}
function handlePasteJson() {
const text = pasteJsonText.value.trim();
if (!text) { ElMessage.warning('请粘贴 JSON 内容'); return; }
try {
const parsed = JSON.parse(text);
if (pastingNodeId.value) {
const node = findNodeById(pastingNodeId.value);
if (!node || (node.type !== 'object' && node.type !== 'array')) {
ElMessage.warning('只能在对象或数组节点上粘贴 JSON');
return;
}
if (Array.isArray(parsed)) {
node.type = 'array';
node.children = parsed.map((item, idx) => valueToNode(item, String(idx), false));
} else if (typeof parsed === 'object' && parsed !== null) {
node.type = 'object';
node.children = Object.entries(parsed).map(([k, v]) => valueToNode(v, k, true));
} else {
ElMessage.warning('粘贴的内容必须是对象或数组');
return;
}
} else {
rootNode.value = valueToNode(parsed, '', false);
}
showPasteDialog.value = false;
pasteJsonText.value = '';
emitChange();
} catch (e: any) {
ElMessage.error('JSON 格式错误: ' + (e.message || ''));
}
}
// --- field config dialog ---
const typeOptionList: { label: string; value: string }[] = [
{ label: '文本框', value: 'string' },
{ label: '多行文本', value: 'textarea' },
{ label: '数字输入', value: 'number' },
{ label: '开关', value: 'switch' },
{ label: '下拉选择', value: 'select' },
{ label: '文件上传', value: 'upload' },
];
const selectNodeId = ref<string | null>(null);
const fieldDialog = ref({
visible: false,
formData: null as FieldFormData | null,
typeOptions: [] as { label: string; value: string }[],
hasConfig: false,
});
function clearSelection() {
selectNodeId.value = null;
fieldDialog.value.visible = false;
fieldDialog.value.formData = null;
}
/** Extract FieldConfig from form data */
function formToConfig(form: FieldFormData): FieldConfig {
const cfg: FieldConfig = {};
// 骨架字段:始终发送,保证后端收到完整结构
cfg.label = form.label || '';
cfg.fieldType = form.fieldType || defaultFormType(form.jsonType);
cfg.isForm = form.isForm ?? true;
cfg.required = form.required ?? false;
// 非骨架字段:有值才发
if (form.description) cfg.description = form.description;
if (form.role) cfg.role = form.role;
if (form.defaultValue) cfg.defaultValue = form.jsonType === 'number' ? Number(form.defaultValue) : form.defaultValue;
if (form.options?.length > 0) cfg.options = [...form.options];
const hasC = Object.values(form.constraint).some((v: any) => {
if (Array.isArray(v)) return v.length > 0;
return v !== undefined && v !== null && v !== '';
});
if (hasC) {
cfg.constraint = { ...form.constraint };
if (cfg.constraint.uploadRules) {
cfg.constraint.uploadRules = cfg.constraint.uploadRules.map(r => ({ ...r }));
}
}
return cfg;
}
/** Check if a node has any meaningful config beyond default skeleton values */
function hasAnyConfig(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.constraint && Object.keys(c.constraint).length > 0) return true;
if (c.fieldType && c.fieldType !== defaultFormType(node.type)) return true;
return false;
}
/** Open dialog in edit mode for an existing node */
function openFieldDialog(nodeId: string) {
const node = findNodeById(nodeId);
if (!node) return;
const cfg = node.config || {};
fieldDialog.value = {
visible: true,
typeOptions: typeOptionList,
hasConfig: hasAnyConfig(node),
formData: {
nodeId: node._id,
nodeKey: node.key,
jsonType: node.type,
fieldType: cfg.fieldType || defaultFormType(node.type),
label: cfg.label || '',
description: cfg.description || '',
role: cfg.role || '',
isForm: cfg.isForm ?? true,
required: cfg.required ?? false,
defaultValue: String(cfg.defaultValue ?? ''),
options: cfg.options || [],
constraint: cfg.constraint ? { ...cfg.constraint } : {},
_childrenCount: node.children.length,
},
};
selectNodeId.value = nodeId;
}
provide('onOpenFieldDialog', openFieldDialog);
/** Open dialog in create mode for adding a new child to a parent node */
function openAddChildDialog(parentNodeId: string) {
const parent = findNodeById(parentNodeId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
fieldDialog.value = {
visible: true,
typeOptions: typeOptionList,
hasConfig: false,
formData: {
nodeId: '',
nodeKey: '',
jsonType: 'string',
fieldType: 'string',
label: '',
description: '',
role: '',
isForm: true,
required: false,
defaultValue: '',
options: [],
constraint: {},
_createParentId: parentNodeId,
_isArrayParent: isArray,
_childrenCount: 0,
},
};
selectNodeId.value = parentNodeId;
}
/** Handle save from the config dialog — supports both create and edit modes */
function handleFieldSave(form: FieldFormData) {
if (form._createParentId) {
// --- create mode ---
const parent = findNodeById(form._createParentId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
const newNode: JsonNodeData = {
_id: genId(),
key: isArray ? (form.nodeKey || String(parent.children.length)) : (form.nodeKey || 'newKey'),
keyEditable: !isArray,
type: form.jsonType as JsonNodeData['type'],
primitiveValue: getDefaultPrimitive(form.jsonType),
children: (form.jsonType === 'object' || form.jsonType === 'array') ? [] : [],
config: formToConfig(form),
};
parent.children.push(newNode);
fieldDialog.value.visible = false;
emitChange();
clearSelection();
} else {
// --- edit mode ---
const node = findNodeById(form.nodeId);
if (!node) return;
node.key = form.nodeKey;
node.type = form.jsonType as JsonNodeData['type'];
node.config = formToConfig(form);
if (node.type === 'object' || node.type === 'array') {
if (!node.children) node.children = [];
} else {
node.children = [];
if (node.primitiveValue === undefined || node.primitiveValue === null || node.type === 'null') {
node.primitiveValue = getDefaultPrimitive(node.type);
}
}
fieldDialog.value.visible = false;
emitChange();
clearSelection();
}
}
function handleFieldRemove() {
const form = fieldDialog.value.formData;
if (!form) return;
if (form._createParentId) {
fieldDialog.value.visible = false;
clearSelection();
return;
}
const node = findNodeById(form.nodeId);
if (node) {
delete node.config;
fieldDialog.value.visible = false;
emitChange();
clearSelection();
}
}
// --- root node management ---
function createDefaultNode(type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array', key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
const base = { _id: id, key, keyEditable, children: [] as JsonNodeData[] };
switch (type) {
case 'object': return { ...base, type: 'object', primitiveValue: null };
case 'array': return { ...base, type: 'array', primitiveValue: null };
case 'null': return { ...base, type: 'null', primitiveValue: null };
case 'number': return { ...base, type: 'number', primitiveValue: 0 };
case 'boolean': return { ...base, type: 'boolean', primitiveValue: false };
default: return { ...base, type: 'string', primitiveValue: '' };
}
}
const rootNode = ref<JsonNodeData>(createDefaultNode('object', '', false));
let skipNextWatch = false;
watch(() => props.modelValue, (val) => {
if (skipNextWatch) { skipNextWatch = false; return; }
rootNode.value = val === undefined || val === null ? createDefaultNode('object', '', false) : valueToNode(val, '', false);
}, { deep: true, immediate: true });
function emitChange() {
skipNextWatch = true;
const json = nodeToValue(rootNode.value);
emit('update:modelValue', json);
emit('change', json);
}
function findNodeById(nodeId: string, from?: JsonNodeData): JsonNodeData | null {
const s = (n: JsonNodeData): JsonNodeData | null => {
if (n._id === nodeId) return n;
for (const c of n.children) { const f = s(c); if (f) return f; }
return null;
};
return s(from || rootNode.value);
}
defineExpose({
getData: () => nodeToValue(rootNode.value),
setData: (d: any) => { rootNode.value = valueToNode(d, '', false); },
openPasteDialog: () => openPasteDialog(null),
getSelectedNodeId: () => selectNodeId.value,
});
</script>
<style scoped lang="scss">
.json-editor-split {
display: flex;
height: 100%;
.tree-panel {
flex: 1;
min-width: 0;
overflow: auto;
.tree-root {
padding: 4px 0;
}
}
.config-sidebar {
width: 320px;
flex-shrink: 0;
border-left: 1px solid #e2e8f0;
overflow: hidden;
}
.config-sidebar-empty {
display: flex;
align-items: center;
justify-content: center;
.config-placeholder {
text-align: center;
color: #94a3b8;
font-size: 13px;
p { margin: 4px 0; }
.config-placeholder-hint { font-size: 11px; color: #c0c8d4; }
}
}
.paste-tip { margin-bottom: 12px;
:deep(.el-alert__description) { font-size: 13px; }
}
}
</style>
+11
View File
@@ -8,3 +8,14 @@ declare module 'json-schema-editor' {
export const FieldConfigDialog: DefineComponent;
export const JsonNode: DefineComponent;
}
declare module '/@/components/json-schema-editor' {
import { DefineComponent } from 'vue';
export const JsonEditor: DefineComponent<{
modelValue?: Record<string, unknown>;
}>;
export const FieldConfigDialog: DefineComponent;
export const JsonNode: DefineComponent;
}
@@ -1,6 +1,8 @@
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="900px" :close-on-click-modal="false" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px" v-loading="detailLoading">
<!-- ========== 基础信息 ========== -->
<el-divider content-position="left">基础信息</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型名称" prop="modelName">
@@ -43,36 +45,31 @@
<el-input v-model="formData.baseUrl" placeholder="请输入模型服务地址" clearable />
</el-form-item>
</el-col>
</el-row>
<!-- ========== 调用配置 ========== -->
<el-divider content-position="left">调用配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求方式" prop="httpMethod">
<el-select v-model="formData.httpMethod" placeholder="请选择请求方式" clearable style="width: 100%">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
<el-form-item label="返回类型" prop="responseType">
<el-select v-model="formData.responseType" placeholder="请选择返回类型" clearable style="width: 100%">
<el-option label="同步" :value="1" />
<el-option label="异步" :value="2" />
<el-option label="流式" :value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="调用模式" prop="invokeType">
<el-select v-model="formData.invokeType" placeholder="请选择调用模式" clearable style="width: 100%">
<el-option label="同步" :value="0" />
<el-option label="异步" :value="1" />
<el-option label="流式" :value="2" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="最大并发" prop="maxConcurrency">
<el-input-number v-model="formData.maxConcurrency" :min="1" :max="1000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="API Key" prop="apiKey">
<el-input v-model="formData.apiKey" type="password" show-password placeholder="请输入调用凭证/密钥" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="私有化模型" prop="privateModel">
<el-switch v-model="formData.privateModel" />
<el-form-item label="对话模型" prop="chatModel">
<el-switch v-model="formData.chatModel" :disabled="!isReasoningModel" />
<span v-if="!isReasoningModel" class="form-hint">仅推理模型支持此设置</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
@@ -81,27 +78,155 @@
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="对话模型" prop="chatModel">
<el-switch v-model="formData.chatModel" />
</el-form-item>
<!-- 占位对齐 -->
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求映射">
<el-button @click="openJsonEditor('request')" style="width: 100%">
配置请求映射 ({{ Object.keys(requestMappingData).length }})
</el-button>
<el-form-item label="最大并发" prop="maxConcurrency">
<el-input-number v-model="formData.maxConcurrency" :min="1" :max="1000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="最大 Token 数" prop="maxTokens">
<el-input-number v-model="formData.maxTokens" :min="0" :max="999999" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="Token 预估价格" prop="tokenPredictPrice">
<el-input-number v-model="formData.tokenPredictPrice" :min="0" :precision="4" :step="0.0001" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- ========== 映射配置 ========== -->
<el-divider content-position="left">映射配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="请求头映射">
<KeyValueEditor v-model="formData.requestHeadMapping" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="请求体映射">
<el-button @click="openMappingEditor('requestBodyMapping')" style="width: 100%">
配置 ({{ Object.keys(formData.requestBodyMapping).length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="响应映射">
<el-button @click="openJsonEditor('response')" style="width: 100%">
配置响应映射 ({{ Object.keys(responseMappingData).length }})
<el-button @click="openMappingEditor('responseMapping')" style="width: 100%">
配置 ({{ Object.keys(formData.responseMapping).length }})
</el-button>
</el-form-item>
</el-col>
</el-row>
<!-- ========== Token 映射配置 ========== -->
<el-divider content-position="left">Token 映射配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="输入 Token 路径">
<el-input v-model="formData.tokenMapping.promptTokens" placeholder="如 data.usage.prompt_tokens" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="输出 Token 路径">
<el-input v-model="formData.tokenMapping.completionTokens" placeholder="如 data.usage.completion_tokens" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="总 Token 路径">
<el-input v-model="formData.tokenMapping.totalTokens" placeholder="如 data.usage.total_tokens" clearable />
</el-form-item>
</el-col>
</el-row>
<!-- ========== 异步任务配置 ========== -->
<template v-if="formData.responseType === 2">
<el-divider content-position="left">异步任务配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="回调 URL">
<el-input v-model="formData.asyncTaskMapping.url" placeholder="请输入异步回调地址" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="回调请求头映射">
<KeyValueEditor v-model="formData.asyncTaskMapping.requestHeadMapping" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="回调响应映射">
<el-button @click="openMappingEditor('asyncResponseMapping')" style="width: 100%">
配置 ({{ Object.keys(formData.asyncTaskMapping.responseMapping).length }})
</el-button>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left" class="sub-divider">任务状态字段路径</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="Task ID 路径">
<el-input v-model="formData.asyncTaskMapping.taskId" placeholder="如 data.task_id" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="任务状态路径">
<el-input v-model="formData.asyncTaskMapping.taskStatus" placeholder="如 data.status" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="待处理值">
<el-input v-model="formData.asyncTaskMapping.taskStatusPending" placeholder="pending" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="运行中值">
<el-input v-model="formData.asyncTaskMapping.taskStatusRunning" placeholder="running" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="成功值">
<el-input v-model="formData.asyncTaskMapping.taskStatusSuccess" placeholder="succeeded" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="失败值">
<el-input v-model="formData.asyncTaskMapping.taskStatusFailed" placeholder="failed" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="取消值">
<el-input v-model="formData.asyncTaskMapping.taskStatusCancel" placeholder="canceled" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="未知值">
<el-input v-model="formData.asyncTaskMapping.taskStatusUnknown" placeholder="unknown" clearable />
</el-form-item>
</el-col>
</el-row>
</template>
<!-- ========== 视频配置 ========== -->
<template v-if="isVideoModel">
<el-divider content-position="left">视频配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="尾帧图像">
<el-input v-model="formData.lastFrame" placeholder="视频的尾帧图像 URL" clearable />
</el-form-item>
</el-col>
</el-row>
</template>
</el-form>
<!-- JSON 编辑器弹窗 -->
@@ -138,12 +263,21 @@
</template>
<script setup lang="ts" name="modelConfigV2EditModule">
import { ref, reactive, computed } from 'vue';
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { JsonEditor } from 'json-schema-editor';
import 'json-schema-editor/dist/style.css';
import { createModelManage, updateModelManage, getModelSupplierList, type ModelManageItem, type ModelTypeTreeNode, type ModelSupplierItem } from '/@/api/settings/modelConfigV2';
import { JsonEditor } from '/@/components/json-schema-editor';
import {
createModelManage,
updateModelManage,
getModelSupplierList,
type ModelManageItem,
type ModelTypeTreeNode,
type ModelSupplierItem,
type TokenMapping,
type AsyncTaskMapping,
} from '/@/api/settings/modelConfigV2';
import KeyValueEditor from './keyValueEditor.vue';
const props = withDefaults(
defineProps<{
@@ -170,47 +304,122 @@ const supplierLoading = ref(false);
const dialogTitle = computed(() => (isEdit.value ? '修改模型配置' : '新增模型配置'));
const submitText = computed(() => (isEdit.value ? '保存修改' : '确 定'));
const defaultTokenMapping: TokenMapping = {
promptTokens: '',
completionTokens: '',
totalTokens: '',
};
const defaultAsyncTaskMapping: AsyncTaskMapping = {
url: '',
httpMethod: 'POST',
requestHeadMapping: {},
responseMapping: {},
taskId: '',
taskStatus: '',
taskStatusPending: '',
taskStatusRunning: '',
taskStatusSuccess: '',
taskStatusFailed: '',
taskStatusCancel: '',
taskStatusUnknown: '',
};
const formData = reactive({
modelName: '',
modelType: [] as number[],
modelSupplier: undefined as number | undefined,
baseUrl: '',
httpMethod: 'POST',
invokeType: 0 as number,
maxConcurrency: 10,
apiKey: '',
privateModel: false,
responseType: undefined as number | undefined,
enabled: true,
chatModel: false,
systemModel: false,
maxConcurrency: 10,
maxTokens: 4096,
tokenPredictPrice: 0,
// 映射
requestHeadMapping: {} as Record<string, unknown>,
requestBodyMapping: {} as Record<string, unknown>,
responseMapping: {} as Record<string, unknown>,
// Token 映射
tokenMapping: { ...defaultTokenMapping },
// 异步任务映射
asyncTaskMapping: { ...defaultAsyncTaskMapping },
// 视频
lastFrame: '',
});
const requestMappingData = ref<Record<string, unknown>>({});
const responseMappingData = ref<Record<string, unknown>>({});
/** 获取选中模型类型路径上的所有标签 */
function getSelectedModelLabels(): string[] {
const path = formData.modelType;
if (!path || path.length === 0) return [];
const labels: string[] = [];
let currentLevel = props.modelTypes;
for (let i = 0; i < path.length; i++) {
const found = currentLevel.find((n) => n.value === path[i]);
if (!found) break;
labels.push(found.label);
currentLevel = found.children || [];
}
return labels;
}
/** 判断是否是视频模型类型 */
const isVideoModel = computed(() => {
return getSelectedModelLabels().some((l) => l.includes('视频') || l.toLowerCase().includes('video'));
});
/** 判断是否是推理模型类型 */
const isReasoningModel = computed(() => {
return getSelectedModelLabels().some((l) => l.includes('推理') || l.toLowerCase().includes('reasoning'));
});
/** 非推理模型时自动关闭对话模型开关 */
watch(isReasoningModel, (val) => {
if (!val) formData.chatModel = false;
});
/** JSON 编辑器弹窗状态 */
const jsonEditorVisible = ref(false);
const jsonEditorData = ref<Record<string, unknown>>({});
const editingMappingType = ref<'request' | 'response'>('request');
const jsonEditorTitle = computed(() => (editingMappingType.value === 'request' ? '编辑请求映射' : '编辑响应映射'));
const editingMappingKey = ref<string>('');
const jsonEditorRef = ref();
/** 查看 JSON 弹窗 */
const viewJsonVisible = ref(false);
const viewJsonText = ref('');
const mappingFieldLabels: Record<string, string> = {
requestHeadMapping: '请求头映射',
requestBodyMapping: '请求体映射',
responseMapping: '响应映射',
asyncResponseMapping: '异步-响应映射',
};
const openJsonEditor = (type: 'request' | 'response') => {
editingMappingType.value = type;
jsonEditorData.value = { ...(type === 'request' ? requestMappingData.value : responseMappingData.value) };
const jsonEditorTitle = computed(() => {
const label = mappingFieldLabels[editingMappingKey.value] || editingMappingKey.value;
return `编辑 ${label}`;
});
/** 获取映射字段的引用 */
function getMappingValue(key: string): Record<string, unknown> {
if (key === 'asyncResponseMapping') return formData.asyncTaskMapping.responseMapping;
return (formData as Record<string, unknown>)[key] as Record<string, unknown>;
}
/** 设置映射字段的值 */
function setMappingValue(key: string, value: Record<string, unknown>) {
if (key === 'asyncResponseMapping') {
formData.asyncTaskMapping.responseMapping = value;
} else {
(formData as Record<string, unknown>)[key] = value;
}
}
const openMappingEditor = (key: string) => {
editingMappingKey.value = key;
jsonEditorData.value = { ...getMappingValue(key) };
jsonEditorVisible.value = true;
};
const confirmJsonEditor = () => {
if (editingMappingType.value === 'request') {
requestMappingData.value = { ...jsonEditorData.value };
} else {
responseMappingData.value = { ...jsonEditorData.value };
}
setMappingValue(editingMappingKey.value, { ...jsonEditorData.value });
jsonEditorVisible.value = false;
};
@@ -223,6 +432,10 @@ const handleViewJson = () => {
viewJsonVisible.value = true;
};
/** 查看 JSON 弹窗 */
const viewJsonVisible = ref(false);
const viewJsonText = ref('');
const formRules: FormRules = {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
modelType: [
@@ -239,7 +452,7 @@ const formRules: FormRules = {
],
modelSupplier: [{ required: true, message: '请选择模型供应商', trigger: 'change' }],
baseUrl: [{ required: true, message: '请输入模型服务地址', trigger: 'blur' }],
httpMethod: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
responseType: [{ required: true, message: '请选择返回类型', trigger: 'change' }],
};
const loadSupplierList = async () => {
@@ -259,16 +472,19 @@ const resetForm = () => {
formData.modelType = [];
formData.modelSupplier = undefined;
formData.baseUrl = '';
formData.httpMethod = 'POST';
formData.invokeType = 0;
formData.maxConcurrency = 10;
formData.apiKey = '';
formData.privateModel = false;
formData.responseType = undefined;
formData.enabled = true;
formData.chatModel = false;
formData.systemModel = false;
requestMappingData.value = {};
responseMappingData.value = {};
formData.maxConcurrency = 10;
formData.maxTokens = 4096;
formData.tokenPredictPrice = 0;
formData.requestHeadMapping = {};
formData.requestBodyMapping = {};
formData.responseMapping = {};
formData.tokenMapping = { ...defaultTokenMapping };
formData.asyncTaskMapping = { ...defaultAsyncTaskMapping };
formData.lastFrame = '';
};
const openDialog = (type: 'add' | 'edit', row?: ModelManageItem) => {
@@ -283,16 +499,20 @@ const openDialog = (type: 'add' | 'edit', row?: ModelManageItem) => {
formData.modelType = [row.modelType];
formData.modelSupplier = Number(row.modelSupplier) || undefined;
formData.baseUrl = row.baseUrl;
formData.httpMethod = row.httpMethod || 'POST';
formData.invokeType = row.invokeType ?? 0;
formData.maxConcurrency = row.maxConcurrency || 10;
formData.apiKey = row.apiKey || '';
formData.privateModel = row.PrivateModel ?? false;
// 兼容新旧字段名
formData.responseType = row.responseType ?? row.invokeType;
formData.enabled = row.enabled ?? true;
formData.chatModel = row.ChatModel ?? false;
formData.systemModel = row.systemModel ?? false;
requestMappingData.value = row.requestMapping || {};
responseMappingData.value = row.responseMapping || {};
formData.maxConcurrency = row.maxConcurrency || 10;
formData.maxTokens = row.maxTokens || 4096;
formData.tokenPredictPrice = row.tokenPredictPrice ?? 0;
formData.requestHeadMapping = row.requestHeadMapping || row.requestMapping || {};
formData.requestBodyMapping = row.requestBodyMapping || {};
formData.responseMapping = row.responseMapping || {};
formData.tokenMapping = row.tokenMapping ? { ...defaultTokenMapping, ...row.tokenMapping } : { ...defaultTokenMapping };
formData.asyncTaskMapping = row.asyncTaskMapping ? { ...defaultAsyncTaskMapping, ...row.asyncTaskMapping } : { ...defaultAsyncTaskMapping };
formData.lastFrame = row.lastFrame || '';
}
dialogVisible.value = true;
@@ -305,28 +525,55 @@ const handleSubmit = async () => {
submitting.value = true;
try {
const params = {
const baseParams = {
modelName: formData.modelName,
modelType: Array.isArray(formData.modelType) ? formData.modelType[formData.modelType.length - 1] : formData.modelType,
modelSupplier: formData.modelSupplier as number,
baseUrl: formData.baseUrl,
httpMethod: formData.httpMethod,
invokeType: formData.invokeType,
maxConcurrency: formData.maxConcurrency,
responseType: formData.responseType as number,
apiKey: formData.apiKey || undefined,
privateModel: formData.privateModel,
enabled: formData.enabled,
chatModel: formData.chatModel,
systemModel: formData.systemModel,
requestMapping: requestMappingData.value,
responseMapping: responseMappingData.value,
maxConcurrency: formData.maxConcurrency,
maxTokens: formData.maxTokens,
tokenPredictPrice: formData.tokenPredictPrice,
requestHeadMapping: Object.keys(formData.requestHeadMapping).length > 0 ? formData.requestHeadMapping : undefined,
requestBodyMapping: Object.keys(formData.requestBodyMapping).length > 0 ? formData.requestBodyMapping : undefined,
responseMapping: Object.keys(formData.responseMapping).length > 0 ? formData.responseMapping : undefined,
};
// 根据返回类型附加对应配置
let tokenMapping: TokenMapping | undefined;
let asyncTaskMapping: AsyncTaskMapping | undefined;
let lastFrame: string | undefined;
if (formData.responseType === 3) {
const hasTokenConfig = Object.values(formData.tokenMapping).some((v) => v);
tokenMapping = hasTokenConfig ? { ...formData.tokenMapping } : undefined;
}
if (formData.responseType === 2) {
const atm = formData.asyncTaskMapping;
const hasAsyncConfig = atm.url || Object.keys(atm.requestHeadMapping).length > 0 || atm.taskId;
asyncTaskMapping = hasAsyncConfig ? ({ ...atm } as AsyncTaskMapping) : undefined;
}
if (formData.lastFrame) {
lastFrame = formData.lastFrame;
}
const params: Record<string, unknown> = {
...baseParams,
...(tokenMapping ? { tokenMapping } : {}),
...(asyncTaskMapping ? { asyncTaskMapping } : {}),
...(lastFrame ? { lastFrame } : {}),
};
if (isEdit.value) {
await updateModelManage({ id: editId.value, ...params });
await updateModelManage({ id: editId.value, ...params } as any);
ElMessage.success('修改成功');
} else {
await createModelManage(params);
await createModelManage(params as any);
ElMessage.success('新增成功');
}
@@ -348,6 +595,11 @@ defineExpose({ openDialog });
margin-bottom: 18px;
}
::v-deep(.sub-divider .el-divider__text) {
font-size: 13px;
color: #909399;
}
.json-editor-toolbar {
display: inline-flex;
gap: 8px;
@@ -0,0 +1,93 @@
<template>
<div class="kv-editor">
<div v-for="(item, index) in kvList" :key="index" class="kv-row">
<el-input v-model="item.key" placeholder="key" size="small" class="kv-key" @input="emitUpdate" />
<el-input v-model="item.value" placeholder="value" size="small" class="kv-value" @input="emitUpdate" />
<el-button size="small" text type="danger" class="kv-del" @click="removeRow(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" class="kv-add" @click="addRow">
<el-icon><Plus /></el-icon> 添加
</el-button>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Plus, Delete } from '@element-plus/icons-vue';
const props = defineProps<{
modelValue: Record<string, unknown>;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', val: Record<string, unknown>): void;
}>();
const kvList = ref<{ key: string; value: string }[]>([]);
function toKvList(obj: Record<string, unknown>): { key: string; value: string }[] {
return Object.entries(obj || {}).map(([k, v]) => ({
key: k,
value: typeof v === 'string' ? v : JSON.stringify(v),
}));
}
function toRecord(list: { key: string; value: string }[]): Record<string, unknown> {
const obj: Record<string, unknown> = {};
for (const item of list) {
if (item.key.trim()) {
obj[item.key.trim()] = item.value;
}
}
return obj;
}
function emitUpdate() {
emit('update:modelValue', toRecord(kvList.value));
}
function addRow() {
kvList.value.push({ key: '', value: '' });
}
function removeRow(index: number) {
kvList.value.splice(index, 1);
emitUpdate();
}
watch(
() => props.modelValue,
(val) => {
kvList.value = toKvList(val);
},
{ immediate: true, deep: true }
);
</script>
<style scoped lang="scss">
.kv-editor {
width: 100%;
.kv-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
align-items: center;
.kv-key {
flex: 1;
min-width: 0;
}
.kv-value {
flex: 2;
min-width: 0;
}
.kv-del {
flex-shrink: 0;
}
}
.kv-add {
font-size: 12px;
}
}
</style>