业务实现 1:API 注册管理模块
脚手架、布局、通用组件都就位了,现在该上战场了。第一个业务模块------API 注册管理,是整个项目的"列表页范式"样板间。把这一篇吃透,后面两个业务模块(模型汇聚、模型标准发布)都是它的变体。
一、业务理解:API 注册管理是什么
"管网模型工具"是 AI 模型生命周期管理后台。模型要被调用,得先有 API。API 注册管理,就是把后端已经写好的 API 登记到平台里,供模型统一调用。
打个比方:平台是一个"调度中心",API 是"可调用的能力清单"。注册管理就是维护这份清单------谁提供了什么能力、怎么调用、现在能不能用。
核心字段
| 字段 | 说明 | 示例 |
|---|---|---|
| 名称 | API 的展示名 | 管网泄漏检测接口 |
| 协议 | 通信协议 | HTTP / HTTPS / gRPC |
| URL | 接口地址 | https://api.pipe.com/leak-detect |
| 请求方式 | HTTP 方法 | GET / POST / PUT |
| 状态 | 启用 / 禁用 | 启用 |
操作
每个 API 有 5 个操作:新增、编辑、删除、详情、调试。其中"调试"是本项目最有特色的功能------直接在平台里发请求看响应,不用开 Postman。
二、列表页实现:用 AppTable 一路平推
列表页是 API 注册管理的核心。有了第 7 篇封装的 AppTable,整个列表页的 template 只有 20 行:
vue
<!-- views/api-manage/ApiRegistry.vue -->
<template>
<div class="page-card">
<AppBreadcrumb :items="[{ title: 'API 注册管理' }]" />
<!-- 筛选区 -->
<div class="filter-bar">
<el-select v-model="filters.protocol" placeholder="协议" clearable style="width: 140px">
<el-option label="HTTP" value="HTTP" />
<el-option label="HTTPS" value="HTTPS" />
<el-option label="gRPC" value="gRPC" />
</el-select>
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
<el-option label="启用" value="enabled" />
<el-option label="禁用" value="disabled" />
</el-select>
<AppSearchInput v-model="filters.keyword" placeholder="搜索 API 名称" @search="onSearch" />
<el-button type="primary" :icon="Plus" @click="openCreate">新增 API</el-button>
</div>
<!-- 表格 -->
<AppTable
:data="tableData"
:columns="columns"
:loading="loading"
:total="total"
v-model:page="page"
v-model:selection="selectedRows"
@page-change="fetchData"
>
<template #status="{ row }">
<StatusTag :status="row.status" />
</template>
<template #action="{ row }">
<el-button type="primary" link @click="goDetail(row)">详情</el-button>
<el-button type="primary" link @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link @click="del(row)">删除</el-button>
<el-button type="primary" link @click="goDebug(row)">调试</el-button>
</template>
</AppTable>
</div>
</template>
2.1 columns 配置
columns 数组告诉 AppTable 表格有哪些列、每列怎么渲染:
js
const columns = [
{ prop: 'name', label: 'API 名称', width: 200, align: 'left' },
{ prop: 'protocol', label: '协议', width: 100, align: 'center' },
{ prop: 'url', label: '接口地址', minWidth: 280, align: 'left' },
{ prop: 'method', label: '请求方式', width: 100, align: 'center' },
{ prop: 'status', label: '状态', width: 100, align: 'center', slot: 'status' },
{ prop: 'createTime', label: '创建时间', width: 180, align: 'center' }
]
注意 status 列配了 slot: 'status'------意思是"这一列不用文本渲染,用名为 status 的 slot"。在 template 里,我们写了 <template #status="{ row }"> 来渲染 StatusTag 组件。
2.2 数据获取
js
import { ref, reactive, onMounted } from 'vue'
import { getApiList } from '@/api/apiManage'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = 30
const filters = reactive({
protocol: '',
status: '',
keyword: ''
})
async function fetchData() {
loading.value = true
try {
const res = await getApiList({
page: page.value,
pageSize,
...filters
})
tableData.value = res.data.list
total.value = res.data.total
} finally {
loading.value = false
}
}
onMounted(fetchData)
fetchData 是列表页的"心脏"------筛选条件变了、翻页了、搜索了,都调它重新拉数据。它只关心"拿数据",不关心"数据怎么展示"------展示是 AppTable 的事。
三、筛选 + 搜索逻辑:多条件组合
3.1 多条件组合筛选
协议下拉 + 状态下拉 + 搜索框,三个条件可以任意组合。实现方式很简单------把它们都放进 filters 对象,调 fetchData 时一起传给后端(Mock):
js
function onFilterChange() {
page.value = 1 // 筛选后回到第一页
fetchData()
}
注意 page.value = 1------筛选后必须重置页码,否则可能出现"筛选后第 3 页没数据"的尴尬。
3.2 防抖搜索
搜索框如果每敲一个字就发一次请求,用户快速输入"管网泄漏"四个字会发 4 次请求。用 500ms 防抖,等用户停下来再发:
js
import { debounce } from '@/utils/debounce'
const onSearch = debounce(() => {
page.value = 1
fetchData()
}, 500)
debounce 是一个 10 行的小工具函数:
js
// utils/debounce.js
export function debounce(fn, delay = 300) {
let timer = null
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
3.3 筛选状态同步到 URL query
这是一个"加分项"------把筛选条件写进 URL,刷新页面后筛选条件还在。实现用 useRoute + useRouter:
js
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// 初始化时从 URL 读取筛选条件
onMounted(() => {
const { protocol, status, keyword, page: p } = route.query
if (protocol) filters.protocol = protocol
if (status) filters.status = status
if (keyword) filters.keyword = keyword
if (p) page.value = Number(p)
fetchData()
})
// 筛选变化时写回 URL
watch(filters, () => {
router.replace({
query: {
...filters,
page: page.value
}
})
}, { deep: true })
原型阶段不做这个也完全 OK------但做了之后演示效果会好很多(客户刷新页面数据不丢)。
四、复选框 + 批量操作:跨页选择是难点
4.1 选中行的获取
AppTable 已经把 selection-change 事件封装好了,父组件通过 v-model:selection 拿到选中行:
js
const selectedRows = ref([])
// 批量删除
function batchDelete() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要删除的 API')
return
}
ElMessageBox.confirm(
`确认删除选中的 ${selectedRows.value.length} 个 API?`,
'批量删除',
{ type: 'warning' }
).then(() => {
// 调用删除接口
ElMessage.success('删除成功')
fetchData()
})
}
4.2 跨页保持选择
第 7 篇提到,AppTable 的复选框列设了 :reserve-selection="true"。这保证了用户在第 1 页选了 3 条、翻到第 2 页选了 2 条,selectedRows 里是 5 条而不是 2 条。
但有个前提------el-table 必须知道"哪行是同一行",靠 row-key 属性。AppTable 默认 rowKey: 'id',所以每条数据必须有唯一的 id 字段。Mock 数据里 @id 生成的 id 正好满足。
4.3 全选/反选/单选
这些 Element Plus 表格自带,不需要额外写代码。AppTable 只是把 selection-change 事件透传出来。
五、行操作:四个按钮各司其职
行操作列的四个按钮,对应四种交互:
| 操作 | 行为 | 实现 |
|---|---|---|
| 详情 | 跳转到详情页 | router.push('/api-manage/detail/' + row.id) |
| 编辑 | 打开编辑弹窗 | openEdit(row) |
| 删除 | 带确认后删除 | ElMessageBox.confirm + 删除接口 |
| 调试 | 跳转到运行调试 tab | router.push('/api-manage/detail/' + row.id + '?tab=debug') |
js
import { useRouter } from 'vue-router'
const router = useRouter()
function goDetail(row) {
router.push(`/api-manage/detail/${row.id}`)
}
function goDebug(row) {
router.push(`/api-manage/detail/${row.id}?tab=debug`)
}
function del(row) {
ElMessageBox.confirm(`确认删除「${row.name}」?`, '删除确认', {
type: 'warning'
}).then(async () => {
await deleteApi(row.id)
ElMessage.success('删除成功')
fetchData()
}).catch(() => {})
}
删除操作必须带 ElMessageBox.confirm 二次确认------这是后台系统的铁律。误删一条生产数据,后果可能很严重。
六、详情页:双 Tab 设计
详情页是 API 注册管理最有特色的部分------它有两个 Tab:
- 基本信息 Tab:只读展示 API 的所有字段
- 运行调试 Tab:JSON 编辑器 + Send 按钮 + 响应展示
6.1 路由与页面结构
js
// router 配置
{
path: 'api-manage/detail/:id',
name: 'ApiDetail',
component: () => import('@/views/api-manage/ApiDetail.vue'),
meta: { title: 'API 详情', module: '/api-manage/registry', hidden: true }
}
vue
<!-- views/api-manage/ApiDetail.vue -->
<template>
<div class="page-card">
<el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
<AppBreadcrumb :items="[{ title: 'API 注册管理', path: '/api-manage/registry' }, { title: 'API 详情' }]" />
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="基本信息" name="info" />
<el-tab-pane label="运行调试" name="debug" />
</el-tabs>
<div v-show="activeTab === 'info'">
<!-- 基本信息只读展示 -->
<el-descriptions :column="2" border>
<el-descriptions-item label="API 名称">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="协议">{{ detail.protocol }}</el-descriptions-item>
<el-descriptions-item label="接口地址">{{ detail.url }}</el-descriptions-item>
<el-descriptions-item label="请求方式">{{ detail.method }}</el-descriptions-item>
<el-descriptions-item label="状态">
<StatusTag :status="detail.status" />
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detail.description }}</el-descriptions-item>
</el-descriptions>
</div>
<div v-show="activeTab === 'debug'">
<!-- 运行调试 -->
<div class="debug-area">
<div class="debug-request">
<div class="debug-toolbar">
<el-select v-model="debugMethod" style="width: 120px">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
</el-select>
<el-input v-model="debugUrl" readonly style="flex: 1" />
<el-button type="primary" :loading="debugging" @click="sendRequest">Send</el-button>
</div>
<el-input
v-model="debugBody"
type="textarea"
:rows="10"
placeholder="请求体(JSON)"
/>
</div>
<div class="debug-response">
<div class="debug-response-title">响应</div>
<pre>{{ debugResult }}</pre>
</div>
</div>
</div>
</div>
</template>
6.2 运行调试的实现
调试 Tab 的核心是一个"假请求"------因为后端还没好,我们用 Mock 模拟响应:
js
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getApiDetail } from '@/api/apiManage'
const route = useRoute()
const detail = ref({})
const activeTab = ref('info')
const debugMethod = ref('GET')
const debugUrl = ref('')
const debugBody = ref('{\n "param": "value"\n}')
const debugResult = ref('')
const debugging = ref(false)
onMounted(async () => {
const res = await getApiDetail(route.params.id)
detail.value = res.data
debugUrl.value = res.data.url
// 从 URL query 读取初始 tab(?tab=debug)
if (route.query.tab) activeTab.value = route.query.tab
})
async function sendRequest() {
debugging.value = true
try {
// 模拟请求延迟
await new Promise(r => setTimeout(r, 800))
debugResult.value = JSON.stringify({
code: 200,
message: 'success',
{
leakProbability: 0.87,
location: [120.15, 30.28],
timestamp: new Date().toISOString()
}
}, null, 2)
} finally {
debugging.value = false
}
}
调试 Tab 让原型"活"了起来------客户在演示现场点一下 Send,800ms 后看到一段真实的 JSON 响应,比"这里应该会返回数据"有说服力得多。
6.3 返回按钮 + 面包屑
返回按钮用 router.back() 或 router.push('/api-manage/registry')。面包屑的第二项带了 path,点击能跳回列表页。
七、注册弹窗:新增/编辑复用同一个
新增和编辑的表单字段完全一样------区别只是"编辑时表单预填了数据"。所以用一个弹窗组件,通过 mode 区分:
vue
<!-- 在 ApiRegistry.vue 中 -->
<AppDialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增 API' : '编辑 API'"
:loading="submitting"
@confirm="handleSubmit"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="API 名称" prop="name">
<el-input v-model="form.name" placeholder="请输入 API 名称" />
</el-form-item>
<el-form-item label="协议" prop="protocol">
<el-select v-model="form.protocol" style="width: 100%">
<el-option label="HTTP" value="HTTP" />
<el-option label="HTTPS" value="HTTPS" />
<el-option label="gRPC" value="gRPC" />
</el-select>
</el-form-item>
<el-form-item label="接口地址" prop="url">
<el-input v-model="form.url" placeholder="https://..." />
</el-form-item>
<el-form-item label="请求方式" prop="method">
<el-select v-model="form.method" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
</AppDialog>
js
const dialogVisible = ref(false)
const dialogMode = ref('create')
const formRef = ref()
const submitting = ref(false)
const form = reactive({
id: null,
name: '',
protocol: 'HTTP',
url: '',
method: 'GET',
description: ''
})
const rules = {
name: [{ required: true, message: '请输入 API 名称', trigger: 'blur' }],
protocol: [{ required: true, message: '请选择协议', trigger: 'change' }],
url: [{ required: true, message: '请输入接口地址', trigger: 'blur' }],
method: [{ required: true, message: '请选择请求方式', trigger: 'change' }]
}
function openCreate() {
dialogMode.value = 'create'
resetForm()
dialogVisible.value = true
}
function openEdit(row) {
dialogMode.value = 'edit'
Object.assign(form, row)
dialogVisible.value = true
}
function resetForm() {
form.id = null
form.name = ''
form.protocol = 'HTTP'
form.url = ''
form.method = 'GET'
form.description = ''
}
async function handleSubmit() {
await formRef.value.validate()
submitting.value = true
try {
if (dialogMode.value === 'create') {
await createApi(form)
ElMessage.success('新增成功')
} else {
await updateApi(form)
ElMessage.success('编辑成功')
}
dialogVisible.value = false
fetchData()
} finally {
submitting.value = false
}
}
7.1 必填校验
rules 对象定义了每个字段的校验规则。formRef.value.validate() 在提交前触发校验,不通过则不会发请求。这是 Element Plus 表单的标准用法,但值得强调------所有用户输入都必须校验,原型也不例外。客户演示时填了个空值点提交,如果没校验直接报错,比"提示请填写"尴尬得多。
7.2 提交 loading + 成功提示
提交时 submitting = true,AppDialog 的确认按钮自动进入 loading 态(第 7 篇封装的 :loading prop)。成功后 ElMessage.success 弹提示,关闭弹窗,重新拉列表。
八、Mock 数据:30 条真实感数据
API 注册管理需要 30 条 Mock 数据。第 11 篇会详细讲 Mock 策略,这里先给一个预览------数据要"有血有肉":
| 名称 | 协议 | URL | 状态 |
|---|---|---|---|
| 管网泄漏检测接口 | HTTPS | https://api.pipe.com/leak-detect |
启用 |
| 压力监测服务 | HTTP | http://10.0.1.23:8080/pressure |
启用 |
| 流量分析 API | gRPC | grpc://pipe-grpc/flow-analysis |
禁用 |
| 水质预测模型 | HTTPS | https://api.pipe.com/water-quality |
启用 |
| ... | ... | ... | ... |
每条数据的名称、URL、状态都有差异,时间字段分布在最近 3 个月------这样列表页看起来像真实生产数据,而不是"测试数据 1/2/3"。
九、小结:列表页范式
API 注册管理模块做完,我们沉淀出一个"列表页范式":
筛选区(下拉 + 搜索)
↓
表格(复选框 + 序号 + 字段列 + 操作列)
↓
分页器(30 条/页)
↓
行操作(详情/编辑/删除/调试)
↓
弹窗(新增/编辑复用)
↓
详情页(双 Tab:基本信息 + 运行调试)
这个范式不是 API 注册管理独有的------模型汇聚、模型标准发布都是它的变体。区别只在于"表格有哪些列""弹窗有哪些字段""详情页有几个 Tab"。
范式 = 不变的结构 + 可变的内容。 把结构固化成通用组件(AppTable、AppDialog),把内容留给每个业务页面填------这就是企业级后台开发效率的秘诀。
下一篇预告:列表页范式跑通了,下一个模块"模型汇聚"是项目最复杂的------详情页有 3 个 Tab,Tab 切换还要同步 URL。怎么让多 Tab 详情页不丢数据?