v2模型管理相关
This commit is contained in:
@@ -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">{...}</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
|
||||
@@ -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="例如: { "name": "test", "age": 18 }"
|
||||
: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>
|
||||
Vendored
+11
@@ -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>
|
||||
Reference in New Issue
Block a user