Files
admin-ui/src/views/settings/modelConfig/modelModule/component/editModule.vue
2026-06-06 17:23:50 +08:00

2054 lines
72 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="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
v-if="props.isSuperAdmin || state.ruleForm.isPrivate === 1"
:xs="24"
:sm="24"
:md="24"
:lg="24"
:xl="24"
class="mb20 provider-section-col"
>
<div class="provider-section-title">服务商模型配置</div>
</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="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 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="callMode">
<el-select v-model="state.ruleForm.callMode" placeholder="请选择调用模式" clearable style="width: 100%">
<el-option label="同步" :value="0"></el-option>
<el-option label="异步" :value="1"></el-option>
<el-option label="流式(待定)" :value="2"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col v-if="state.ruleForm.callMode === 1" :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="查询/回调配置" prop="asyncQueryConfig">
<el-button @click="showAsyncQueryConfigDialog = true" style="width: 100%">配置查询/回调</el-button>
</el-form-item>
</el-col>
<el-col v-if="state.ruleForm.callMode === 2" :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-alert type="warning" :closable="false" title="流式执行模式暂未配置独立表单,当前仅保留模式标记,不提交流式专用配置。" />
</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-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="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="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="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="showAsyncQueryConfigDialog" title="配置查询/回调" width="800px" :close-on-click-modal="false">
<el-form label-width="140px">
<el-form-item label="查询地址">
<el-input v-model="asyncQueryConfigForm.url" placeholder="请输入查询地址" clearable></el-input>
</el-form-item>
<el-form-item label="请求方式">
<el-select v-model="asyncQueryConfigForm.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="任务ID路径">
<el-input v-model="asyncQueryConfigForm.taskId" placeholder="如 data.task_id" clearable></el-input>
</el-form-item>
<el-form-item label="结果路径">
<el-input v-model="asyncQueryConfigForm.resultPath" placeholder="如 data.audio_url" clearable></el-input>
</el-form-item>
<el-form-item label="状态路径">
<el-input v-model="asyncQueryConfigForm.statusPath" placeholder="如 data.task_status" clearable></el-input>
</el-form-item>
<el-form-item label="轮询间隔(秒)">
<el-input-number v-model="asyncQueryConfigForm.intervalSeconds" :min="1" :max="3600" style="width: 100%"></el-input-number>
</el-form-item>
<el-form-item label="状态值映射">
<div class="mapping-config-container async-query-status-values">
<div v-for="(field, index) in asyncQueryConfigForm.statusValueFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="状态名称,如 succeeded" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="状态值,按字符串提交" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="removeAsyncQueryStatusValueField(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addAsyncQueryStatusValueField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAsyncQueryConfigDialog = false" size="default"> </el-button>
<el-button type="primary" @click="showAsyncQueryConfigDialog = false" size="default"> </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="860px" :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: 18%"
clearable
@input="syncRequestSpecialFieldsOnKeyChange(index)"
></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 18%" clearable></el-input>
<el-button :type="field.required ? 'success' : 'primary'" :plain="!field.required" @click="toggleRequiredField(index)" size="small">
{{ field.required ? '✓ 必填字段' : '设置必填字段' }}
</el-button>
<el-button :type="field.isFirstFrame ? 'success' : 'primary'" :plain="!field.isFirstFrame" @click="setFirstFrameField(index)" size="small">
{{ field.isFirstFrame ? '✓ 首帧参数' : '设置首帧参数' }}
</el-button>
<el-button :type="field.isLastFrame ? 'success' : 'primary'" :plain="!field.isLastFrame" @click="setLastFrameField(index)" size="small">
{{ field.isLastFrame ? '✓ 尾帧参数' : '设置尾帧参数' }}
</el-button>
<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 ? 'success' : '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="showExtendMappingDialog" title="配置附加映射" width="760px" :close-on-click-modal="false">
<el-form ref="extendMappingDialogFormRef" :model="streamConfigForm" :rules="streamConfigRules" label-width="0" class="stream-dialog-form">
<div class="stream-dialog-panel">
<div class="stream-dialog-panel__row stream-dialog-panel__row--single">
<div class="stream-dialog-panel__label required">内容路径</div>
<el-form-item prop="targetContentPath" class="stream-dialog-panel__item stream-dialog-panel__item--full">
<el-input v-model="streamConfigForm.targetContentPath" placeholder="如 messages.0.content" clearable></el-input>
</el-form-item>
</div>
</div>
<div class="stream-config-container">
<div v-for="templateKey in streamAttachmentTemplateKeys" :key="templateKey" class="stream-template-card">
<div class="stream-template-card__header">
<span class="stream-template-card__title">{{ templateKey }} 附件模板</span>
</div>
<div class="stream-template-grid">
<div class="stream-template-grid__label required">type</div>
<el-form-item :prop="`${templateKey}Type`" class="stream-template-grid__item stream-template-grid__item--full">
<el-input v-model="streamConfigForm.attachmentTemplates[templateKey].type" placeholder="请输入 type" clearable></el-input>
</el-form-item>
<div class="stream-template-grid__label required stream-template-grid__label--top">body</div>
<div class="stream-template-grid__item stream-template-grid__item--full">
<div class="stream-body-list">
<div v-for="(field, index) in streamConfigForm.attachmentTemplates[templateKey].bodyFields" :key="index" class="stream-body-row">
<el-input v-model="field.key" placeholder="请输入 body key" clearable class="stream-body-row__input"></el-input>
<span class="stream-body-row__separator">=</span>
<el-input v-model="field.value" placeholder="请输入 body value" clearable class="stream-body-row__input"></el-input>
<el-button type="danger" link class="stream-body-row__delete" @click="removeStreamBodyField(templateKey, index)">删除</el-button>
</div>
<el-button type="primary" link class="stream-body-list__add" @click="addStreamBodyField(templateKey)">+ 添加 body 字段</el-button>
</div>
</div>
</div>
</div>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showExtendMappingDialog = false" size="default"> </el-button>
<el-button type="primary" @click="validateAndConfirmExtendMapping" size="default"> </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>
</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[]; // 选项列表
}
export interface KeyValueField {
key: string;
value: string;
}
export interface RequestMappingField extends KeyValueField {
required?: boolean;
isFirstFrame?: boolean;
isLastFrame?: boolean;
}
export interface ResponseMappingField extends KeyValueField {
isMainBody?: boolean;
isTokenField?: boolean;
}
export interface StreamAttachmentTemplateForm {
type: string;
bodyFields: KeyValueField[];
}
export interface StreamConfigForm {
targetContentPath: string;
attachmentTemplates: {
audio: StreamAttachmentTemplateForm;
image: StreamAttachmentTemplateForm;
video: StreamAttachmentTemplateForm;
};
}
const props = withDefaults(
defineProps<{
modelTypes?: ModelTypeOption[];
isSuperAdmin?: boolean;
}>(),
{
modelTypes: () => [] as ModelTypeOption[],
isSuperAdmin: false,
}
);
const modelTypeOptions = computed(() => props.modelTypes);
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 extendMappingDialogFormRef = ref();
const emit = defineEmits(['refresh']);
const showHeaderDialog = ref(false);
const showAsyncQueryConfigDialog = ref(false);
const showFormDialog = ref(false);
const showRequestMappingDialog = ref(false);
const showResponseMappingDialog = ref(false);
const showExtendMappingDialog = ref(false);
const showTokenConfigDialog = ref(false);
const asyncQueryConfigForm = reactive({
url: '',
method: 'POST',
taskId: '',
resultPath: '',
statusPath: '',
intervalSeconds: 2,
statusValueFields: [{ key: '', value: '' }] as Array<{ key: string; value: string }>,
});
const createEmptyStreamAttachmentTemplate = (): StreamAttachmentTemplateForm => ({
type: '',
bodyFields: [{ key: '', value: '' }],
});
const createEmptyStreamConfigForm = (): StreamConfigForm => ({
targetContentPath: '',
attachmentTemplates: {
audio: createEmptyStreamAttachmentTemplate(),
image: createEmptyStreamAttachmentTemplate(),
video: createEmptyStreamAttachmentTemplate(),
},
});
const streamConfigForm = reactive<StreamConfigForm>(createEmptyStreamConfigForm());
const streamConfigRules = {
targetContentPath: [{ required: true, message: '请输入内容路径', trigger: 'blur' }],
// audioType: [{ required: true, message: '请输入 Audio 附件模板 type', trigger: 'blur' }],
// imageType: [{ required: true, message: '请输入 Image 附件模板 type', trigger: 'blur' }],
// videoType: [{ required: true, message: '请输入 Video 附件模板 type', trigger: 'blur' }],
};
const streamAttachmentTemplateKeys: Array<keyof StreamConfigForm['attachmentTemplates']> = ['audio', 'image', 'video'];
const state = reactive({
ruleForm: {
id: '',
modelName: '',
modelType: null as number | string | null,
operatorName: '',
baseUrl: '',
httpMethod: 'POST',
headMsg: '',
isPrivate: 0,
apiKey: '',
enabled: 1,
isChatModel: 0,
callMode: 0,
firstFrame: '',
lastFrame: '',
requiredFields: [] as string[],
maxConcurrency: 10,
timeoutSeconds: 30,
retryTimes: 3,
autoCleanSeconds: 300,
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' }],
callMode: [{ required: true, message: '请选择调用模式', trigger: 'change' }],
asyncQueryConfig: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
if (Number(state.ruleForm.callMode) !== 1) {
callback();
return;
}
if (!String(asyncQueryConfigForm.url || '').trim()) {
callback(new Error('异步执行时,请填写查询地址'));
return;
}
if (!String(asyncQueryConfigForm.method || '').trim()) {
callback(new Error('异步执行时,请选择请求方式'));
return;
}
if (!String(asyncQueryConfigForm.taskId || '').trim()) {
callback(new Error('异步执行时请填写任务ID路径'));
return;
}
if (!String(asyncQueryConfigForm.resultPath || '').trim()) {
callback(new Error('异步执行时,请填写结果路径'));
return;
}
if (!String(asyncQueryConfigForm.statusPath || '').trim()) {
callback(new Error('异步执行时,请填写状态路径'));
return;
}
if (!asyncQueryConfigForm.intervalSeconds || Number(asyncQueryConfigForm.intervalSeconds) <= 0) {
callback(new Error('异步执行时,请填写有效的轮询间隔'));
return;
}
const validStatusValues = asyncQueryConfigForm.statusValueFields.filter(
(item) => String(item.key || '').trim() !== '' && String(item.value || '').trim() !== ''
);
if (validStatusValues.length === 0) {
callback(new Error('异步执行时,请至少配置一个状态值映射'));
return;
}
const hasInvalidStatusValue = asyncQueryConfigForm.statusValueFields.some(
(item) =>
(String(item.key || '').trim() === '' && String(item.value || '').trim() !== '') ||
(String(item.key || '').trim() !== '' && String(item.value || '').trim() === '')
);
if (hasInvalidStatusValue) {
callback(new Error('状态值映射的键和值都必须完整填写'));
return;
}
callback();
},
trigger: 'change',
},
],
streamConfig: [
{
validator: (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
callback();
},
trigger: 'change',
},
],
operatorName: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
if (!(props.isSuperAdmin || state.ruleForm.isPrivate === 1)) {
callback();
return;
}
if (!value || String(value).trim() === '') {
callback(new Error('请选择运营商名称'));
return;
}
callback();
},
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' }],
timeoutSeconds: [{ 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;
}
const duplicatedSpecialSelection = state.requestMappingFields.some((x) => x.isFirstFrame && x.isLastFrame);
if (duplicatedSpecialSelection) {
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) => {
if (!String(streamConfigForm.targetContentPath || '').trim()) {
callback(new Error('附加映射时,请填写内容路径'));
return;
}
const missingTemplateType = Object.entries(streamConfigForm.attachmentTemplates).some(
([, template]) => !String(template.type || '').trim()
);
if (missingTemplateType) {
callback(new Error('附加映射时,请完整填写所有附件模板 type'));
return;
}
const hasEmptyBodyTemplate = Object.entries(streamConfigForm.attachmentTemplates).some(([, template]) => {
const validRows = template.bodyFields.filter((item) => String(item.key || '').trim() !== '' && String(item.value || '').trim() !== '');
return validRows.length === 0;
});
if (hasEmptyBodyTemplate) {
callback(new Error('附加映射时,每个附件模板至少需要一个 body 字段'));
return;
}
const hasInvalidBodyField = Object.entries(streamConfigForm.attachmentTemplates).some(([, template]) =>
template.bodyFields.some(
(item) =>
(String(item.key || '').trim() === '' && String(item.value || '').trim() !== '') ||
(String(item.key || '').trim() !== '' && String(item.value || '').trim() === '')
)
);
if (hasInvalidBodyField) {
callback(new Error('附件模板 body 的键和值都必须完整填写'));
return;
}
callback();
},
trigger: 'change',
},
],
},
dialog: {
isShowDialog: false,
type: '',
title: '',
submitTxt: '',
loading: false,
detailLoading: false,
},
showAdvanced: false,
headers: [] as KeyValueField[],
formFields: [] as Array<FormField>,
requestMappingFields: [] as RequestMappingField[],
responseMappingFields: [] as ResponseMappingField[],
extendMappingFields: [] as KeyValueField[],
tokenConfigFields: [] as KeyValueField[],
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: KeyValueField[]) => {
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): KeyValueField[] => {
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 buildAsyncQueryConfig = () => {
const statusValues: Record<string, string> = {};
asyncQueryConfigForm.statusValueFields.forEach((item) => {
const key = String(item.key || '').trim();
if (!key) return;
statusValues[key] = String(item.value ?? '');
});
return {
url: String(asyncQueryConfigForm.url || '').trim(),
method: String(asyncQueryConfigForm.method || 'POST').trim() || 'POST',
task_id: String(asyncQueryConfigForm.taskId || '').trim(),
result_path: String(asyncQueryConfigForm.resultPath || '').trim(),
status_path: String(asyncQueryConfigForm.statusPath || '').trim(),
status_values: statusValues,
interval_seconds: Number(asyncQueryConfigForm.intervalSeconds || 0),
};
};
const addAsyncQueryStatusValueField = () => {
asyncQueryConfigForm.statusValueFields.push({ key: '', value: '' });
};
const addStreamBodyField = (templateKey: keyof StreamConfigForm['attachmentTemplates']) => {
streamConfigForm.attachmentTemplates[templateKey].bodyFields.push({ key: '', value: '' });
};
const removeStreamBodyField = (templateKey: keyof StreamConfigForm['attachmentTemplates'], index: number) => {
const template = streamConfigForm.attachmentTemplates[templateKey];
if (template.bodyFields.length <= 1) {
template.bodyFields[0] = { key: '', value: '' };
return;
}
template.bodyFields.splice(index, 1);
};
const removeAsyncQueryStatusValueField = (index: number) => {
asyncQueryConfigForm.statusValueFields.splice(index, 1);
};
const validateAndConfirmExtendMapping = async () => {
await extendMappingDialogFormRef.value?.validate?.();
await editModuleFormRef.value?.validateField?.('extendMapping');
showExtendMappingDialog.value = false;
};
const objectToFields = (obj: Record<string, unknown>) => {
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value ?? '') }));
};
const resetStreamConfigForm = () => {
const next = createEmptyStreamConfigForm();
streamConfigForm.targetContentPath = next.targetContentPath;
streamConfigForm.attachmentTemplates.audio.type = next.attachmentTemplates.audio.type;
streamConfigForm.attachmentTemplates.audio.bodyFields = next.attachmentTemplates.audio.bodyFields;
streamConfigForm.attachmentTemplates.image.type = next.attachmentTemplates.image.type;
streamConfigForm.attachmentTemplates.image.bodyFields = next.attachmentTemplates.image.bodyFields;
streamConfigForm.attachmentTemplates.video.type = next.attachmentTemplates.video.type;
streamConfigForm.attachmentTemplates.video.bodyFields = next.attachmentTemplates.video.bodyFields;
};
const applyStreamAttachmentTemplate = (template: StreamAttachmentTemplateForm, raw: unknown) => {
template.type = '';
template.bodyFields = [{ key: '', value: '' }];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return;
}
const rawTemplate = raw as Record<string, unknown>;
template.type = String(rawTemplate.type || '');
template.bodyFields = ensureKeyValueRows(parseFieldsUnified(rawTemplate.body), () => ({ key: '', value: '' }));
};
const applyStreamConfig = (raw: unknown) => {
resetStreamConfigForm();
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return;
}
const streamConfig = raw as Record<string, unknown>;
streamConfigForm.targetContentPath = String(streamConfig.target_content_path || '');
const attachmentTemplates =
streamConfig.attachment_templates && typeof streamConfig.attachment_templates === 'object' && !Array.isArray(streamConfig.attachment_templates)
? (streamConfig.attachment_templates as Record<string, unknown>)
: {};
applyStreamAttachmentTemplate(streamConfigForm.attachmentTemplates.audio, attachmentTemplates.audio);
applyStreamAttachmentTemplate(streamConfigForm.attachmentTemplates.image, attachmentTemplates.image);
applyStreamAttachmentTemplate(streamConfigForm.attachmentTemplates.video, attachmentTemplates.video);
};
const buildStreamAttachmentTemplate = (template: StreamAttachmentTemplateForm) => ({
type: String(template.type || '').trim(),
body: fieldsToUnknownObject(template.bodyFields.filter((item) => String(item.key || '').trim() !== '')),
});
const buildExtendMappingPayload = () => {
return {
target_content_path: String(streamConfigForm.targetContentPath || '').trim(),
attachment_templates: {
audio: buildStreamAttachmentTemplate(streamConfigForm.attachmentTemplates.audio),
image: buildStreamAttachmentTemplate(streamConfigForm.attachmentTemplates.image),
video: buildStreamAttachmentTemplate(streamConfigForm.attachmentTemplates.video),
},
};
};
const buildQueryConfig = () => {
if (Number(state.ruleForm.callMode) === 1) {
return buildAsyncQueryConfig();
}
return {};
};
// 添加请求头
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: '', required: false, isFirstFrame: false, isLastFrame: false });
};
const removeRequestMappingField = (index: number) => {
const removed = state.requestMappingFields[index];
state.requestMappingFields.splice(index, 1);
if (removed?.required) {
state.ruleForm.requiredFields = state.ruleForm.requiredFields.filter((item) => item !== String(removed.key || '').trim());
}
if (removed?.isFirstFrame) {
state.ruleForm.firstFrame = '';
}
if (removed?.isLastFrame) {
state.ruleForm.lastFrame = '';
}
};
const toggleRequiredField = (index: number) => {
const row = state.requestMappingFields[index];
if (!row) return;
row.required = !row.required;
state.ruleForm.requiredFields = state.requestMappingFields
.filter((field) => field.required)
.map((field) => String(field.key || '').trim())
.filter(Boolean);
};
const setFirstFrameField = (index: number) => {
state.requestMappingFields.forEach((field, i) => {
field.isFirstFrame = i === index;
if (i === index) {
field.isLastFrame = false;
}
});
state.ruleForm.firstFrame = state.requestMappingFields[index]?.key?.trim?.() || '';
if (state.requestMappingFields[index]?.isLastFrame) {
state.ruleForm.lastFrame = '';
}
};
const setLastFrameField = (index: number) => {
state.requestMappingFields.forEach((field, i) => {
field.isLastFrame = i === index;
if (i === index) {
field.isFirstFrame = false;
}
});
state.ruleForm.lastFrame = state.requestMappingFields[index]?.key?.trim?.() || '';
if (state.requestMappingFields[index]?.isFirstFrame) {
state.ruleForm.firstFrame = '';
}
};
const syncRequestSpecialFieldsOnKeyChange = (index: number) => {
const row = state.requestMappingFields[index];
if (!row) return;
if (row.required) {
const nextKey = row.key?.trim?.() || '';
state.ruleForm.requiredFields = state.requestMappingFields
.filter((field) => field.required)
.map((field) => String(field.key || '').trim())
.filter(Boolean);
if (!nextKey) {
row.required = false;
}
}
if (row.isFirstFrame) {
state.ruleForm.firstFrame = row.key?.trim?.() || '';
}
if (row.isLastFrame) {
state.ruleForm.lastFrame = row.key?.trim?.() || '';
}
};
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 ensureKeyValueRows = <T extends KeyValueField>(rows: T[], createEmpty: () => T): T[] => (rows.length ? rows : [createEmpty()]);
// 解析旧格式的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>) => {
resetStreamConfigForm();
state.mainBodyIndex = -1;
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 = '';
}
Object.assign(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'),
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,
callMode: Number(row.callMode ?? 0),
firstFrame: String(row.firstFrame || ''),
lastFrame: String(row.lastFrame || ''),
requiredFields: Array.isArray(row.requiredFields) ? row.requiredFields.map((item) => String(item || '').trim()).filter(Boolean) : [],
maxConcurrency: Number(row.maxConcurrency ?? 10),
timeoutSeconds,
retryTimes: Number(row.retryTimes ?? 3),
autoCleanSeconds: Number(row.autoCleanSeconds ?? 300),
extendMapping: '{}',
responseTokenField: String(row.responseTokenField || ''),
tokenConfig: '{}',
});
state.headers = ensureKeyValueRows(parseHeaders(row.headMsg), () => ({ key: '', value: '' }));
state.formFields = parseFormFieldsUnified(row.form);
state.requestMappingFields = ensureKeyValueRows(parseRequestMappingFields(row.requestMapping) as RequestMappingField[], () => ({
key: '',
value: '',
required: false,
isFirstFrame: false,
isLastFrame: false,
}));
state.requestMappingFields.forEach((field) => {
const fieldKey = String(field.key || '').trim();
field.required = fieldKey !== '' && state.ruleForm.requiredFields.includes(fieldKey);
field.isFirstFrame = fieldKey !== '' && fieldKey === String(row.firstFrame || '').trim();
field.isLastFrame = fieldKey !== '' && fieldKey === String(row.lastFrame || '').trim();
});
state.responseMappingFields = ensureKeyValueRows(parseResponseMappingFields(row.responseMapping) as ResponseMappingField[], () => ({
key: '',
value: '',
isMainBody: false,
isTokenField: false,
}));
state.extendMappingFields = ensureKeyValueRows(parseFieldsUnified(row.extendMapping), () => ({ key: '', value: '' }));
state.tokenConfigFields = ensureKeyValueRows(parseFieldsUnified(row.tokenConfig), () => ({ key: '', value: '' }));
applyStreamConfig(row.extendMapping);
// 根据 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 字段设置返回主体标记(单选)
const responseBodyKey = String(row.responseBody || '').trim();
if (responseBodyKey) {
const bodyFieldIndex = state.responseMappingFields.findIndex((f) => String(f.key || '').trim() === responseBodyKey);
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>;
asyncQueryConfigForm.url = String(qc.url || '');
asyncQueryConfigForm.method = String(qc.method || 'POST') || 'POST';
asyncQueryConfigForm.taskId = String(qc.task_id || '');
asyncQueryConfigForm.resultPath = String(qc.result_path || '');
asyncQueryConfigForm.statusPath = String(qc.status_path || '');
asyncQueryConfigForm.intervalSeconds = Number(qc.interval_seconds ?? 2) || 2;
asyncQueryConfigForm.statusValueFields = ensureKeyValueRows(objectToFields((qc.status_values as Record<string, unknown>) || {}), () => ({
key: '',
value: '',
}));
} else {
asyncQueryConfigForm.url = '';
asyncQueryConfigForm.method = 'POST';
asyncQueryConfigForm.taskId = '';
asyncQueryConfigForm.resultPath = '';
asyncQueryConfigForm.statusPath = '';
asyncQueryConfigForm.intervalSeconds = 2;
asyncQueryConfigForm.statusValueFields = [{ key: '', value: '' }];
}
};
// 打开弹窗(编辑时会请求 /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',
headMsg: '',
isPrivate: props.isSuperAdmin ? 1 : 0,
apiKey: '',
enabled: 1,
isChatModel: 0,
callMode: 0,
firstFrame: '',
lastFrame: '',
requiredFields: [],
maxConcurrency: 10,
timeoutSeconds: 30,
retryTimes: 3,
autoCleanSeconds: 300,
extendMapping: '{}',
responseTokenField: '',
tokenConfig: '{}',
};
state.headers = [{ key: '', value: '' }];
state.formFields = [createEmptyFormField()];
state.requestMappingFields = [{ key: '', value: '', required: false, isFirstFrame: false, isLastFrame: false }];
state.responseMappingFields = [{ key: '', value: '', isMainBody: false, isTokenField: false }];
state.mainBodyIndex = -1;
state.extendMappingFields = [{ key: '', value: '' }];
state.tokenConfigFields = [{ key: '', value: '' }];
asyncQueryConfigForm.url = '';
asyncQueryConfigForm.method = 'POST';
asyncQueryConfigForm.taskId = '';
asyncQueryConfigForm.resultPath = '';
asyncQueryConfigForm.statusPath = '';
asyncQueryConfigForm.intervalSeconds = 2;
asyncQueryConfigForm.statusValueFields = [{ key: '', value: '' }];
resetStreamConfigForm();
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 (Number(state.ruleForm.callMode) === 1) {
await editModuleFormRef.value?.validateField?.('asyncQueryConfig');
}
await editModuleFormRef.value?.validateField?.('extendMapping');
// 验证响应映射(如果有配置)
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 requiredFields = state.requestMappingFields
.filter((f) => Boolean(f.required) && String(f.key || '').trim() !== '')
.map((f) => String(f.key || '').trim());
state.ruleForm.requiredFields = requiredFields;
const responseMapping = fieldsToObject(state.responseMappingFields.filter((f) => String(f.key || '').trim() !== ''));
// 获取被设置为返回主体的字段 key
const responseBodyField = state.responseMappingFields.find((f) => f.isMainBody && String(f.key || '').trim() !== '');
const responseBody = responseBodyField ? responseBodyField.key.trim() : '';
// 获取计费字段(可选)
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,
callMode: state.ruleForm.callMode,
// 确保 API 密钥只在 isPrivate=1 时提交
apiKey: state.ruleForm.isPrivate === 1 ? String(state.ruleForm.apiKey ?? '').trim() : '',
form: processedFormFields,
requestMapping,
requiredFields,
firstFrame: String(state.ruleForm.firstFrame || '').trim(),
lastFrame: String(state.ruleForm.lastFrame || '').trim(),
responseMapping,
responseBody,
maxConcurrency: state.ruleForm.maxConcurrency,
timeoutSeconds: state.ruleForm.timeoutSeconds,
retryTimes: state.ruleForm.retryTimes,
autoCleanSeconds: state.ruleForm.autoCleanSeconds,
extendMapping: buildExtendMappingPayload(),
responseTokenField,
tokenConfig: fieldsToUnknownObject(state.tokenConfigFields.filter((f) => String(f.key || '').trim() !== '')),
queryConfig: buildQueryConfig(),
streamConfig: undefined,
};
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;
}
}
}
.async-query-status-values {
width: 100%;
}
.provider-section-col {
margin-bottom: 0;
}
.provider-section-title {
font-size: 13px;
font-weight: 600;
color: #606266;
padding: 2px 0 4px;
}
.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;
}
}
}
}
.stream-dialog-form {
padding-top: 4px;
}
.stream-dialog-panel {
padding: 14px 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fcfcfd;
margin-bottom: 14px;
&__row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: start;
column-gap: 12px;
row-gap: 6px;
}
&__label {
height: 32px;
line-height: 32px;
font-size: 13px;
color: #606266;
padding-top: 1px;
&.required::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
}
&__item {
margin-bottom: 2px;
:deep(.el-form-item__content) {
line-height: 32px;
}
}
&__item--full {
width: 100%;
}
}
.stream-config-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.stream-template-card {
padding: 14px 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fcfcfd;
&__header {
margin-bottom: 10px;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #303133;
text-transform: capitalize;
}
}
.stream-template-grid {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
column-gap: 12px;
row-gap: 10px;
align-items: start;
&__label {
height: 32px;
line-height: 32px;
font-size: 13px;
color: #606266;
padding-top: 1px;
&.required::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
}
&__label--top {
padding-top: 2px;
height: auto;
line-height: 20px;
}
&__item {
margin-bottom: 2px;
:deep(.el-form-item__content) {
line-height: 32px;
}
}
&__item--full {
width: 100%;
}
}
.stream-body-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.stream-body-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr) 44px;
column-gap: 8px;
align-items: center;
&__input {
width: 100%;
}
&__separator {
text-align: center;
font-weight: 600;
color: #606266;
}
&__delete {
justify-self: end;
padding: 0;
}
}
.stream-body-list__add {
align-self: flex-start;
padding: 0;
}
:deep(.stream-dialog-form .el-input__wrapper) {
min-height: 32px;
}
:deep(.stream-dialog-form .el-input__inner) {
height: 32px;
}
:deep(.stream-dialog-form .el-form-item) {
margin-bottom: 0;
}
:deep(.stream-dialog-form .el-form-item__content) {
display: block;
}
:deep(.stream-dialog-form .el-form-item__error) {
position: static;
padding-top: 4px;
line-height: 1.2;
white-space: normal;
}
.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>