feat(admin): paginate tools list endpoint and console UI

- GET /tools returns ToolPageView{total,page,pageSize,records}, pageSize=0 keeps full-list mode
- App.vue adds pager below tools table; scope/audit/category pickers use allTools
- add backend pagination tests and front-end pager contract test
This commit is contained in:
my_local_kimi_dev2
2026-09-16 09:52:19 +08:00
parent 6e8cf60fd3
commit a575736c81
4 changed files with 80 additions and 18 deletions
+28 -10
View File
@@ -24,8 +24,10 @@ const tokens = ref([])
const openClients = ref([])
const scopes = ref([])
const tools = ref([])
const allTools = ref([])
const audits = ref([])
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
const toolPage = reactive({ page: 1, pageSize: 20, total: 0 })
const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
const tokenDialog = ref(false)
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
@@ -40,8 +42,8 @@ const activeScope = ref(null)
const selectedToolIds = ref([])
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
const categories = computed(() => [...new Set(tools.value.map(tool => tool.category))])
const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
const categories = computed(() => [...new Set(allTools.value.map(tool => tool.category))])
const groupedTools = computed(() => allTools.value.reduce((groups, tool) => {
const category = tool.category || '其他'
;(groups[category] ||= []).push(tool)
return groups
@@ -96,9 +98,9 @@ async function switchMenu(id) {
current.value = id
if (id === 'tokens') await loadUsers()
if (id === 'clients') await loadOpenClients()
if (id === 'tools') await loadTools()
if (id === 'tools') await Promise.all([loadTools(), loadAllTools()])
if (id === 'scopes') await loadScopes()
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
if (id === 'audit') await Promise.all([loadAudit(), loadAllTools()])
}
async function loadUsers(resetPage = false) {
@@ -214,8 +216,21 @@ async function rotateClient(client) {
}).catch(() => {})
}
async function loadTools() {
tools.value = await api(`/tools${qs(toolFilter)}`)
async function loadTools(resetPage = false) {
if (resetPage) toolPage.page = 1
const data = await api(`/tools${qs({ ...toolFilter, page: toolPage.page, pageSize: toolPage.pageSize })}`)
tools.value = data.records
toolPage.total = data.total
}
async function loadAllTools() {
const data = await api('/tools?pageSize=0')
allTools.value = data.records
}
async function changeToolPage(page) {
toolPage.page = page
await run(loadTools)
}
async function setToolStatus(tool) {
@@ -261,7 +276,7 @@ async function deleteScope(scope) {
async function openScopeTools(scope) {
activeScope.value = scope
;[selectedToolIds.value, tools.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), api('/tools')])
;[selectedToolIds.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), loadAllTools()])
scopeToolsDialog.value = true
}
@@ -411,8 +426,8 @@ onMounted(async () => {
<div class="toolbar">
<el-select v-model="toolFilter.category" clearable placeholder="全部分类" aria-label="工具分类" data-testid="admin-tool-category"><el-option v-for="item in categories" :key="item" :label="item" :value="item" /></el-select>
<el-select v-model="toolFilter.kind" clearable placeholder="全部类型" aria-label="工具类型" data-testid="admin-tool-kind"><el-option label="读" value="read" /><el-option label="写" value="write" /></el-select>
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(loadTools)" />
<el-button type="primary" data-testid="admin-tool-search" @click="run(loadTools)">查询</el-button>
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(() => loadTools(true))" />
<el-button type="primary" data-testid="admin-tool-search" @click="run(() => loadTools(true))">查询</el-button>
</div>
</article>
<article class="card">
@@ -427,6 +442,9 @@ onMounted(async () => {
<el-table-column label="同步时间" min-width="145"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link :type="row.status === 1 ? 'danger' : 'primary'" :data-testid="`admin-tool-status-${row.id}`" @click="setToolStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button></template></el-table-column>
</el-table>
<el-pagination class="user-pager" layout="prev, pager, next, total" :total="toolPage.total"
:page-size="toolPage.pageSize" :current-page="toolPage.page" small background
data-testid="admin-tool-pager" @current-change="changeToolPage" />
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
</article>
</template>
@@ -456,7 +474,7 @@ onMounted(async () => {
<div class="toolbar audit-toolbar">
<el-input v-model="auditFilter.person" clearable placeholder="姓名 / 手机号" aria-label="人员" data-testid="admin-audit-person" />
<el-input v-model="auditFilter.tokenPrefix" clearable placeholder="Token 前缀" aria-label="Token 前缀" data-testid="admin-audit-token" />
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in tools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in allTools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
<el-select v-model="auditFilter.result" clearable placeholder="全部结果" aria-label="调用结果" data-testid="admin-audit-result"><el-option label="成功" value="success" /><el-option label="拒绝" value="denied" /><el-option label="错误" value="error" /></el-select>
<el-select v-model="auditFilter.client" clearable placeholder="全部客户端" aria-label="客户端" data-testid="admin-audit-client"><el-option label="Kimi" value="mcp-kimi" /><el-option label="Claude" value="mcp-claude" /><el-option label="CLI" value="cli" /><el-option label="未知" value="unknown" /></el-select>
<el-date-picker v-model="auditFilter.from" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" aria-label="开始日期" data-testid="admin-audit-from" />
@@ -19,6 +19,7 @@ test('prototype interactions expose stable test ids', async () => {
'admin-table-clients',
'admin-issued-client-key',
'admin-table-tools',
'admin-tool-pager',
'admin-btn-new-scope',
'admin-table-scopes',
'admin-table-audit',
@@ -162,16 +162,24 @@ public class AdminConsoleController {
return tokenService.rotate(id, adminId(session));
}
/** pageSize=0 表示不分页返回全量,供作用域配置、审计筛选等需要完整工具列表的场景使用。 */
@GetMapping("/tools")
public List<ToolView> tools(@RequestParam(defaultValue = "") String category,
@RequestParam(defaultValue = "") String kind,
@RequestParam(defaultValue = "") String keyword) {
public ToolPageView tools(@RequestParam(defaultValue = "") String category,
@RequestParam(defaultValue = "") String kind,
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
int pageNo = Math.max(page, 1);
int size = pageSize == 0 ? 0 : pageSize < 1 ? 20 : Math.min(pageSize, 100);
LambdaQueryWrapper<Tool> query = new LambdaQueryWrapper<Tool>().eq(Tool::getSoftDelete, 0);
if (!category.isBlank()) query.eq(Tool::getCategory, category);
if (!kind.isBlank()) query.eq(Tool::getKind, kind);
if (!keyword.isBlank()) query.and(w -> w.like(Tool::getName, keyword.trim())
.or().like(Tool::getDescription, keyword.trim()));
List<Tool> tools = toolMapper.selectList(query.orderByAsc(Tool::getCategory, Tool::getName));
long total = toolMapper.selectCount(query);
LambdaQueryWrapper<Tool> paged = query.orderByAsc(Tool::getCategory, Tool::getName);
if (size > 0) paged.last("LIMIT " + (long) (pageNo - 1) * size + "," + size);
List<Tool> tools = toolMapper.selectList(paged);
Map<Long, AgentScope> scopes = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
.eq(AgentScope::getSoftDelete, 0)).stream()
.collect(Collectors.toMap(AgentScope::getId, Function.identity()));
@@ -180,9 +188,10 @@ public class AdminConsoleController {
.filter(r -> scopes.containsKey(r.getScopeId()))
.collect(Collectors.groupingBy(ScopeTool::getToolId,
Collectors.mapping(r -> scopes.get(r.getScopeId()).getName(), Collectors.toList())));
return tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(), tool.getCategory(),
tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
List<ToolView> records = tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(),
tool.getCategory(), tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
scopeNames.getOrDefault(tool.getId(), List.of()))).toList();
return new ToolPageView(total, size > 0 ? pageNo : 1, size, records);
}
@PatchMapping("/tools/{id}/status")
@@ -397,6 +406,7 @@ public class AdminConsoleController {
public record StatusRequest(byte status) {}
public record ToolView(Long id, String name, String category, String description, String kind,
Byte status, Date modifyTime, List<String> scopeNames) {}
public record ToolPageView(long total, int page, int pageSize, List<ToolView> records) {}
public record ScopeRequest(String name, String code, String memo) {}
public record ScopeView(Long id, String name, String code, String memo, Byte status,
Date modifyTime, long toolCount) {}
@@ -46,11 +46,15 @@ class AdminConsoleControllerTest {
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
me.iiv.agent.entity.m0.User.class);
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
me.iiv.agent.entity.svc.Tool.class);
controller = new AdminConsoleController(userMapper, authorityMapper, agentMapper, tokenMapper,
scopeMapper, toolMapper, scopeToolMapper, auditMapper, tokenScopeMapper,
permissionService, tokenService);
when(userMapper.selectCount(any())).thenReturn(0L);
when(userMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(userMapper.selectCount(any())).thenReturn(0L);
org.mockito.Mockito.lenient().when(userMapper.selectList(any())).thenReturn(List.of());
}
@Test
@@ -78,4 +82,33 @@ class AdminConsoleControllerTest {
assertTrue(sql.contains("enterprise_type"), "用户列表必须过滤物业账号: " + sql);
assertTrue(sql.contains("IS NULL"), "历史 NULL 数据需保留: " + sql);
}
@Test
void toolsClampsPageSizeToHundred() {
when(toolMapper.selectCount(any())).thenReturn(30L);
when(toolMapper.selectList(any())).thenReturn(List.of());
when(scopeMapper.selectList(any())).thenReturn(List.of());
when(scopeToolMapper.selectList(any())).thenReturn(List.of());
AdminConsoleController.ToolPageView view = controller.tools("", "", "", 1, 500);
assertEquals(100, view.pageSize());
assertEquals(1, view.page());
assertEquals(30, view.total());
}
@Test
void toolsPageSizeZeroReturnsAllWithoutLimit() {
when(toolMapper.selectCount(any())).thenReturn(30L);
when(toolMapper.selectList(any())).thenReturn(List.of());
when(scopeMapper.selectList(any())).thenReturn(List.of());
when(scopeToolMapper.selectList(any())).thenReturn(List.of());
AdminConsoleController.ToolPageView view = controller.tools("", "", "", 3, 0);
assertEquals(0, view.pageSize());
assertEquals(1, view.page());
assertEquals(30, view.total());
org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>>
captor = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
org.mockito.Mockito.verify(toolMapper).selectList(captor.capture());
assertTrue(captor.getValue().getSqlSegment() == null || !captor.getValue().getSqlSegment().contains("LIMIT"),
"全量模式不应带 LIMIT: " + captor.getValue().getSqlSegment());
}
}