Files
admin-ui/src/views/settings/modelConfig/modelModule/component/editModule.vue
2026-06-04 10:16:20 +08:00

1693 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="system-edit-module-container">
<el-dialog :title="state.dialog.title" v-model="state.dialog.isShowDialog" width="900px">
<el-form
ref="editModuleFormRef"
v-loading="state.dialog.detailLoading"
:model="state.ruleForm"
:rules="state.rules"
size="default"
label-width="140px"
>
<!-- 基础配置 -->
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="state.ruleForm.modelName" placeholder="请输入模型名称" clearable></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型类型" prop="modelType" required>
<el-select v-model="state.ruleForm.modelType" placeholder="请选择模型类型" clearable style="width: 100%">
<el-option v-for="t in modelTypeOptions" :key="String(t.id)" :label="t.label" :value="typeOptionValue(t.id)"></el-option>
</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="operatorName" required>
<el-select v-model="state.ruleForm.operatorName" placeholder="请选择运营商" clearable style="width: 100%">
<el-option v-for="item in operatorNameOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</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="baseUrl">
<el-input v-model="state.ruleForm.baseUrl" placeholder="请输入模型服务地址" clearable></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求方式" prop="httpMethod">
<el-select v-model="state.ruleForm.httpMethod" placeholder="请选择请求方式" clearable style="width: 100%">
<el-option label="GET" value="GET"></el-option>
<el-option label="POST" value="POST"></el-option>
<el-option label="PUT" value="PUT"></el-option>
<el-option label="DELETE" value="DELETE"></el-option>
</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="queryResponseType">
<el-select v-model="state.ruleForm.queryResponseType" placeholder="请选择响应类型" clearable style="width: 100%">
<el-option v-for="item in queryResponseTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col v-if="state.ruleForm.queryResponseType === 'callback'" :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="回调地址" prop="queryCallbackUrl">
<el-input v-model="state.ruleForm.queryCallbackUrl" placeholder="请输入回调地址" clearable></el-input>
</el-form-item>
</el-col>
<el-col v-if="state.ruleForm.queryResponseType === 'pull'" :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="主动拉取配置" prop="queryPullConfig">
<el-button @click="showPullConfigDialog = true" style="width: 100%">配置主动拉取</el-button>
</el-form-item>
</el-col>
<el-col v-if="!props.isSuperAdmin" :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="访问类型" prop="isPrivate">
<el-radio-group v-model="state.ruleForm.isPrivate" @change="onIsPrivateChange">
<el-radio :label="0">本地模型</el-radio>
<el-radio :label="1">服务商模型</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="!props.isSuperAdmin && state.ruleForm.isPrivate === 1" :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="API 密钥" prop="apiKey" required>
<el-input v-model="state.ruleForm.apiKey" type="password" show-password placeholder="请输入 API 密钥字符串" clearable></el-input>
</el-form-item>
</el-col>
<el-col v-if="!props.isSuperAdmin && state.dialog.type === 'add'" :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="是否对话模型" prop="isChatModel">
<el-radio-group v-model="state.ruleForm.isChatModel">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="执行模式" prop="isAsync">
<el-radio-group v-model="state.ruleForm.isAsync">
<el-radio :label="0">同步</el-radio>
<el-radio :label="1">异步</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求头绑定" prop="headMsg">
<el-button @click="showHeaderDialog = true" style="width: 100%"> 配置请求头 ({{ state.headers.length }}) </el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="自定义字段" prop="form">
<el-button @click="showFormDialog = true" style="width: 100%"> 配置表单字段 ({{ state.formFields.length }}) </el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="是否启用" prop="enabled">
<el-radio-group v-model="state.ruleForm.enabled">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="备注说明" prop="remark">
<el-input v-model="state.ruleForm.remark" type="textarea" placeholder="请输入备注说明" :rows="3" clearable></el-input>
</el-form-item>
</el-col>
</el-row>
<!-- 高级配置折叠区域 -->
<el-divider content-position="left">
<el-button link type="primary" @click="state.showAdvanced = !state.showAdvanced">
{{ state.showAdvanced ? '收起高级配置' : '展开高级配置' }}
<el-icon class="ml5">
<component :is="state.showAdvanced ? 'ArrowUp' : 'ArrowDown'" />
</el-icon>
</el-button>
</el-divider>
<el-collapse-transition>
<el-row v-show="state.showAdvanced" :gutter="20">
<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="state.ruleForm.maxConcurrency" :min="1" :max="1000" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="排队队列上限" prop="queueLimit">
<el-input-number v-model="state.ruleForm.queueLimit" :min="1" :max="10000" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求超时时间(秒)" prop="timeoutSeconds">
<el-input-number v-model="state.ruleForm.timeoutSeconds" :min="1" :max="3600" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="预计执行时间(秒)" prop="expectedSeconds">
<el-input-number v-model="state.ruleForm.expectedSeconds" :min="1" :max="3600" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="失败重试次数" prop="retryTimes">
<el-input-number v-model="state.ruleForm.retryTimes" :min="0" :max="10" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="重试最大排队时间" prop="retryQueueMaxSeconds">
<el-input-number v-model="state.ruleForm.retryQueueMaxSeconds" :min="0" :max="3600" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="自动清理间隔(秒)" prop="autoCleanSeconds">
<el-input-number v-model="state.ruleForm.autoCleanSeconds" :min="0" :max="86400" style="width: 100%"> </el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求映射" prop="requestMapping">
<el-button @click="showRequestMappingDialog = true" style="width: 100%">
配置请求映射 ({{ state.requestMappingFields.length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="响应映射" prop="responseMapping">
<el-button @click="showResponseMappingDialog = true" style="width: 100%">
配置响应映射 ({{ state.responseMappingFields.length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="附加映射" prop="extendMapping">
<el-button @click="showExtendMappingDialog = true" style="width: 100%"
>配置附加映射 ({{ state.extendMappingFields.length }})</el-button
>
</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="tokenConfig">
<el-button @click="showTokenConfigDialog = true" style="width: 100%">配置Token计算 ({{ state.tokenConfigFields.length }})</el-button>
</el-form-item>
</el-col>
</el-row>
</el-collapse-transition>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onCancel" size="default"> </el-button>
<el-button type="primary" @click="onSubmit" size="default" :loading="state.dialog.loading" :disabled="state.dialog.detailLoading">
{{ state.dialog.submitTxt }}
</el-button>
</span>
</template>
</el-dialog>
<!-- 请求头配置弹窗 -->
<el-dialog v-model="showHeaderDialog" title="配置请求头" width="600px" :close-on-click-modal="false">
<div class="header-config-container">
<div v-for="(header, index) in state.headers" :key="index" class="header-item">
<el-input v-model="header.key" placeholder="请输入 Key" style="width: 40%" clearable></el-input>
<span class="separator">:</span>
<el-input v-model="header.value" placeholder="请输入 Value" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="removeHeader(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addHeader" style="margin-top: 10px">+ 添加请求头</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showHeaderDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmHeaders" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 自定义字段配置弹窗 -->
<el-dialog v-model="showFormDialog" title="配置表单字段" width="900px" :close-on-click-modal="false">
<div class="form-config-container">
<div v-for="(field, index) in state.formFields" :key="index" class="form-field-item">
<!-- 删除按钮 - 右上角 -->
<el-button type="danger" link size="small" @click="removeFormField(index)" class="delete-btn">删除</el-button>
<!-- 第一行 -->
<div class="field-row">
<div class="field-col field-col-sm">
<label class="field-label required">字段描述</label>
<el-input v-model="field.label" placeholder="字段显示名称" clearable style="width: 100%"></el-input>
</div>
<div class="field-col field-col-sm">
<label class="field-label required">字段名称</label>
<el-input v-model="field.key" placeholder="字段Key如 user_name" clearable style="width: 100%"></el-input>
</div>
<div class="field-col field-col-sm">
<label class="field-label required">字段类型</label>
<el-select v-model="field.type" placeholder="选择字段类型" clearable style="width: 100%" @change="onFieldTypeChange(index)">
<el-option label="字符串" value="string"></el-option>
<el-option label="数字" value="number"></el-option>
<el-option label="下拉菜单" value="select"></el-option>
<el-option label="单选按钮" value="radio"></el-option>
<el-option label="附件上传" value="file"></el-option>
</el-select>
</div>
<div class="field-col field-col-sm">
<label class="field-label">默认值</label>
<el-input v-model="field.defaultValue" placeholder="默认值" clearable style="width: 100%"></el-input>
</div>
<div class="field-col field-col-sm">
<label class="field-label">是否必填</label>
<div class="switch-wrapper">
<el-switch v-model="field.required"></el-switch>
</div>
</div>
</div>
<!-- 第二行类型特定配置 -->
<div class="field-row">
<!-- 字符串额外配置 -->
<template v-if="field.type === 'string'">
<div class="field-col field-col-sm">
<label class="field-label">最大长度</label>
<el-input-number v-model="field.maxLength" :min="0" :max="10000" placeholder="最大长度" style="width: 100%"></el-input-number>
</div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
<!-- 数字额外配置 -->
<template v-if="field.type === 'number'">
<div class="field-col field-col-sm">
<label class="field-label">数字类型</label>
<el-select v-model="field.numberType" placeholder="选择数字类型" clearable style="width: 100%">
<el-option label="任意数字" value="any"></el-option>
<el-option label="整数" value="integer"></el-option>
<el-option label="浮点数(小数)" value="float"></el-option>
<el-option label="正整数" value="positive-int"></el-option>
<el-option label="正浮点数" value="positive-float"></el-option>
<el-option label="负整数" value="negative-int"></el-option>
<el-option label="负浮点数" value="negative-float"></el-option>
</el-select>
</div>
<div class="field-col field-col-sm">
<label class="field-label">最小值</label>
<el-input-number v-model="field.min" placeholder="最小值" style="width: 100%"></el-input-number>
</div>
<div class="field-col field-col-sm">
<label class="field-label">最大值</label>
<el-input-number v-model="field.max" placeholder="最大值" style="width: 100%"></el-input-number>
</div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
<!-- 附件上传额外配置 -->
<template v-if="field.type === 'file'">
<div class="field-col field-col-sm">
<label class="field-label">最大文件(MB)</label>
<el-input-number v-model="field.maxSize" :min="1" :max="1000" style="width: 100%"></el-input-number>
</div>
<div class="field-col field-col-sm">
<label class="field-label">最大上传数量</label>
<el-input-number v-model="field.maxCount" :min="1" :max="100" :default-value="1" style="width: 100%"></el-input-number>
</div>
<div class="field-col field-col-md">
<label class="field-label">允许格式</label>
<el-input v-model="field.allowedTypes" placeholder=".jpg,.png,.pdf" clearable style="width: 100%"></el-input>
</div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
<!-- 下拉框和单选不需要第二行额外配置 -->
<template v-if="field.type === 'select' || field.type === 'radio'">
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
</div>
<!-- 下拉菜单/单选框 选项配置 -->
<div v-if="field.type === 'select' || field.type === 'radio'" class="options-row">
<div class="options-label">选项配置</div>
<div v-for="(opt, optIndex) in field.options" :key="optIndex" class="option-item">
<div class="option-col">
<el-input v-model="opt.label" placeholder="显示文本" style="width: 100%" clearable></el-input>
</div>
<span class="separator">:</span>
<div class="option-col">
<el-input v-model="opt.value" placeholder="值" style="width: 100%" clearable></el-input>
</div>
<el-button type="danger" link size="small" @click="removeFieldOption(field, optIndex)">删除</el-button>
</div>
<el-button type="primary" link size="small" @click="addFieldOption(field)">+ 添加选项</el-button>
</div>
</div>
<el-button type="primary" link @click="addFormField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showFormDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmFormFields" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 请求映射配置弹窗 -->
<el-dialog v-model="showRequestMappingDialog" title="配置请求映射" width="600px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in state.requestMappingFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="removeRequestMappingField(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addRequestMappingField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showRequestMappingDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmRequestMappingFields" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 响应映射配置弹窗 -->
<el-dialog v-model="showResponseMappingDialog" title="配置响应映射" width="700px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in state.responseMappingFields" :key="index" class="mapping-field-item">
<el-input
v-model="field.key"
placeholder="请输入字段名 (Key)"
style="width: 30%"
clearable
@input="syncTokenFieldOnKeyChange(index)"
></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 30%" clearable></el-input>
<el-button :type="field.isTokenField ? 'warning' : 'primary'" :plain="!field.isTokenField" @click="setTokenField(index)" size="small">
{{ field.isTokenField ? '✓ 计费字段' : '设置计费字段' }}
</el-button>
<el-button :type="field.isMainBody ? 'success' : 'primary'" :plain="!field.isMainBody" @click="setMainBody(index)" size="small">
{{ field.isMainBody ? '✓ 返回主体' : '设置返回主体' }}
</el-button>
<el-button type="danger" link @click="removeResponseMappingField(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addResponseMappingField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showResponseMappingDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmResponseMappingFields" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 主动拉取配置弹窗 -->
<el-dialog v-model="showPullConfigDialog" title="配置主动拉取" width="800px" :close-on-click-modal="false">
<el-form label-width="120px">
<el-form-item label="请求方式">
<el-select v-model="pullConfigForm.method" style="width: 100%">
<el-option label="GET" value="GET"></el-option>
<el-option label="POST" value="POST"></el-option>
<el-option label="PUT" value="PUT"></el-option>
<el-option label="DELETE" value="DELETE"></el-option>
</el-select>
</el-form-item>
<el-form-item label="拉取地址">
<el-input v-model="pullConfigForm.url" placeholder="请输入拉取 URL" clearable></el-input>
</el-form-item>
<el-form-item label="请求头(headers)">
<el-button @click="showPullHeadersDialog = true" style="width: 100%">配置请求头 ({{ pullConfigForm.headersFields.length }})</el-button>
</el-form-item>
<el-form-item label="请求体(body)">
<el-button @click="showPullBodyDialog = true" style="width: 100%">配置请求体 ({{ pullConfigForm.bodyFields.length }})</el-button>
</el-form-item>
<el-form-item label="响应映射(response)">
<el-button @click="showPullResponseDialog = true" style="width: 100%">配置响应字段 ({{ pullConfigForm.responseFields.length }})</el-button>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showPullConfigDialog = false" size="default"> </el-button>
<el-button type="primary" @click="showPullConfigDialog = false" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 附加映射配置 -->
<el-dialog v-model="showExtendMappingDialog" title="配置附加映射" width="600px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in state.extendMappingFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="state.extendMappingFields.splice(index, 1)">删除</el-button>
</div>
<el-button type="primary" link @click="state.extendMappingFields.push({ key: '', value: '' })" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer
><span class="dialog-footer"><el-button @click="showExtendMappingDialog = false"> </el-button></span></template
>
</el-dialog>
<!-- Token计算配置 -->
<el-dialog v-model="showTokenConfigDialog" title="配置Token计算" width="600px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in state.tokenConfigFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="state.tokenConfigFields.splice(index, 1)">删除</el-button>
</div>
<el-button type="primary" link @click="state.tokenConfigFields.push({ key: '', value: '' })" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer
><span class="dialog-footer"><el-button @click="showTokenConfigDialog = false"> </el-button></span></template
>
</el-dialog>
<!-- Pull headers -->
<el-dialog v-model="showPullHeadersDialog" title="配置请求头(headers)" width="600px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in pullConfigForm.headersFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="pullConfigForm.headersFields.splice(index, 1)">删除</el-button>
</div>
<el-button type="primary" link @click="pullConfigForm.headersFields.push({ key: '', value: '' })" style="margin-top: 10px"
>+ 添加字段</el-button
>
</div>
<template #footer
><span class="dialog-footer"><el-button @click="showPullHeadersDialog = false"> </el-button></span></template
>
</el-dialog>
<!-- Pull body -->
<el-dialog v-model="showPullBodyDialog" title="配置请求体(body)" width="600px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in pullConfigForm.bodyFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段路径 (如 task_id.id)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-select v-model="field.value" placeholder="请输入或选择字段值" style="width: 40%" clearable filterable allow-create default-first-option>
<el-option v-for="(item, idx) in responseMappingValueOptions" :key="idx" :label="item" :value="item"> </el-option>
</el-select>
<el-button type="danger" link @click="pullConfigForm.bodyFields.splice(index, 1)">删除</el-button>
</div>
<el-button type="primary" link @click="pullConfigForm.bodyFields.push({ key: '', value: '' })" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer
><span class="dialog-footer"><el-button @click="showPullBodyDialog = false"> </el-button></span></template
>
</el-dialog>
<!-- Pull response -->
<el-dialog v-model="showPullResponseDialog" title="配置响应映射(response)" width="600px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in pullConfigForm.responseFields" :key="index" class="mapping-field-item">
<el-input v-model="field.value" placeholder="请输入响应字段路径 (如 content.video_url)" style="width: 30%" clearable></el-input>
<span class="separator">=</span>
<el-button :type="field.isTokenField ? 'warning' : 'primary'" :plain="!field.isTokenField" @click="setPullTokenField(index)" size="small">
{{ field.isTokenField ? '✓ 计费字段' : '设置计费字段' }}
</el-button>
<el-button :type="field.isMainBody ? 'success' : 'primary'" :plain="!field.isMainBody" @click="setPullMainBody(index)" size="small">
{{ field.isMainBody ? '✓ 返回主体' : '设置返回主体' }}
</el-button>
<el-button type="danger" link @click="pullConfigForm.responseFields.splice(index, 1)">删除</el-button>
</div>
<el-button
type="primary"
link
@click="pullConfigForm.responseFields.push({ value: '', isTokenField: false, isMainBody: false })"
style="margin-top: 10px"
>
+ 添加字段
</el-button>
</div>
<template #footer
><span class="dialog-footer"><el-button @click="showPullResponseDialog = false"> </el-button></span></template
>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="systemEditModule">
import { reactive, ref, computed, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue';
import {
addModelModule,
updateModelModule,
getModelModuleDetail,
getOperatorList,
type ModelFormEntry,
type CreateModelParams,
} from '/@/api/settings/modelConfig/modelModule/index';
export type ModelTypeOption = { id: number | string; label: string };
// 定义自定义字段选项类型
export interface FormFieldOption {
label: string;
value: string;
}
// 定义自定义字段类型
export interface FormField {
label: string; // 字段描述
key: string; // 字段名称
type: 'string' | 'number' | 'select' | 'radio' | 'file'; // 字段类型
defaultValue: string; // 默认值
required: boolean; // 是否必填
// 字符串配置
maxLength?: number; // 最大长度
// 数字配置
min?: number; // 最小值
max?: number; // 最大值
numberType?: 'any' | 'integer' | 'float' | 'positive-int' | 'positive-float' | 'negative-int' | 'negative-float'; // 数字子类型
// 文件上传配置
maxSize?: number; // 最大文件大小(MB)
maxCount?: number; // 最大上传数量
allowedTypes?: string; // 允许的文件格式,逗号分隔
// 下拉框/单选框配置
options?: FormFieldOption[]; // 选项列表
}
const props = withDefaults(
defineProps<{
modelTypes?: ModelTypeOption[];
isSuperAdmin?: boolean;
}>(),
{
modelTypes: () => [] as ModelTypeOption[],
isSuperAdmin: false,
}
);
const modelTypeOptions = computed(() => props.modelTypes);
const responseMappingValueOptions = computed(() => {
return state.responseMappingFields.map((f) => String(f.value || '').trim()).filter((v) => v !== '');
});
const operatorNameOptions = ref<Array<{ label: string; value: string }>>([]);
const loadOperatorOptions = async () => {
try {
const res: any = await getOperatorList();
const list = res?.data?.list;
operatorNameOptions.value = Array.isArray(list)
? list.filter((item: unknown) => typeof item === 'string' && item.trim() !== '').map((name: string) => ({ label: name, value: name }))
: [];
} catch {
operatorNameOptions.value = [];
}
};
const typeOptionValue = (id: number | string): number | string => {
const n = Number(id);
return Number.isNaN(n) ? id : n;
};
const editModuleFormRef = ref();
const emit = defineEmits(['refresh']);
const showHeaderDialog = ref(false);
const showFormDialog = ref(false);
const showRequestMappingDialog = ref(false);
const showResponseMappingDialog = ref(false);
const showPullConfigDialog = ref(false);
const showExtendMappingDialog = ref(false);
const showTokenConfigDialog = ref(false);
const showPullHeadersDialog = ref(false);
const showPullBodyDialog = ref(false);
const showPullResponseDialog = ref(false);
const pullConfigForm = reactive({
method: 'GET',
url: 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks',
headersFields: [
{ key: 'Content-Type', value: 'application/json' },
{ key: 'Authorization', value: 'Bearer ' },
] as Array<{ key: string; value: string }>,
bodyFields: [{ key: 'task_id.id', value: '111' }] as Array<{ key: string; value: string }>,
responseFields: [
{ value: 'status', isTokenField: false, isMainBody: false },
{ value: 'content.video_url', isTokenField: false, isMainBody: false },
{ value: 'usage.completion_tokens', isTokenField: false, isMainBody: false },
] as Array<{ value: string; isTokenField?: boolean; isMainBody?: boolean }>,
});
const queryResponseTypeOptions = [
{ label: '同步返回', value: 'sync' },
{ label: '等候回调', value: 'callback' },
{ label: '主动拉取', value: 'pull' },
];
const state = reactive({
ruleForm: {
id: '',
modelName: '',
modelType: null as number | string | null,
operatorName: '',
baseUrl: '',
httpMethod: 'POST',
queryResponseType: 'sync',
queryCallbackUrl: '',
headMsg: '',
isPrivate: 0,
apiKey: '',
enabled: 1,
isChatModel: 0,
isAsync: 0, // 0-同步 1-异步默认0
maxConcurrency: 10,
queueLimit: 100,
timeoutSeconds: 30,
expectedSeconds: 15,
retryTimes: 3,
retryQueueMaxSeconds: 60,
autoCleanSeconds: 300,
remark: '',
extendMapping: '{}',
responseTokenField: '',
tokenConfig: '{}',
},
rules: {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
modelType: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
if (value === null || value === undefined || value === '') {
callback(new Error('请选择模型类型'));
} else {
callback();
}
},
trigger: 'change',
},
],
baseUrl: [{ required: true, message: '请输入模型服务地址', trigger: 'blur' }],
httpMethod: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
queryResponseType: [{ required: true, message: '请选择响应类型', trigger: 'change' }],
queryCallbackUrl: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
if (state.ruleForm.queryResponseType !== 'callback') {
callback();
return;
}
if (!value || String(value).trim() === '') {
callback(new Error('请选择等候回调时,回调地址必填'));
return;
}
callback();
},
trigger: 'blur',
},
],
queryPullConfig: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
if (state.ruleForm.queryResponseType !== 'pull') {
callback();
return;
}
if (!pullConfigForm.url || String(pullConfigForm.url).trim() === '') {
callback(new Error('主动拉取时,请填写拉取地址'));
return;
}
// 验证响应字段至少有一个有效字段
const validResponseFields = pullConfigForm.responseFields.filter((f) => String(f.value || '').trim() !== '');
if (validResponseFields.length === 0) {
callback(new Error('主动拉取时,至少需要配置一个响应字段'));
return;
}
// 验证是否设置了返回主体字段
const hasMainBody = pullConfigForm.responseFields.some((f) => f.isMainBody && String(f.value || '').trim() !== '');
if (!hasMainBody) {
callback(new Error('主动拉取时,必须设置一个返回主体字段'));
return;
}
callback();
},
trigger: 'change',
},
],
operatorName: [{ required: true, message: '请选择运营商名称', trigger: 'change' }],
apiKey: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
// 管理员不需要验证 apiKey
if (props.isSuperAdmin) {
callback();
return;
}
// 普通用户如果是服务商模型isPrivate = 1apiKey 必填
if (state.ruleForm.isPrivate === 1) {
if (!value || String(value).trim() === '') {
callback(new Error('服务商模型必须输入 API 密钥'));
} else {
callback();
}
} else {
callback();
}
},
trigger: 'blur',
},
],
maxConcurrency: [{ required: true, message: '请输入最大并发数', trigger: 'blur' }],
queueLimit: [{ required: true, message: '请输入排队队列上限', trigger: 'blur' }],
timeoutSeconds: [{ required: true, message: '请输入请求超时时间', trigger: 'blur' }],
expectedSeconds: [{ required: true, message: '请输入预计执行时间', trigger: 'blur' }],
requestMapping: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
const emptyKeys = state.requestMappingFields.filter((x) => String(x.key || '').trim() === '' && String(x.value || '').trim() !== '');
if (emptyKeys.length > 0) {
callback(new Error('请求映射字段名不能为空'));
return;
}
callback();
},
trigger: 'change',
},
],
responseMapping: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
// 检查是否有空键名但有值的字段
const emptyKeys = state.responseMappingFields.filter((x) => String(x.key || '').trim() === '' && String(x.value || '').trim() !== '');
if (emptyKeys.length > 0) {
callback(new Error('响应映射字段名不能为空'));
return;
}
// 检查是否设置了返回主体字段(必填)
const validFields = state.responseMappingFields.filter((x) => String(x.key || '').trim() !== '');
if (validFields.length > 0) {
const hasMainBody = state.responseMappingFields.some((f) => f.isMainBody && String(f.key || '').trim() !== '');
if (!hasMainBody) {
callback(new Error('响应映射必须设置一个返回主体字段'));
return;
}
}
callback();
},
trigger: 'change',
},
],
tokenConfig: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
const emptyKeys = state.tokenConfigFields.filter((x) => String(x.key || '').trim() === '' && String(x.value || '').trim() !== '');
if (emptyKeys.length > 0) {
callback(new Error('Token计算配置字段名不能为空'));
return;
}
callback();
},
trigger: 'change',
},
],
extendMapping: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
const emptyKeys = state.extendMappingFields.filter((x) => String(x.key || '').trim() === '' && String(x.value || '').trim() !== '');
if (emptyKeys.length > 0) {
callback(new Error('附加映射字段名不能为空'));
return;
}
callback();
},
trigger: 'change',
},
],
},
dialog: {
isShowDialog: false,
type: '',
title: '',
submitTxt: '',
loading: false,
detailLoading: false,
},
showAdvanced: false,
headers: [] as Array<{ key: string; value: string }>,
formFields: [] as Array<FormField>,
requestMappingFields: [] as Array<{ key: string; value: string }>,
responseMappingFields: [] as Array<{ key: string; value: string; isMainBody?: boolean; isTokenField?: boolean }>,
extendMappingFields: [] as Array<{ key: string; value: string }>,
tokenConfigFields: [] as Array<{ key: string; value: string }>,
mainBodyIndex: -1, // 记录哪一行被设置为返回主体
});
// 创建空字段
const createEmptyFormField = (): FormField => {
return {
label: '',
key: '',
type: 'string',
defaultValue: '',
required: false,
maxLength: undefined,
min: undefined,
max: undefined,
numberType: 'any',
maxSize: 10,
maxCount: 1,
allowedTypes: '',
options: [{ label: '', value: '' }],
};
};
// 将数组转换为对象
const fieldsToObject = (fields: Array<{ key: string; value: string }>) => {
const obj: Record<string, string> = {};
fields.forEach((f) => {
if (f.key && f.key.trim()) {
obj[f.key.trim()] = f.value || '';
}
});
return obj;
};
// 解析headers支持多种格式
// 1. 旧格式:逗号分隔的"key1:value1,key2:value2"字符串
// 2. JSON字符串格式化为JSON对象的字符串
// 3. 新格式直接是Record<string, string>对象
const parseHeaders = (raw: unknown): Array<{ key: string; value: string }> => {
if (!raw) return [];
// 如果是字符串先尝试解析为JSON如果失败则尝试解析为旧格式key:value,key2:value2
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (!trimmed) return [];
// 试试JSON解析如果开头是{或者[说明是JSON
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
return parseHeaders(parsed);
} catch {
// JSON解析失败回退到旧格式解析
return parseKeyValueString(trimmed);
}
}
// 不是JSON格式按旧格式解析
return parseKeyValueString(trimmed);
}
// 如果是数组格式 [{ key, value }]
if (Array.isArray(raw)) {
return raw
.filter((item) => item && (item.key !== undefined || item.value !== undefined))
.map((item) => ({
key: String(item.key ?? '').trim(),
value: String(item.value ?? '').trim(),
}));
}
// 如果是对象格式 { key: value }
if (typeof raw === 'object') {
const fields: Array<{ key: string; value: string }> = [];
Object.keys(raw as Record<string, unknown>).forEach((key) => {
let v = (raw as Record<string, unknown>)[key];
// 处理 { key: { value: value } } 格式(后端可能返回这种结构)
if (v && typeof v === 'object' && !Array.isArray(v) && 'value' in v) {
v = (v as { value: unknown }).value;
}
const value = String(v ?? '');
fields.push({ key: key.trim(), value });
});
return fields;
}
return [];
};
// 统一的字段解析函数支持数组、对象、JSON字符串
const parseFieldsUnified = (raw: unknown): Array<{ key: string; value: string }> => {
if (!raw) return [];
// 如果是字符串尝试解析为JSON
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return parseFieldsUnified(parsed);
} catch {
return [];
}
}
// 如果是数组格式 [{ key, value }]
if (Array.isArray(raw)) {
return (raw as ModelFormEntry[])
.filter((item) => item && (item.key !== undefined || item.value !== undefined))
.map((item) => ({
key: String(item.key ?? '').trim(),
value: String(item.value ?? '').trim(),
}));
}
// 如果是对象格式 { key: value } 或者 { key: { value: value } }
if (typeof raw === 'object') {
const fields: Array<{ key: string; value: string }> = [];
Object.keys(raw as Record<string, unknown>).forEach((key) => {
let v = (raw as Record<string, unknown>)[key];
// 处理 { key: { value: value } } 格式(后端可能返回这种结构)
if (v && typeof v === 'object' && !Array.isArray(v) && 'value' in v) {
v = (v as { value: unknown }).value;
}
const value = String(v ?? '');
fields.push({ key, value });
});
return fields;
}
return [];
};
// 解析 requestMapping 对象为数组
const parseRequestMappingFields = (mapping: unknown) => {
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) return [];
return Object.entries(mapping).map(([key, value]) => ({ key, value: String(value) }));
};
// 解析 responseMapping 对象为数组
const parseResponseMappingFields = (mapping: unknown) => {
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) return [];
return Object.entries(mapping).map(([key, value]) => ({ key, value: String(value) }));
};
const parseKeyValueString = (raw: string) => {
if (!raw) return [];
const headers: Array<{ key: string; value: string }> = [];
const pairs = raw.split(',');
pairs.forEach((pair) => {
const idx = pair.indexOf(':');
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) {
headers.push({ key, value });
}
});
return headers;
};
const stringifyHeaders = () => {
return state.headers
.filter((h) => h.key?.trim() && h.value?.trim())
.map((h) => `${h.key.trim()}:${h.value.trim()}`)
.join(',');
};
const onIsPrivateChange = (val: string | number | boolean | undefined) => {
if (val === 0 || val === '0') {
state.ruleForm.apiKey = '';
}
};
const fieldsToUnknownObject = (fields: Array<{ key: string; value: string }>) => {
const obj: Record<string, unknown> = {};
fields.forEach((f) => {
const k = String(f.key || '').trim();
if (!k) return;
obj[k] = String(f.value ?? '');
});
return obj;
};
const fieldsToNestedObject = (fields: Array<{ key: string; value: string }>) => {
const root: Record<string, unknown> = {};
fields.forEach((f) => {
const path = String(f.key || '').trim();
if (!path) return;
const parts = path
.split('.')
.map((p) => p.trim())
.filter(Boolean);
if (!parts.length) return;
let cur: Record<string, unknown> = root;
parts.forEach((part, idx) => {
if (idx === parts.length - 1) {
cur[part] = String(f.value ?? '');
return;
}
if (!cur[part] || typeof cur[part] !== 'object' || Array.isArray(cur[part])) {
cur[part] = {};
}
cur = cur[part] as Record<string, unknown>;
});
});
return root;
};
const objectToFields = (obj: Record<string, unknown>) => {
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value ?? '') }));
};
const nestedObjectToFields = (obj: Record<string, unknown>, prefix = ''): Array<{ key: string; value: string }> => {
const rows: Array<{ key: string; value: string }> = [];
Object.entries(obj || {}).forEach(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
rows.push(...nestedObjectToFields(value as Record<string, unknown>, fullKey));
return;
}
rows.push({ key: fullKey, value: String(value ?? '') });
});
return rows;
};
const buildQueryConfig = () => {
const responseType = state.ruleForm.queryResponseType || 'sync';
if (responseType === 'callback') {
return {
responseType: 'callback',
callbackUrl: String(state.ruleForm.queryCallbackUrl || '').trim(),
};
}
if (responseType === 'pull') {
return {
responseType: 'pull',
callbackUrl: '',
method: pullConfigForm.method || 'GET',
url: pullConfigForm.url || '',
headers: fieldsToUnknownObject(pullConfigForm.headersFields),
body: fieldsToNestedObject(pullConfigForm.bodyFields),
response: pullConfigForm.responseFields
.map((row) => ({
value: String(row.value || '').trim(),
isTokenField: Boolean(row.isTokenField),
isMainBody: Boolean(row.isMainBody),
}))
.filter((row) => row.value !== ''),
};
}
return {
responseType: 'sync',
callbackUrl: '',
};
};
// 添加请求头
const addHeader = () => {
state.headers.push({ key: '', value: '' });
};
// 删除请求头
const removeHeader = (index: number) => {
state.headers.splice(index, 1);
};
// 确认请求头配置
const confirmHeaders = () => {
state.ruleForm.headMsg = stringifyHeaders();
showHeaderDialog.value = false;
};
// 添加表单字段
const addFormField = () => {
state.formFields.push(createEmptyFormField());
};
// 删除表单字段
const removeFormField = (index: number) => {
state.formFields.splice(index, 1);
};
// 字段类型变化时初始化默认配置
const onFieldTypeChange = (index: number) => {
const field = state.formFields[index];
if ((field.type === 'select' || field.type === 'radio') && !field.options) {
field.options = [{ label: '', value: '' }];
}
};
// 添加选项
const addFieldOption = (field: FormField) => {
if (!field.options) {
field.options = [];
}
field.options.push({ label: '', value: '' });
};
// 删除选项
const removeFieldOption = (field: FormField, optIndex: number) => {
if (field.options) {
field.options.splice(optIndex, 1);
}
};
// 确认表单字段配置
const confirmFormFields = () => {
showFormDialog.value = false;
};
// 请求映射字段操作
const addRequestMappingField = () => {
state.requestMappingFields.push({ key: '', value: '' });
};
const removeRequestMappingField = (index: number) => {
state.requestMappingFields.splice(index, 1);
};
const confirmRequestMappingFields = () => {
showRequestMappingDialog.value = false;
};
// 响应映射字段操作
const addResponseMappingField = () => {
state.responseMappingFields.push({ key: '', value: '', isMainBody: false, isTokenField: false });
};
const removeResponseMappingField = (index: number) => {
const removed = state.responseMappingFields[index];
state.responseMappingFields.splice(index, 1);
if (removed?.isTokenField) {
state.ruleForm.responseTokenField = '';
}
};
const setTokenField = (index: number) => {
state.responseMappingFields.forEach((field, i) => {
field.isTokenField = i === index;
});
state.ruleForm.responseTokenField = state.responseMappingFields[index]?.key?.trim?.() || '';
};
const syncTokenFieldOnKeyChange = (index: number) => {
const row = state.responseMappingFields[index];
if (!row?.isTokenField) return;
state.ruleForm.responseTokenField = row.key?.trim?.() || '';
};
// 设置返回主体(单选)
const setMainBody = (index: number) => {
// 清除所有字段的返回主体标记
state.responseMappingFields.forEach((field, i) => {
field.isMainBody = i === index;
});
state.mainBodyIndex = index;
};
const confirmResponseMappingFields = () => {
showResponseMappingDialog.value = false;
};
const setPullTokenField = (index: number) => {
pullConfigForm.responseFields.forEach((field, i) => {
field.isTokenField = i === index;
});
};
const setPullMainBody = (index: number) => {
pullConfigForm.responseFields.forEach((field, i) => {
field.isMainBody = i === index;
});
};
const ensureKeyValueRows = (rows: Array<{ key: string; value: string }>) => (rows.length ? rows : [{ key: '', value: '' }]);
// 解析旧格式的form数据兼容到新格式
const parseFormFieldsUnified = (raw: unknown): Array<FormField> => {
if (!raw) return [createEmptyFormField()];
// 如果已经是新格式数组(每个元素有 type 字段),直接返回
if (Array.isArray(raw)) {
if (raw.length === 0) return [createEmptyFormField()];
// 确保新增字段有默认值
return (raw as any[]).map(item => {
const field = createEmptyFormField();
return { ...field, ...item };
}) as FormField[];
}
// 旧格式对象 { key: value } -> 转换为新格式数组
if (typeof raw === 'object') {
const fields: FormField[] = [];
Object.entries(raw as Record<string, unknown>).forEach(([key, value]) => {
const field = createEmptyFormField();
field.key = key;
field.label = key;
field.defaultValue = String(value ?? '').trim();
fields.push(field);
});
return fields.length ? fields : [createEmptyFormField()];
}
return [createEmptyFormField()];
};
const unwrapModelDetailPayload = (data: unknown): Record<string, unknown> | null => {
if (data == null) return null;
if (Array.isArray(data)) return null;
if (typeof data !== 'object') return null;
const o = data as Record<string, unknown>;
if (typeof o.modelName === 'string') return o;
if (o.model && typeof o.model === 'object' && !Array.isArray(o.model)) {
return o.model as Record<string, unknown>;
}
if (o.detail && typeof o.detail === 'object' && !Array.isArray(o.detail)) {
return o.detail as Record<string, unknown>;
}
if (o.info && typeof o.info === 'object' && !Array.isArray(o.info)) {
return o.info as Record<string, unknown>;
}
return o;
};
const fillFormFromDetailRow = (row: Record<string, unknown>) => {
const timeoutSeconds =
row.timeoutSeconds != null && row.timeoutSeconds !== ''
? Number(row.timeoutSeconds)
: row.timeoutMs != null
? Math.floor(Number(row.timeoutMs) / 1000)
: 30;
const isPrivate = row.isPrivate !== undefined && row.isPrivate !== null ? Number(row.isPrivate) : 0;
// headMsg might already be a Record<string, string> object (new format) or a string (old format)
let ruleFormHeadMsg = '';
if (typeof row.headMsg === 'string') {
ruleFormHeadMsg = row.headMsg;
} else if (row.headMsg && typeof row.headMsg === 'object') {
// If it's already an object, stringify for compatibility
ruleFormHeadMsg = JSON.stringify(row.headMsg);
} else {
ruleFormHeadMsg = '';
}
state.ruleForm = {
id: row.id as string,
modelName: String(row.modelName ?? ''),
modelType: row.modelType !== undefined && row.modelType !== null ? typeOptionValue(row.modelType as number | string) : null,
operatorName: String(row.operatorName ?? ''),
baseUrl: String(row.baseUrl ?? ''),
httpMethod: String(row.httpMethod || 'POST'),
queryResponseType: String((row.queryConfig as Record<string, unknown>)?.responseType || 'sync'),
queryCallbackUrl: String((row.queryConfig as Record<string, unknown>)?.callbackUrl || ''),
headMsg: ruleFormHeadMsg,
isPrivate,
apiKey: isPrivate === 1 ? String(row.apiKey ?? '') : '',
enabled: Number(row.enabled ?? 1),
isChatModel: row.isChatModel !== undefined && row.isChatModel !== null ? Number(row.isChatModel) : 0,
isAsync: row.isAsync !== undefined && row.isAsync !== null ? Number(row.isAsync) : 0,
maxConcurrency: Number(row.maxConcurrency ?? 10),
queueLimit: Number(row.queueLimit ?? 100),
timeoutSeconds,
expectedSeconds: Number(row.expectedSeconds ?? 15),
retryTimes: Number(row.retryTimes ?? 3),
retryQueueMaxSeconds: Number(row.retryQueueMaxSeconds ?? 60),
autoCleanSeconds: Number(row.autoCleanSeconds ?? 300),
remark: String(row.remark || ''),
extendMapping: '{}',
responseTokenField: String(row.responseTokenField || ''),
tokenConfig: '{}',
};
state.headers = ensureKeyValueRows(parseHeaders(row.headMsg));
state.formFields = parseFormFieldsUnified(row.form);
state.requestMappingFields = ensureKeyValueRows(parseRequestMappingFields(row.requestMapping));
state.responseMappingFields = ensureKeyValueRows(parseResponseMappingFields(row.responseMapping));
state.extendMappingFields = ensureKeyValueRows(parseFieldsUnified(row.extendMapping));
state.tokenConfigFields = ensureKeyValueRows(parseFieldsUnified(row.tokenConfig));
// 根据 responseTokenField 字段设置计费字段标记(单选)
const tokenFieldKey = String(row.responseTokenField || '').trim();
if (tokenFieldKey) {
const tokenFieldIndex = state.responseMappingFields.findIndex((f) => String(f.key || '').trim() === tokenFieldKey);
if (tokenFieldIndex !== -1) {
state.responseMappingFields[tokenFieldIndex].isTokenField = true;
}
}
// 根据 responseBody 字段设置返回主体标记(单选)
if (row.responseBody && typeof row.responseBody === 'object' && !Array.isArray(row.responseBody)) {
const bodyKeys = Object.keys(row.responseBody);
if (bodyKeys.length > 0) {
const bodyKey = bodyKeys[0];
const bodyFieldIndex = state.responseMappingFields.findIndex((f) => String(f.key || '').trim() === bodyKey);
if (bodyFieldIndex !== -1) {
state.responseMappingFields[bodyFieldIndex].isMainBody = true;
state.mainBodyIndex = bodyFieldIndex;
}
}
}
if (row.queryConfig && typeof row.queryConfig === 'object' && !Array.isArray(row.queryConfig)) {
const qc = row.queryConfig as Record<string, unknown>;
pullConfigForm.method = String(qc.method || 'GET');
pullConfigForm.url = String(qc.url || 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks');
pullConfigForm.headersFields = ensureKeyValueRows(objectToFields((qc.headers as Record<string, unknown>) || {}));
pullConfigForm.bodyFields = ensureKeyValueRows(nestedObjectToFields((qc.body as Record<string, unknown>) || {}));
pullConfigForm.responseFields = ((qc.response as unknown[]) || []).map((item) => {
if (typeof item === 'string') {
return { value: item, isTokenField: false, isMainBody: false };
}
const row = item as Record<string, unknown>;
return {
value: String(row.value ?? ''),
isTokenField: Boolean(row.isTokenField),
isMainBody: Boolean(row.isMainBody),
};
});
} else {
pullConfigForm.method = 'GET';
pullConfigForm.url = '';
pullConfigForm.headersFields = [{ key: '', value: '' }];
pullConfigForm.bodyFields = [{ key: '', value: '' }];
pullConfigForm.responseFields = [{ value: '', isTokenField: false, isMainBody: false }];
}
};
// 打开弹窗(编辑时会请求 /model/getModel 详情)
const openDialog = async (type: string, row?: Record<string, unknown>) => {
state.dialog.type = type;
state.dialog.isShowDialog = true;
state.showAdvanced = false;
state.dialog.detailLoading = false;
if (type === 'edit') {
const listRowId = row?.id;
if (listRowId === undefined || listRowId === null || listRowId === '') {
ElMessage.error('缺少模型 ID');
state.dialog.isShowDialog = false;
return;
}
state.dialog.title = '修改模型配置';
state.dialog.submitTxt = '修 改';
state.dialog.detailLoading = true;
try {
const res: any = await getModelModuleDetail(listRowId as string | number);
if (res.code !== 0) {
ElMessage.error(res.message || '获取模型详情失败');
state.dialog.isShowDialog = false;
return;
}
const detail = unwrapModelDetailPayload(res.data) ?? row ?? null;
if (!detail || typeof detail !== 'object') {
ElMessage.error('获取模型详情失败');
state.dialog.isShowDialog = false;
return;
}
fillFormFromDetailRow(detail as Record<string, unknown>);
} catch {
// 接口错误由 request 全局提示后端 message
state.dialog.isShowDialog = false;
} finally {
state.dialog.detailLoading = false;
}
} else {
state.ruleForm = {
id: '',
modelName: '',
modelType: null,
operatorName: '',
baseUrl: '',
httpMethod: 'POST',
queryResponseType: 'sync',
queryCallbackUrl: '',
headMsg: '',
isPrivate: props.isSuperAdmin ? 1 : 0,
apiKey: '',
enabled: 1,
isChatModel: 0,
isAsync: 0,
maxConcurrency: 10,
queueLimit: 100,
timeoutSeconds: 30,
expectedSeconds: 15,
retryTimes: 3,
retryQueueMaxSeconds: 60,
autoCleanSeconds: 300,
remark: '',
extendMapping: '{}',
responseTokenField: '',
tokenConfig: '{}',
};
state.headers = [{ key: '', value: '' }];
state.formFields = [createEmptyFormField()];
state.requestMappingFields = [{ key: '', value: '' }];
state.responseMappingFields = [{ key: '', value: '', isMainBody: false, isTokenField: false }];
state.mainBodyIndex = -1;
state.extendMappingFields = [{ key: '', value: '' }];
state.tokenConfigFields = [{ key: '', value: '' }];
pullConfigForm.method = 'GET';
pullConfigForm.url = '';
pullConfigForm.headersFields = [{ key: '', value: '' }];
pullConfigForm.bodyFields = [{ key: '', value: '' }];
pullConfigForm.responseFields = [{ value: '', isTokenField: false, isMainBody: false }];
state.dialog.title = '新增模型配置';
state.dialog.submitTxt = '新 增';
}
};
// 关闭弹窗
const closeDialog = () => {
state.dialog.isShowDialog = false;
state.dialog.detailLoading = false;
editModuleFormRef.value?.resetFields();
};
// 取消
const onCancel = () => {
closeDialog();
};
// 提交
const onSubmit = () => {
editModuleFormRef.value.validate(async (valid: boolean) => {
if (!valid) return;
state.dialog.loading = true;
try {
// 触发所有自定义字段的验证
if (state.ruleForm.queryResponseType === 'pull') {
await editModuleFormRef.value?.validateField?.('queryPullConfig');
}
// 验证响应映射(如果有配置)
const validResponseFields = state.responseMappingFields.filter((x) => String(x.key || '').trim() !== '');
if (validResponseFields.length > 0) {
await editModuleFormRef.value?.validateField?.('responseMapping');
}
// 验证请求映射(如果有配置)
const validRequestFields = state.requestMappingFields.filter((x) => String(x.key || '').trim() !== '');
if (validRequestFields.length > 0) {
await editModuleFormRef.value?.validateField?.('requestMapping');
}
state.ruleForm.headMsg = stringifyHeaders();
// 过滤掉空键名的字段
const requestMapping = fieldsToObject(state.requestMappingFields.filter((f) => String(f.key || '').trim() !== ''));
const responseMapping = fieldsToObject(state.responseMappingFields.filter((f) => String(f.key || '').trim() !== ''));
// 获取被设置为返回主体的字段 {key: value}
const responseBodyField = state.responseMappingFields.find((f) => f.isMainBody && String(f.key || '').trim() !== '');
const responseBody = responseBodyField ? { [responseBodyField.key.trim()]: responseBodyField.value } : {};
// 获取计费字段(可选)
const responseTokenField =
state.responseMappingFields.find((f) => f.isTokenField)?.key?.trim() || String(state.ruleForm.responseTokenField || '').trim();
// 过滤掉空key的自定义字段
const processedFormFields = state.formFields
.filter((f) => String(f.key || '').trim() !== '')
.map((f) => ({
key: String(f.key).trim(),
value: f.defaultValue || '',
label: f.label?.trim() || f.key,
type: f.type,
defaultValue: f.defaultValue || '',
required: Boolean(f.required),
maxLength: f.type === 'string' && f.maxLength ? f.maxLength : undefined,
min: f.type === 'number' && f.min !== undefined ? f.min : undefined,
max: f.type === 'number' && f.max !== undefined ? f.max : undefined,
numberType: f.type === 'number' ? f.numberType || 'any' : undefined,
maxSize: f.type === 'file' && f.maxSize ? f.maxSize : undefined,
maxCount: f.type === 'file' && f.maxCount ? f.maxCount : undefined,
allowedTypes: f.type === 'file' && f.allowedTypes ? f.allowedTypes.trim() : undefined,
options: (f.type === 'select' || f.type === 'radio') && f.options
? f.options.filter(opt => String(opt.label || '').trim() !== '' || String(opt.value || '').trim() !== '')
: undefined,
})) as unknown as ModelFormEntry[];
// 将headers转换为JSON对象格式
const headMsgObj: Record<string, string> = {};
state.headers
.filter((h) => h.key?.trim() && h.value?.trim())
.forEach((h) => {
headMsgObj[h.key.trim()] = h.value.trim();
});
const submitData: CreateModelParams = {
modelName: state.ruleForm.modelName,
modelType: state.ruleForm.modelType as number | string,
operatorName: state.ruleForm.operatorName,
baseUrl: state.ruleForm.baseUrl,
httpMethod: state.ruleForm.httpMethod || 'POST',
headMsg: headMsgObj,
isPrivate: state.ruleForm.isPrivate,
enabled: state.ruleForm.enabled,
isChatModel: state.ruleForm.isChatModel,
isAsync: state.ruleForm.isAsync,
// 确保 API 密钥只在 isPrivate=1 时提交
apiKey: state.ruleForm.isPrivate === 1 ? String(state.ruleForm.apiKey ?? '').trim() : '',
form: processedFormFields,
requestMapping,
responseMapping,
responseBody,
maxConcurrency: state.ruleForm.maxConcurrency,
queueLimit: state.ruleForm.queueLimit,
timeoutSeconds: state.ruleForm.timeoutSeconds,
expectedSeconds: state.ruleForm.expectedSeconds,
retryTimes: state.ruleForm.retryTimes,
retryQueueMaxSeconds: state.ruleForm.retryQueueMaxSeconds,
autoCleanSeconds: state.ruleForm.autoCleanSeconds,
remark: state.ruleForm.remark || '',
extendMapping: fieldsToUnknownObject(state.extendMappingFields.filter((f) => String(f.key || '').trim() !== '')),
responseTokenField,
tokenConfig: fieldsToUnknownObject(state.tokenConfigFields.filter((f) => String(f.key || '').trim() !== '')),
queryConfig: buildQueryConfig(),
};
if (state.dialog.type === 'edit') {
await updateModelModule({ ...submitData, id: state.ruleForm.id });
ElMessage.success('修改成功');
} else {
await addModelModule(submitData);
ElMessage.success('新增成功');
}
closeDialog();
emit('refresh');
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
state.dialog.loading = false;
}
});
};
// 暴露变量
defineExpose({
openDialog,
});
onMounted(() => {
loadOperatorOptions();
});
</script>
<style scoped lang="scss">
.mapping-config-container {
.mapping-field-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.separator {
font-weight: bold;
color: #606266;
}
}
}
.form-config-container {
max-height: 550px;
overflow-y: auto;
padding: 5px 0;
.form-field-item {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 16px 16px 12px 16px;
padding-right: 80px;
margin-bottom: 16px;
background-color: #fafafa;
position: relative;
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
}
}
.field-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
.field-col {
flex: 1;
&.field-col-sm {
flex: 0 0 140px;
}
&.field-col-md {
flex: 0 0 200px;
}
&.field-col-xs {
flex: 0 0 80px;
}
}
.field-label {
display: block;
font-size: 13px;
color: #606266;
margin-bottom: 6px;
line-height: 1.2;
&.required::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
}
.switch-wrapper {
padding-top: 4px;
}
}
.options-row {
margin-top: 8px;
padding-top: 16px;
border-top: 1px dashed #e4e7ed;
.options-label {
font-size: 14px;
color: #606266;
margin-bottom: 10px;
font-weight: 500;
}
.option-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.option-col {
flex: 1;
max-width: 180px;
}
}
}
}
.ml5 {
margin-left: 5px;
}
.header-config-container {
max-height: 400px;
overflow-y: auto;
}
.header-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.separator {
font-weight: bold;
color: #606266;
}
</style>