Files
admin-ui/src/views/settings/modelConfig/modelModule/component/editModule.vue
2910410219 27de8213f2 feat: 添加 queryConfig 字段及相关配置功能
- 在 CreateModelParams 接口中新增 queryConfig 字段,支持更灵活的查询配置。
- 更新 ModelSelector 组件,优化模型选择界面,增强用户体验。
- 在 editModule.vue 中添加响应类型选择及主动拉取配置功能,提升配置灵活性与可用性。
2026-05-23 17:56:52 +08:00

1222 lines
48 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="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="600px" :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-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="removeFormField(index)">删除</el-button>
</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 };
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,
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;
}
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' }],
tokenConfig: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
if (!state.tokenConfigFields.length || state.tokenConfigFields.some((x) => !String(x.key || '').trim())) {
callback(new Error('Token计算配置字段名不能为空'));
return;
}
callback();
},
trigger: 'change',
},
],
extendMapping: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
if (state.extendMappingFields.some((x) => !String(x.key || '').trim())) {
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<{ key: string; value: string }>,
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 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;
};
const parseHeaders = (headMsg: string) => parseKeyValueString(headMsg);
// 解析 form支持数组 [{ key, value }] 或历史对象 { k: { value } }
const parseFormFields = (form: unknown) => {
if (!form) return [];
if (Array.isArray(form)) {
return (form as ModelFormEntry[])
.filter((item) => item && (item.key !== undefined || item.value !== undefined))
.map((item) => ({
key: String(item.key ?? '').trim(),
value: String(item.value ?? '').trim(),
}));
}
if (typeof form === 'object') {
const fields: Array<{ key: string; value: string }> = [];
Object.keys(form as Record<string, unknown>).forEach((key) => {
const v = (form as Record<string, unknown>)[key];
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({ key: '', value: '' });
};
// 删除表单字段
const removeFormField = (index: number) => {
state.formFields.splice(index, 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: '' }]);
const ensureResponseMappingRows = (rows: Array<{ key: string; value: string; isMainBody?: boolean; isTokenField?: boolean }>) => {
if (!rows.length) return [{ key: '', value: '', isMainBody: false, isTokenField: false }];
return rows.map((row) => ({ ...row, isMainBody: row.isMainBody || false, isTokenField: row.isTokenField || false }));
};
/** 从 getModel 返回的 data 中取出单条模型对象 */
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;
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: String(row.headMsg || ''),
isPrivate,
apiKey: isPrivate === 1 ? String(row.apiKey ?? '') : '',
enabled: Number(row.enabled ?? 1),
isChatModel: row.isChatModel !== undefined && row.isChatModel !== null ? Number(row.isChatModel) : 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(String(row.headMsg || '')));
state.formFields = ensureKeyValueRows(parseFormFields(row.form));
// 解析请求映射和响应映射
state.requestMappingFields = ensureKeyValueRows(parseRequestMappingFields(row.requestMapping));
state.responseMappingFields = ensureResponseMappingRows(parseResponseMappingFields(row.responseMapping));
state.extendMappingFields = ensureKeyValueRows(objectToFields((row.extendMapping as Record<string, unknown>) || {}));
state.tokenConfigFields = ensureKeyValueRows(objectToFields((row.tokenConfig as Record<string, unknown>) || {}));
// 根据 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,
maxConcurrency: 10,
queueLimit: 100,
timeoutSeconds: 30,
expectedSeconds: 15,
retryTimes: 3,
retryQueueMaxSeconds: 60,
autoCleanSeconds: 300,
remark: '',
extendMapping: '{}',
responseTokenField: '',
tokenConfig: '{}',
};
state.headers = [{ key: '', value: '' }];
state.formFields = [{ key: '', value: '' }];
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');
}
state.ruleForm.headMsg = stringifyHeaders();
const requestMapping = fieldsToObject(state.requestMappingFields);
const responseMapping = fieldsToObject(state.responseMappingFields);
// 获取被设置为返回主体的字段 {key: value}
const responseBodyField = state.responseMappingFields.find((f) => f.isMainBody);
const responseBody = responseBodyField ? { [responseBodyField.key.trim()]: responseBodyField.value } : {};
const responseTokenField =
state.responseMappingFields.find((f) => f.isTokenField)?.key?.trim() || String(state.ruleForm.responseTokenField || '').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: state.ruleForm.headMsg,
isPrivate: state.ruleForm.isPrivate,
enabled: state.ruleForm.enabled,
isChatModel: state.ruleForm.isChatModel,
apiKey: state.ruleForm.isPrivate === 1 ? String(state.ruleForm.apiKey ?? '').trim() : '',
form: state.formFields
.filter((f) => String(f.key || '').trim() !== '')
.map((f) => ({ key: String(f.key).trim(), value: String(f.value ?? '') })),
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),
responseTokenField,
tokenConfig: fieldsToUnknownObject(state.tokenConfigFields),
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: 400px;
overflow-y: auto;
}
.form-field-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.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>