增加智能体数据留存功能

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
@@ -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>