Files
yunzerwebsiteallinone/platform/src/views/tools/agentapimanagement/index.vue
T

739 lines
19 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="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>