增加智能体数据留存功能

This commit is contained in:
2026-08-27 00:24:26 +08:00
parent e297a4871f
commit 7078584a40
10 changed files with 3079 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import request from "@/utils/request";
/**
* 智能体 API 管理模块
* 按上游接口(OpenAI / Anthropic / Gemini 等)存储调用地址、Key、模型列表
*/
/** 上游接口配置列表 */
export function getAgentApiList(params) {
return request({
url: "/platform/agentApi/list",
method: "get",
params,
});
}
/** 上游接口配置详情 */
export function getAgentApiDetail(id) {
return request({
url: `/platform/agentApi/${id}`,
method: "get",
});
}
/** 新增上游接口配置 */
export function createAgentApi(data) {
return request({
url: "/platform/agentApi",
method: "post",
data,
});
}
/** 更新上游接口配置 */
export function updateAgentApi(id, data) {
return request({
url: `/platform/agentApi/${id}`,
method: "put",
data,
});
}
/** 删除上游接口配置 */
export function deleteAgentApi(id) {
return request({
url: `/platform/agentApi/${id}`,
method: "delete",
});
}
/** 批量删除 */
export function batchDeleteAgentApi(ids) {
return request({
url: "/platform/agentApi/batchDelete",
method: "post",
data: { ids },
});
}
/** 切换启用状态 */
export function toggleAgentApiStatus(id, status) {
return request({
url: `/platform/agentApi/${id}/status`,
method: "post",
data: { status },
});
}
/** 测试指定配置下某个模型的连通性 */
export function testAgentApiConnection(data) {
return request({
url: "/platform/agentApi/test",
method: "post",
data,
});
}
@@ -0,0 +1,301 @@
<template>
<el-drawer v-model="visible" title="上游接口详情" size="580px" destroy-on-close>
<div v-if="detail" class="detail-wrap" v-loading="loading">
<!-- 顶部标识 -->
<div class="detail-head" :style="{ borderLeftColor: providerColor(detail.provider) }">
<div class="provider-badge" :style="{ background: providerColor(detail.provider) }">
{{ providerInitial(detail.provider) }}
</div>
<div class="head-text">
<div class="head-name">{{ providerLabel(detail.provider) }}</div>
<div class="head-provider">
{{ apiKeys.length }} 个密钥 · {{ models.length }} 个模型
</div>
</div>
<el-tag :type="Number(detail.status) === 1 ? 'success' : 'info'" size="small">
{{ Number(detail.status) === 1 ? '已启用' : '已禁用' }}
</el-tag>
</div>
<el-descriptions :column="1" border class="detail-desc">
<el-descriptions-item label="ID" label-width="130px">
{{ detail.id }}
</el-descriptions-item>
<el-descriptions-item label="接口地址" label-width="130px">
<div class="url-text">{{ detail.base_url || '—' }}</div>
<el-tag
v-if="Number(detail.use_custom_url) === 1"
type="warning"
size="small"
effect="plain"
class="mt6"
>
已启用自定义地址,实际调用走下方地址
</el-tag>
</el-descriptions-item>
<el-descriptions-item
v-if="Number(detail.use_custom_url) === 1"
label="自定义地址"
label-width="130px"
>
<div class="url-text">{{ detail.custom_url || '—' }}</div>
</el-descriptions-item>
<el-descriptions-item label="API Key" label-width="130px">
<div v-if="apiKeys.length > 0" class="key-list">
<div v-for="(k, idx) in apiKeys" :key="idx" class="key-item">
<div class="key-item-head">
<el-tag v-if="k.remark" size="small" type="success" effect="plain">
{{ k.remark }}
</el-tag>
<span v-else class="key-no-remark">密钥 {{ idx + 1 }}</span>
<el-button size="small" text type="primary" @click="copyModel(k.key)">
复制
</el-button>
</div>
<el-input :model-value="k.key" readonly class="key-input" />
</div>
</div>
<span v-else class="placeholder">未配置密钥</span>
</el-descriptions-item>
<el-descriptions-item label="模型列表" label-width="130px">
<div v-if="models.length > 0" class="model-tags">
<el-tag
v-for="m in models"
:key="m"
size="small"
type="info"
effect="plain"
class="model-tag"
:title="`点击复制:${m}`"
@click="copyModel(m)"
>
{{ m }}
</el-tag>
</div>
<span v-else class="placeholder">未配置模型</span>
<div v-if="models.length > 0" class="model-count">
共 {{ models.length }} 个模型,点击可复制
</div>
</el-descriptions-item>
<el-descriptions-item label="备注" label-width="130px">
<div class="content-text">{{ detail.remark || '—' }}</div>
</el-descriptions-item>
<el-descriptions-item label="创建时间" label-width="130px">
{{ detail.create_time || '—' }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" label-width="130px">
{{ detail.update_time || '—' }}
</el-descriptions-item>
</el-descriptions>
</div>
<div v-else-if="loading" class="empty-wrap">
<el-skeleton :rows="10" animated />
</div>
<div v-else class="empty-wrap">
<el-empty description="暂无数据" />
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { getAgentApiDetail } from '@/api/agentapimanagement'
import {
providerLabel,
providerColor,
providerInitial,
normalizeModels,
normalizeApiKeys,
} from '../constants'
const visible = ref(false)
const loading = ref(false)
const detail = ref(null)
const models = computed(() => normalizeModels(detail.value?.models))
const apiKeys = computed(() => normalizeApiKeys(detail.value?.api_keys))
async function open(id) {
detail.value = null
visible.value = true
loading.value = true
try {
const res = await getAgentApiDetail(id)
if (res?.code === 200 && res.data) {
detail.value = res.data
} else {
ElMessage.error(res?.msg || '加载失败')
visible.value = false
}
} finally {
loading.value = false
}
}
/** 复制文本(密钥 / 模型名共用) */
async function copyModel(text) {
if (!text) {
ElMessage.warning('内容为空')
return
}
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
defineExpose({ open })
</script>
<style lang="less" scoped>
.detail-wrap {
padding: 4px 0 20px;
}
.detail-head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
margin-bottom: 16px;
background: var(--el-fill-color-light);
border-left: 4px solid var(--el-color-primary);
border-radius: 4px;
.provider-badge {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 19px;
font-weight: 700;
line-height: 1;
}
.head-text {
flex: 1;
min-width: 0;
}
.head-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.head-provider {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.url-text {
word-break: break-all;
color: #409eff;
line-height: 1.5;
}
.mt6 {
margin-top: 6px;
}
.key-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.key-item {
.key-item-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.key-no-remark {
font-size: 12px;
color: #909399;
}
.key-input {
width: 100%;
:deep(.el-input__wrapper) {
background: #f5f7fa;
}
:deep(.el-input__inner) {
font-family: Consolas, Monaco, monospace;
font-size: 13px;
}
}
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.model-tag {
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&:active {
transform: scale(0.96);
}
}
}
.model-count {
margin-top: 6px;
font-size: 12px;
color: #909399;
}
.placeholder {
color: #c0c4cc;
font-size: 13px;
}
.content-text {
white-space: pre-wrap;
word-break: break-all;
color: #606266;
line-height: 1.6;
}
.empty-wrap {
padding: 40px 20px;
}
</style>
@@ -0,0 +1,557 @@
<template>
<el-drawer
v-model="visible"
:title="drawerTitle"
size="640px"
destroy-on-close
@closed="onClosed"
>
<el-alert
v-if="isCopy"
type="info"
show-icon
:closable="false"
class="copy-tip"
title="已带入源配置的全部内容,修改后保存将创建一条新记录,源配置不受影响"
/>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="110px"
v-loading="loading"
label-position="right"
class="agent-form"
>
<el-form-item label="上游接口" prop="provider">
<el-input
v-model="form.provider"
placeholder="自行填写,例如:OpenAI 主账号、Claude 中转、公司内部网关"
maxlength="100"
show-word-limit
clearable
/>
<span class="form-tip">用于区分不同上游来源,内容完全由你决定</span>
</el-form-item>
<el-form-item label="接口地址" prop="base_url">
<el-input
v-model="form.base_url"
placeholder="例如:https://api.openai.com/v1"
:disabled="form.use_custom_url"
clearable
/>
<el-checkbox v-model="form.use_custom_url" class="custom-check">
使用自定义地址(中转 / 代理 / 私有部署)
</el-checkbox>
</el-form-item>
<el-form-item v-if="form.use_custom_url" label="自定义地址" prop="custom_url">
<el-input
v-model="form.custom_url"
placeholder="例如:https://your-proxy.com/v1"
clearable
/>
<span class="form-tip">勾选自定义后,实际调用将使用此地址,上方地址仅作留档</span>
</el-form-item>
<el-form-item label="API Key" prop="api_keys">
<div class="keys-box">
<div v-if="form.api_keys.length === 0" class="no-key">暂未添加密钥</div>
<div v-for="(item, idx) in form.api_keys" :key="idx" class="key-row">
<div class="key-index">{{ idx + 1 }}</div>
<div class="key-fields">
<el-input
v-model="item.key"
type="password"
placeholder="请输入 API Key"
show-password
clearable
autocomplete="new-password"
@blur="validateKeys"
/>
<el-input
v-model="item.remark"
placeholder="备注(可选),例如:mimo198、188 号账号"
maxlength="100"
clearable
class="key-remark"
/>
</div>
<el-button
text
type="danger"
:disabled="form.api_keys.length <= 1"
@click="removeKey(idx)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" class="add-key-btn" @click="addKey">
<el-icon><Plus /></el-icon>
添加密钥
</el-button>
<span class="form-tip">
同一上游下有多个账号时可添加多条密钥,备注用于区分账号,可留空
</span>
</div>
</el-form-item>
<el-form-item label="模型列表" prop="models">
<div class="models-box">
<div v-if="form.models.length > 0" class="model-tags">
<el-tag
v-for="m in form.models"
:key="m"
closable
size="default"
class="model-tag"
@close="removeModel(m)"
>
{{ m }}
</el-tag>
</div>
<div v-else class="no-model">暂未添加模型</div>
<div class="model-input-row">
<el-input
v-if="inputVisible"
ref="inputRef"
v-model="inputValue"
size="small"
placeholder="输入模型名后回车"
class="model-input"
@keyup.enter="confirmInput"
@blur="confirmInput"
/>
<el-button v-else size="small" @click="showInput">
<el-icon><Plus /></el-icon>
添加模型
</el-button>
</div>
</div>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="可选:用途说明、配额、到期时间等"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Delete } from '@element-plus/icons-vue'
import { getAgentApiDetail, createAgentApi, updateAgentApi } from '@/api/agentapimanagement'
import { normalizeModels, normalizeApiKeys } from '../constants'
const emit = defineEmits(['saved'])
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const isAdd = ref(true)
// 复制新建:带入源配置内容,但保存时走新增
const isCopy = ref(false)
const formRef = ref(null)
const drawerTitle = computed(() => {
if (isCopy.value) return '复制新建上游接口'
return isAdd.value ? '新增上游接口' : '编辑上游接口'
})
// 模型 tag 输入
const inputVisible = ref(false)
const inputValue = ref('')
const inputRef = ref(null)
const form = reactive({
id: 0,
provider: '',
base_url: '',
use_custom_url: false,
custom_url: '',
api_keys: [],
models: [],
status: 1,
remark: '',
})
const rules = {
provider: [{ required: true, message: '请输入上游接口名称', trigger: 'blur' }],
base_url: [
{
validator: (rule, value, callback) => {
// 勾选自定义地址时上方地址可为空
if (form.use_custom_url) {
callback()
return
}
if (!value) {
callback(new Error('请输入接口地址'))
return
}
if (!/^https?:\/\//i.test(value)) {
callback(new Error('地址需以 http:// 或 https:// 开头'))
return
}
callback()
},
trigger: 'blur',
},
],
custom_url: [
{
validator: (rule, value, callback) => {
if (!form.use_custom_url) {
callback()
return
}
if (!value) {
callback(new Error('请输入自定义地址'))
return
}
if (!/^https?:\/\//i.test(value)) {
callback(new Error('地址需以 http:// 或 https:// 开头'))
return
}
callback()
},
trigger: 'blur',
},
],
api_keys: [
{
validator: (rule, value, callback) => {
const filled = (value || []).filter((k) => String(k.key || '').trim() !== '')
if (filled.length === 0) {
callback(new Error('请至少添加一个 API Key'))
return
}
// 同一配置内不允许重复密钥
const keys = filled.map((k) => k.key.trim())
if (new Set(keys).size !== keys.length) {
callback(new Error('存在重复的 API Key'))
return
}
callback()
},
trigger: 'change',
},
],
models: [
{
validator: (rule, value, callback) => {
if (!value || value.length === 0) {
callback(new Error('请至少添加一个模型'))
return
}
callback()
},
trigger: 'change',
},
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
// 显示模型输入框
async function showInput() {
inputVisible.value = true
await nextTick()
inputRef.value?.focus?.()
}
// 确认添加模型
function confirmInput() {
const val = inputValue.value.trim()
if (val) {
if (form.models.includes(val)) {
ElMessage.warning('该模型已存在')
} else {
form.models.push(val)
formRef.value?.validateField?.('models')
}
}
inputVisible.value = false
inputValue.value = ''
}
// 移除模型
function removeModel(m) {
const idx = form.models.indexOf(m)
if (idx > -1) {
form.models.splice(idx, 1)
formRef.value?.validateField?.('models')
}
}
// 新增一条空密钥
function addKey() {
form.api_keys.push({ key: '', remark: '' })
}
// 移除指定密钥(至少保留一条输入行)
function removeKey(idx) {
if (form.api_keys.length <= 1) return
form.api_keys.splice(idx, 1)
validateKeys()
}
function validateKeys() {
formRef.value?.validateField?.('api_keys')
}
function resetForm() {
form.id = 0
form.provider = ''
form.base_url = ''
form.use_custom_url = false
form.custom_url = ''
// 默认给一条空行,方便直接填写
form.api_keys = [{ key: '', remark: '' }]
form.models = []
form.status = 1
form.remark = ''
inputVisible.value = false
inputValue.value = ''
}
/**
* 打开抽屉
* @param {number} [id] 传入 ID 表示编辑,不传表示新增
*/
async function open(id) {
resetForm()
isCopy.value = false
isAdd.value = !id
visible.value = true
await nextTick()
formRef.value?.clearValidate?.()
if (id) {
await loadInto(id)
}
}
/**
* 复制新建:带入源配置的全部内容,但保存时走新增接口
* @param {number} id 源配置 ID
*/
async function openCopy(id) {
resetForm()
isCopy.value = true
isAdd.value = true
visible.value = true
await nextTick()
formRef.value?.clearValidate?.()
const ok = await loadInto(id)
if (ok) {
// 不继承源记录的主键,保证保存时创建新记录
form.id = 0
// 名称加后缀,避免与源配置混淆
if (form.provider) {
form.provider = `${form.provider} 副本`.slice(0, 100)
}
}
}
/** 拉取指定配置填入表单,返回是否成功 */
async function loadInto(id) {
loading.value = true
try {
const res = await getAgentApiDetail(id)
if (res?.code !== 200 || !res.data) {
ElMessage.error(res?.msg || '加载失败')
visible.value = false
return false
}
const d = res.data
form.id = d.id
form.provider = d.provider || ''
form.base_url = d.base_url || ''
form.use_custom_url = Number(d.use_custom_url) === 1
form.custom_url = d.custom_url || ''
const keys = normalizeApiKeys(d.api_keys)
form.api_keys = keys.length > 0 ? keys : [{ key: '', remark: '' }]
form.models = normalizeModels(d.models)
form.status = d.status === undefined ? 1 : Number(d.status)
form.remark = d.remark || ''
return true
} finally {
loading.value = false
}
}
function onClosed() {
resetForm()
isCopy.value = false
}
async function submit() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// 过滤掉空行,备注保持原样(允许为空)
const cleanedKeys = form.api_keys
.map((k) => ({ key: String(k.key || '').trim(), remark: String(k.remark || '').trim() }))
.filter((k) => k.key !== '')
const payload = {
provider: form.provider.trim(),
base_url: form.base_url || '',
use_custom_url: form.use_custom_url ? 1 : 0,
custom_url: form.use_custom_url ? form.custom_url : '',
api_keys: cleanedKeys,
models: form.models,
status: Number(form.status),
remark: form.remark || '',
}
const res = isAdd.value
? await createAgentApi(payload)
: await updateAgentApi(form.id, payload)
if (res?.code === 200) {
ElMessage.success(isCopy.value ? '复制新建成功' : isAdd.value ? '新增成功' : '保存成功')
visible.value = false
emit('saved')
} else {
ElMessage.error(res?.msg || '操作失败')
}
} finally {
saving.value = false
}
}
defineExpose({ open, openCopy })
</script>
<style lang="less" scoped>
.copy-tip {
margin-bottom: 12px;
}
.agent-form {
padding: 10px 20px 40px 0;
}
.form-tip {
display: block;
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.custom-check {
margin-top: 6px;
}
.keys-box {
width: 100%;
}
.no-key {
font-size: 12px;
color: #c0c4cc;
margin-bottom: 8px;
}
.key-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
padding: 8px;
background: var(--el-fill-color-lighter);
border-radius: 4px;
.key-index {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-top: 5px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
font-size: 12px;
line-height: 22px;
text-align: center;
}
.key-fields {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
}
.add-key-btn {
margin-bottom: 4px;
}
.models-box {
width: 100%;
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.no-model {
font-size: 12px;
color: #c0c4cc;
margin-bottom: 8px;
}
.model-input-row {
.model-input {
width: 240px;
}
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
</style>
@@ -0,0 +1,332 @@
<template>
<el-drawer v-model="visible" title="测试上游接口连通性" size="560px" destroy-on-close>
<div v-loading="loading">
<el-form label-width="100px" label-position="right" class="test-form">
<el-form-item label="上游接口">
<el-input v-model="info.provider_label" disabled />
</el-form-item>
<el-form-item label="生效地址">
<el-input v-model="info.url" disabled />
</el-form-item>
<el-form-item label="测试密钥">
<el-select v-model="selectedKeyIndex" placeholder="请选择要测试的密钥" style="width: 100%">
<el-option
v-for="(k, idx) in apiKeys"
:key="idx"
:label="keyLabel(k, idx)"
:value="idx"
/>
</el-select>
<span class="form-tip">同一上游下有多个账号时,选择要验证的那个密钥</span>
</el-form-item>
<el-form-item label="测试模型">
<el-select
v-model="selectedModel"
placeholder="请选择要测试的模型"
style="width: 100%"
filterable
allow-create
default-first-option
>
<el-option v-for="m in models" :key="m" :label="m" :value="m" />
</el-select>
<span class="form-tip">可从已配置模型中选择,也可直接输入其他模型名</span>
</el-form-item>
<el-form-item label="测试提示">
<el-input
v-model="prompt"
type="textarea"
:rows="3"
placeholder="发送给模型的提示词"
/>
</el-form-item>
</el-form>
<div class="batch-row">
<el-button size="small" :loading="testing" @click="runTest">
<el-icon><Connection /></el-icon>
测试选中模型
</el-button>
<el-button
size="small"
type="primary"
plain
:loading="batchTesting"
:disabled="models.length === 0"
@click="runBatchTest"
>
<el-icon><Files /></el-icon>
测试全部模型({{ models.length }})
</el-button>
</div>
<el-divider content-position="left">测试结果</el-divider>
<div v-if="results.length === 0 && !testing && !batchTesting" class="empty-result">
<el-empty description="点击上方按钮开始测试" :image-size="80" />
</div>
<div v-else-if="testing || batchTesting" class="testing">
<el-icon class="is-loading"><Loading /></el-icon>
<span style="margin-left: 8px;">
正在测试{{ batchTesting ? `(${results.length}/${models.length})` : '' }},请稍候...
</span>
</div>
<div v-if="results.length > 0" class="result-list">
<div v-for="(r, idx) in results" :key="idx" class="result-item">
<div class="result-head">
<el-tag :type="r.success ? 'success' : 'danger'" size="small">
{{ r.success ? '成功' : '失败' }}
</el-tag>
<span class="result-model">{{ r.model }}</span>
<el-tag v-if="r.key_remark" size="small" type="info" effect="plain">
{{ r.key_remark }}
</el-tag>
<span v-if="r.latency_ms != null" class="result-latency">{{ r.latency_ms }} ms</span>
</div>
<div class="result-msg" :class="{ error: !r.success }">
{{ r.message || (r.success ? '连接正常' : '连接异常') }}
</div>
<pre v-if="r.response" class="result-response">{{ r.response }}</pre>
<pre v-if="!r.success && r.detail" class="result-response error">{{ r.detail }}</pre>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading, Connection, Files } from '@element-plus/icons-vue'
import { getAgentApiDetail, testAgentApiConnection } from '@/api/agentapimanagement'
import {
providerLabel,
effectiveUrl,
normalizeModels,
normalizeApiKeys,
maskKey,
} from '../constants'
const visible = ref(false)
const loading = ref(false)
const testing = ref(false)
const batchTesting = ref(false)
const currentId = ref(null)
const models = ref([])
const selectedModel = ref('')
const apiKeys = ref([])
const selectedKeyIndex = ref(0)
const prompt = ref('你好,请用一句话介绍你自己。')
const results = ref([])
const info = reactive({
provider_label: '',
url: '',
})
/** 密钥下拉的展示文本:有备注显示备注,无备注显示序号 + 脱敏值 */
function keyLabel(k, idx) {
const masked = maskKey(k.key)
return k.remark ? `${k.remark}(${masked})` : `密钥 ${idx + 1}(${masked})`
}
async function open(id) {
currentId.value = id
results.value = []
models.value = []
apiKeys.value = []
selectedModel.value = ''
selectedKeyIndex.value = 0
prompt.value = '你好,请用一句话介绍你自己。'
visible.value = true
loading.value = true
try {
const res = await getAgentApiDetail(id)
if (res?.code === 200 && res.data) {
const d = res.data
info.provider_label = providerLabel(d.provider)
info.url = effectiveUrl(d)
models.value = normalizeModels(d.models)
selectedModel.value = models.value[0] || ''
apiKeys.value = normalizeApiKeys(d.api_keys)
selectedKeyIndex.value = apiKeys.value.length > 0 ? 0 : -1
} else {
ElMessage.error(res?.msg || '加载失败')
visible.value = false
}
} finally {
loading.value = false
}
}
/** 测试单个模型,返回统一结构 */
async function testOne(model) {
try {
const res = await testAgentApiConnection({
id: currentId.value,
model,
key_index: selectedKeyIndex.value,
prompt: prompt.value || '你好',
})
if (res?.code === 200 && res.data) {
return { model, ...res.data }
}
return { model, success: false, message: res?.msg || '测试失败' }
} catch (err) {
return {
model,
success: false,
message: '测试请求异常',
detail: err?.message || String(err),
}
}
}
async function runTest() {
if (!selectedModel.value) {
ElMessage.warning('请选择要测试的模型')
return
}
if (selectedKeyIndex.value < 0) {
ElMessage.warning('该配置未添加密钥')
return
}
testing.value = true
results.value = []
try {
const r = await testOne(selectedModel.value)
results.value = [r]
} finally {
testing.value = false
}
}
async function runBatchTest() {
if (models.value.length === 0) {
ElMessage.warning('该配置未添加模型')
return
}
if (selectedKeyIndex.value < 0) {
ElMessage.warning('该配置未添加密钥')
return
}
batchTesting.value = true
results.value = []
try {
// 逐个串行测试,避免同时打满上游限流
for (const m of models.value) {
const r = await testOne(m)
results.value.push(r)
}
} finally {
batchTesting.value = false
}
}
defineExpose({ open })
</script>
<style lang="less" scoped>
.test-form {
padding: 10px 20px 10px 0;
}
.form-tip {
display: block;
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.batch-row {
display: flex;
gap: 8px;
padding: 0 0 4px 100px;
}
.empty-result {
padding: 20px 0;
}
.testing {
display: flex;
align-items: center;
justify-content: center;
padding: 30px 0;
color: #409eff;
font-size: 14px;
}
.result-list {
padding-bottom: 20px;
}
.result-item {
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--el-fill-color-lighter);
.result-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.result-model {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
word-break: break-all;
}
.result-latency {
margin-left: auto;
font-size: 12px;
color: #909399;
flex-shrink: 0;
}
.result-msg {
font-size: 13px;
color: #606266;
line-height: 1.5;
word-break: break-all;
&.error {
color: #f56c6c;
}
}
.result-response {
margin: 8px 0 0;
padding: 8px 10px;
background: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
&.error {
background: #fef0f0;
border-color: #fde2e2;
color: #f56c6c;
}
}
}
</style>
@@ -0,0 +1,133 @@
/**
* 智能体 API 管理 - 共享工具
*
* 上游接口(provider)为用户自由填写的文本,不做枚举限制。
* 这里只提供展示辅助(配色、首字母徽标)与数据规整能力。
*/
/** 徽标配色池,按上游名称哈希稳定取色,保证同一上游每次渲染颜色一致 */
const BADGE_COLORS = [
'#10a37f',
'#d97757',
'#4285f4',
'#4d6bfe',
'#ff6a00',
'#615ced',
'#3859ff',
'#16b98c',
'#7c3aed',
'#e6465e',
'#0ea5e9',
'#f59e0b',
];
/** 上游名称展示文本 */
export function providerLabel(value) {
const s = String(value || '').trim();
return s || '未填写上游';
}
/**
* 由上游名称稳定推导徽标颜色
* 用简单字符串哈希取模,纯展示用途
*/
export function providerColor(value) {
const s = String(value || '').trim();
if (!s) return '#909399';
let hash = 0;
for (let i = 0; i < s.length; i += 1) {
hash = (hash * 31 + s.charCodeAt(i)) % 100000;
}
return BADGE_COLORS[hash % BADGE_COLORS.length];
}
/**
* 徽标显示字符:取上游名称首个可见字符
* 英文转大写,中文原样显示
*/
export function providerInitial(value) {
const s = String(value || '').trim();
if (!s) return '?';
return s.charAt(0).toUpperCase();
}
/**
* 计算实际生效的接口地址
* 勾选了自定义地址时用 custom_url,否则用 base_url
*/
export function effectiveUrl(row) {
if (!row) return '';
return Number(row.use_custom_url) === 1 ? row.custom_url || '' : row.base_url || '';
}
/**
* 把后端返回的 models 统一规整成字符串数组
* 后端可能返回 JSON 字符串、数组或逗号分隔字符串
*/
export function normalizeModels(models) {
if (!models) return [];
if (Array.isArray(models)) return models.filter(Boolean).map((m) => String(m).trim());
if (typeof models === 'string') {
const raw = models.trim();
if (!raw) return [];
if (raw.startsWith('[')) {
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter(Boolean).map((m) => String(m).trim()) : [];
} catch {
return [];
}
}
return raw
.split(',')
.map((m) => m.trim())
.filter(Boolean);
}
return [];
}
/**
* 把后端返回的 api_keys 统一规整成 [{ key, remark }] 数组
* 兼容对象数组、纯字符串数组、单个裸密钥字符串
*/
export function normalizeApiKeys(apiKeys) {
if (!apiKeys) return [];
const toItem = (v) => {
if (v && typeof v === 'object') {
const key = String(v.key || '').trim();
if (!key) return null;
return { key, remark: String(v.remark || '').trim() };
}
const key = String(v || '').trim();
return key ? { key, remark: '' } : null;
};
if (Array.isArray(apiKeys)) {
return apiKeys.map(toItem).filter(Boolean);
}
if (typeof apiKeys === 'string') {
const raw = apiKeys.trim();
if (!raw) return [];
if (raw.startsWith('[')) {
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.map(toItem).filter(Boolean) : [];
} catch {
return [];
}
}
return [{ key: raw, remark: '' }];
}
return [];
}
/** API Key 脱敏:保留首 6 位与末 4 位 */
export function maskKey(key) {
if (!key) return '—';
const s = String(key);
if (s.length <= 12) return '*'.repeat(s.length);
return `${s.slice(0, 6)}${'*'.repeat(8)}${s.slice(-4)}`;
}
@@ -0,0 +1,738 @@
<template>
<div class="container-box agent-api-page">
<div class="header-bar">
<h2>智能体 API 管理</h2>
<div class="header-actions">
<el-button type="primary" @click="editRef.open()">
<el-icon><Plus /></el-icon>
新增上游接口
</el-button>
<el-button @click="reload" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider />
<!-- 搜索筛选 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="上游 / 地址 / 模型"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="上游接口">
<el-input
v-model="searchForm.provider"
placeholder="上游名称,支持模糊"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.status"
placeholder="全部"
clearable
style="width: 130px"
@change="handleSearch"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
查询
</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 瀑布流卡片区 -->
<div class="waterfall-wrap">
<div v-if="list.length > 0" class="waterfall">
<div v-for="row in list" :key="row.id" class="waterfall-item">
<div class="api-card" :class="{ disabled: Number(row.status) !== 1 }">
<!-- 卡片头:上游接口标识 -->
<div class="card-head" :style="{ borderTopColor: providerColor(row.provider) }">
<div class="provider-badge" :style="{ background: providerColor(row.provider) }">
{{ providerInitial(row.provider) }}
</div>
<div class="head-text">
<div class="card-title" :title="row.provider">
{{ providerLabel(row.provider) }}
</div>
<div class="card-subtitle">
{{ keysOf(row).length }} 个密钥 · {{ modelsOf(row).length }} 个模型
</div>
</div>
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, row)">
<el-button type="primary" size="small" class="card-action-btn">
操作
<el-icon class="action-arrow"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="detail">
<el-icon><View /></el-icon> 详情
</el-dropdown-item>
<el-dropdown-item command="test">
<el-icon><Connection /></el-icon> 测试连通性
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon> 编辑
</el-dropdown-item>
<el-dropdown-item command="copy">
<el-icon><CopyDocument /></el-icon> 复制新建
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon> 删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 接口地址 -->
<div class="card-section">
<div class="section-label">
接口地址
<el-tag v-if="Number(row.use_custom_url) === 1" type="warning" size="small" effect="plain">
自定义
</el-tag>
<span class="section-actions">
<el-button
text
type="primary"
size="small"
@click="copyText(effectiveUrl(row), '地址')"
>
复制
</el-button>
</span>
</div>
<div class="url-text" :title="effectiveUrl(row)">{{ effectiveUrl(row) || '—' }}</div>
</div>
<!-- API Key 列表:每条独立脱敏/显示/复制 -->
<div class="card-section">
<div class="section-label">
API Key
<span class="model-count">{{ keysOf(row).length }} 个</span>
</div>
<div v-if="keysOf(row).length > 0" class="key-list">
<div v-for="(k, idx) in keysOf(row)" :key="idx" class="key-item">
<div class="key-item-head">
<el-tag v-if="k.remark" size="small" type="success" effect="plain">
{{ k.remark }}
</el-tag>
<span v-else class="key-no-remark">密钥 {{ idx + 1 }}</span>
<span class="section-actions">
<el-button
text
type="primary"
size="small"
@click="toggleKey(`${row.id}-${idx}`)"
>
{{ revealedKeys.has(`${row.id}-${idx}`) ? '隐藏' : '显示' }}
</el-button>
<el-button
text
type="primary"
size="small"
@click="copyText(k.key, '密钥')"
>
复制
</el-button>
</span>
</div>
<div
class="key-text"
:title="revealedKeys.has(`${row.id}-${idx}`) ? k.key : ''"
>
{{ revealedKeys.has(`${row.id}-${idx}`) ? k.key : maskKey(k.key) }}
</div>
</div>
</div>
<div v-else class="empty-models">未配置密钥</div>
</div>
<!-- 模型列表:点击 tag 复制模型名 -->
<div class="card-section">
<div class="section-label">
模型列表
<span class="model-hint">点击可复制</span>
<span class="model-count">{{ modelsOf(row).length }} 个</span>
</div>
<div v-if="modelsOf(row).length > 0" class="model-tags">
<el-tag
v-for="m in modelsOf(row)"
:key="m"
size="small"
type="info"
effect="plain"
class="model-tag"
:title="`点击复制:${m}`"
@click="copyText(m, '模型名')"
>
{{ m }}
</el-tag>
</div>
<div v-else class="empty-models">未配置模型</div>
</div>
<!-- 备注 -->
<div v-if="row.remark" class="card-section">
<div class="section-label">备注</div>
<div class="remark-text">{{ row.remark }}</div>
</div>
<!-- 卡片底部 -->
<div class="card-foot">
<span class="foot-time">{{ row.create_time || '' }}</span>
</div>
</div>
</div>
</div>
<el-empty
v-else-if="!loading && !firstLoading"
description="暂无上游接口配置,点击右上角新增"
/>
<!-- 首屏骨架 -->
<div v-if="firstLoading" class="loading-block">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 滚动加载哨兵:进入视口即拉取下一页 -->
<div ref="sentinelRef" class="load-sentinel">
<div v-if="loadingMore" class="loading-block">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在加载更多...</span>
</div>
<div v-else-if="!hasMore && list.length > 0" class="list-end">
已加载全部 {{ total }} 条配置
</div>
</div>
</div>
<!-- 新增/编辑抽屉 -->
<AgentApiEdit ref="editRef" @saved="reload" />
<!-- 详情抽屉 -->
<AgentApiDetail ref="detailRef" />
<!-- 测试抽屉 -->
<AgentApiTest ref="testRef" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Refresh,
Search,
ArrowDown,
View,
Edit,
Delete,
Connection,
CopyDocument,
Loading,
} from '@element-plus/icons-vue'
import { getAgentApiList, deleteAgentApi } from '@/api/agentapimanagement'
import {
providerLabel,
providerColor,
providerInitial,
effectiveUrl,
normalizeModels,
normalizeApiKeys,
maskKey,
} from './constants'
import AgentApiEdit from './components/edit.vue'
import AgentApiDetail from './components/detail.vue'
import AgentApiTest from './components/test.vue'
const editRef = ref(null)
const detailRef = ref(null)
const testRef = ref(null)
// 滚动加载哨兵元素
const sentinelRef = ref(null)
let observer = null
const loading = ref(false) // 任一加载中(首屏或加载更多)
const firstLoading = ref(false) // 首屏加载中
const loadingMore = ref(false) // 追加下一页中
const list = ref([])
const total = ref(0)
const hasMore = ref(true)
// 每页条数与当前页码;滚动到底部时页码 +1 并追加数据
const PAGE_SIZE = 24
let page = 1
// 记录已点开明文的密钥,元素为 `${配置ID}-${密钥下标}`
const revealedKeys = ref(new Set())
const searchForm = reactive({
keyword: '',
provider: undefined,
status: undefined,
})
/** 取该行的模型数组 */
function modelsOf(row) {
return normalizeModels(row.models)
}
/** 取该行的密钥数组 */
function keysOf(row) {
return normalizeApiKeys(row.api_keys)
}
/** 切换单条密钥的明文/脱敏显示 */
function toggleKey(id) {
const next = new Set(revealedKeys.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
revealedKeys.value = next
}
/**
* 拉取一页数据
* @param {boolean} append true 追加到现有列表(滚动加载),false 替换(首次/搜索)
*/
async function fetchPage(append = false) {
if (loading.value) return
loading.value = true
if (append) {
loadingMore.value = true
} else {
firstLoading.value = true
}
try {
const params = {
page,
pageSize: PAGE_SIZE,
keyword: searchForm.keyword || undefined,
provider: searchForm.provider || undefined,
status: searchForm.status,
}
const res = await getAgentApiList(params)
if (res?.code === 200 && res.data) {
const rows = res.data.list || []
total.value = res.data.total ?? 0
list.value = append ? [...list.value, ...rows] : rows
// 本页返回不足一页,或已凑满总数,说明没有更多了
hasMore.value = rows.length >= PAGE_SIZE && list.value.length < total.value
} else {
if (!append) list.value = []
hasMore.value = false
ElMessage.error(res?.msg || '加载失败')
}
} catch (err) {
hasMore.value = false
if (!append) list.value = []
} finally {
loading.value = false
loadingMore.value = false
firstLoading.value = false
// 数据渲染后重新观察哨兵,应对"一页填不满视口"的情况
await nextTick()
observeSentinel()
}
}
/** 重置到第一页重新加载(搜索、刷新、增删改后调用) */
function reload() {
page = 1
hasMore.value = true
fetchPage(false)
}
/** 滚动到底部时加载下一页 */
function loadMore() {
if (loading.value || !hasMore.value) return
page += 1
fetchPage(true)
}
/** 用 IntersectionObserver 监听哨兵元素进入视口 */
function observeSentinel() {
if (!observer || !sentinelRef.value) return
observer.disconnect()
if (hasMore.value) {
observer.observe(sentinelRef.value)
}
}
function handleSearch() {
reload()
}
function resetSearch() {
searchForm.keyword = ''
searchForm.provider = undefined
searchForm.status = undefined
reload()
}
// 卡片下拉操作
function handleCommand(cmd, row) {
if (cmd === 'detail') {
detailRef.value?.open(row.id)
} else if (cmd === 'test') {
testRef.value?.open(row.id)
} else if (cmd === 'edit') {
editRef.value?.open(row.id)
} else if (cmd === 'copy') {
// 复制新建:带入该卡片数据,保存时创建新记录
editRef.value?.openCopy(row.id)
} else if (cmd === 'delete') {
handleDelete(row)
}
}
async function copyText(text, label = '地址') {
if (!text) {
ElMessage.warning(`${label}为空`)
return
}
try {
await navigator.clipboard.writeText(text)
ElMessage.success(`已复制${label}`)
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
// 删除
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除「${providerLabel(row.provider)}」该上游接口配置吗?`, '提示', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
const res = await deleteAgentApi(row.id)
if (res?.code === 200) {
ElMessage.success('已删除')
reload()
} else {
ElMessage.error(res?.msg || '删除失败')
}
}
onMounted(() => {
// 哨兵进入视口即触发下一页加载;提前 200px 预加载,滚动更顺滑
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
loadMore()
}
},
{ rootMargin: '200px' }
)
reload()
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
</script>
<style lang="less" scoped>
.search-form {
margin-bottom: 12px;
}
.waterfall-wrap {
min-height: 200px;
}
/* CSS 多列实现真实瀑布流:卡片高度不一致时自动错落排布 */
.waterfall {
column-count: 4;
column-gap: 16px;
@media (max-width: 1600px) {
column-count: 3;
}
@media (max-width: 1200px) {
column-count: 2;
}
@media (max-width: 768px) {
column-count: 1;
}
}
.waterfall-item {
break-inside: avoid;
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
margin-bottom: 16px;
}
.api-card {
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.25s ease, transform 0.25s ease;
&:hover {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.disabled {
opacity: 0.65;
filter: grayscale(0.4);
}
}
.card-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px 12px;
border-top: 3px solid var(--el-color-primary);
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
.provider-badge {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 17px;
font-weight: 700;
line-height: 1;
}
.head-text {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-subtitle {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-action-btn {
flex-shrink: 0;
padding: 6px 10px;
font-weight: 600;
.action-arrow {
margin-left: 2px;
font-size: 12px;
}
}
}
.card-section {
padding: 10px 14px;
border-bottom: 1px dashed var(--el-border-color-lighter);
.section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.model-hint {
color: var(--el-text-color-placeholder);
font-size: 11px;
}
.model-count {
margin-left: auto;
color: var(--el-color-primary);
font-weight: 600;
}
.key-actions,
.section-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
:deep(.el-button) {
padding: 0 2px;
height: auto;
}
}
}
.url-text {
font-size: 13px;
color: var(--el-color-primary);
word-break: break-all;
line-height: 1.5;
}
.key-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.key-item {
padding: 8px;
background: var(--el-fill-color-lighter);
border-radius: 4px;
.key-item-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.key-no-remark {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.section-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
:deep(.el-button) {
padding: 0 2px;
height: auto;
}
}
}
.key-text {
font-size: 13px;
font-family: Consolas, Monaco, monospace;
color: var(--el-text-color-regular);
letter-spacing: 0.5px;
word-break: break-all;
line-height: 1.5;
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.model-tag {
max-width: 100%;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&:active {
transform: scale(0.96);
}
}
}
.empty-models {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.remark-text {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.card-foot {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 10px 14px;
background: var(--el-fill-color-lighter);
.foot-time {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.load-sentinel {
min-height: 1px;
}
.loading-block {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 0;
color: var(--el-color-primary);
font-size: 14px;
}
.list-end {
padding: 20px 0;
text-align: center;
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>