739 lines
19 KiB
Vue
739 lines
19 KiB
Vue
<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>
|