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:
@@ -24,8 +24,10 @@ const tokens = ref([])
|
|||||||
const openClients = ref([])
|
const openClients = ref([])
|
||||||
const scopes = ref([])
|
const scopes = ref([])
|
||||||
const tools = ref([])
|
const tools = ref([])
|
||||||
|
const allTools = ref([])
|
||||||
const audits = ref([])
|
const audits = ref([])
|
||||||
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
|
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 auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
|
||||||
const tokenDialog = ref(false)
|
const tokenDialog = ref(false)
|
||||||
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
|
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
|
||||||
@@ -40,8 +42,8 @@ const activeScope = ref(null)
|
|||||||
const selectedToolIds = ref([])
|
const selectedToolIds = ref([])
|
||||||
|
|
||||||
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
|
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
|
||||||
const categories = computed(() => [...new Set(tools.value.map(tool => tool.category))])
|
const categories = computed(() => [...new Set(allTools.value.map(tool => tool.category))])
|
||||||
const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
|
const groupedTools = computed(() => allTools.value.reduce((groups, tool) => {
|
||||||
const category = tool.category || '其他'
|
const category = tool.category || '其他'
|
||||||
;(groups[category] ||= []).push(tool)
|
;(groups[category] ||= []).push(tool)
|
||||||
return groups
|
return groups
|
||||||
@@ -96,9 +98,9 @@ async function switchMenu(id) {
|
|||||||
current.value = id
|
current.value = id
|
||||||
if (id === 'tokens') await loadUsers()
|
if (id === 'tokens') await loadUsers()
|
||||||
if (id === 'clients') await loadOpenClients()
|
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 === '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) {
|
async function loadUsers(resetPage = false) {
|
||||||
@@ -214,8 +216,21 @@ async function rotateClient(client) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTools() {
|
async function loadTools(resetPage = false) {
|
||||||
tools.value = await api(`/tools${qs(toolFilter)}`)
|
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) {
|
async function setToolStatus(tool) {
|
||||||
@@ -261,7 +276,7 @@ async function deleteScope(scope) {
|
|||||||
|
|
||||||
async function openScopeTools(scope) {
|
async function openScopeTools(scope) {
|
||||||
activeScope.value = 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
|
scopeToolsDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,8 +426,8 @@ onMounted(async () => {
|
|||||||
<div class="toolbar">
|
<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.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-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-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)">查询</el-button>
|
<el-button type="primary" data-testid="admin-tool-search" @click="run(() => loadTools(true))">查询</el-button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<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="同步时间" 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-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-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>
|
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
@@ -456,7 +474,7 @@ onMounted(async () => {
|
|||||||
<div class="toolbar audit-toolbar">
|
<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.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-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.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-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" />
|
<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-table-clients',
|
||||||
'admin-issued-client-key',
|
'admin-issued-client-key',
|
||||||
'admin-table-tools',
|
'admin-table-tools',
|
||||||
|
'admin-tool-pager',
|
||||||
'admin-btn-new-scope',
|
'admin-btn-new-scope',
|
||||||
'admin-table-scopes',
|
'admin-table-scopes',
|
||||||
'admin-table-audit',
|
'admin-table-audit',
|
||||||
|
|||||||
@@ -162,16 +162,24 @@ public class AdminConsoleController {
|
|||||||
return tokenService.rotate(id, adminId(session));
|
return tokenService.rotate(id, adminId(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** pageSize=0 表示不分页返回全量,供作用域配置、审计筛选等需要完整工具列表的场景使用。 */
|
||||||
@GetMapping("/tools")
|
@GetMapping("/tools")
|
||||||
public List<ToolView> tools(@RequestParam(defaultValue = "") String category,
|
public ToolPageView tools(@RequestParam(defaultValue = "") String category,
|
||||||
@RequestParam(defaultValue = "") String kind,
|
@RequestParam(defaultValue = "") String kind,
|
||||||
@RequestParam(defaultValue = "") String keyword) {
|
@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);
|
LambdaQueryWrapper<Tool> query = new LambdaQueryWrapper<Tool>().eq(Tool::getSoftDelete, 0);
|
||||||
if (!category.isBlank()) query.eq(Tool::getCategory, category);
|
if (!category.isBlank()) query.eq(Tool::getCategory, category);
|
||||||
if (!kind.isBlank()) query.eq(Tool::getKind, kind);
|
if (!kind.isBlank()) query.eq(Tool::getKind, kind);
|
||||||
if (!keyword.isBlank()) query.and(w -> w.like(Tool::getName, keyword.trim())
|
if (!keyword.isBlank()) query.and(w -> w.like(Tool::getName, keyword.trim())
|
||||||
.or().like(Tool::getDescription, 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>()
|
Map<Long, AgentScope> scopes = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
|
||||||
.eq(AgentScope::getSoftDelete, 0)).stream()
|
.eq(AgentScope::getSoftDelete, 0)).stream()
|
||||||
.collect(Collectors.toMap(AgentScope::getId, Function.identity()));
|
.collect(Collectors.toMap(AgentScope::getId, Function.identity()));
|
||||||
@@ -180,9 +188,10 @@ public class AdminConsoleController {
|
|||||||
.filter(r -> scopes.containsKey(r.getScopeId()))
|
.filter(r -> scopes.containsKey(r.getScopeId()))
|
||||||
.collect(Collectors.groupingBy(ScopeTool::getToolId,
|
.collect(Collectors.groupingBy(ScopeTool::getToolId,
|
||||||
Collectors.mapping(r -> scopes.get(r.getScopeId()).getName(), Collectors.toList())));
|
Collectors.mapping(r -> scopes.get(r.getScopeId()).getName(), Collectors.toList())));
|
||||||
return tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(), tool.getCategory(),
|
List<ToolView> records = tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(),
|
||||||
tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
|
tool.getCategory(), tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
|
||||||
scopeNames.getOrDefault(tool.getId(), List.of()))).toList();
|
scopeNames.getOrDefault(tool.getId(), List.of()))).toList();
|
||||||
|
return new ToolPageView(total, size > 0 ? pageNo : 1, size, records);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/tools/{id}/status")
|
@PatchMapping("/tools/{id}/status")
|
||||||
@@ -397,6 +406,7 @@ public class AdminConsoleController {
|
|||||||
public record StatusRequest(byte status) {}
|
public record StatusRequest(byte status) {}
|
||||||
public record ToolView(Long id, String name, String category, String description, String kind,
|
public record ToolView(Long id, String name, String category, String description, String kind,
|
||||||
Byte status, Date modifyTime, List<String> scopeNames) {}
|
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 ScopeRequest(String name, String code, String memo) {}
|
||||||
public record ScopeView(Long id, String name, String code, String memo, Byte status,
|
public record ScopeView(Long id, String name, String code, String memo, Byte status,
|
||||||
Date modifyTime, long toolCount) {}
|
Date modifyTime, long toolCount) {}
|
||||||
|
|||||||
+35
-2
@@ -46,11 +46,15 @@ class AdminConsoleControllerTest {
|
|||||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
|
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
|
||||||
me.iiv.agent.entity.m0.User.class);
|
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,
|
controller = new AdminConsoleController(userMapper, authorityMapper, agentMapper, tokenMapper,
|
||||||
scopeMapper, toolMapper, scopeToolMapper, auditMapper, tokenScopeMapper,
|
scopeMapper, toolMapper, scopeToolMapper, auditMapper, tokenScopeMapper,
|
||||||
permissionService, tokenService);
|
permissionService, tokenService);
|
||||||
when(userMapper.selectCount(any())).thenReturn(0L);
|
org.mockito.Mockito.lenient().when(userMapper.selectCount(any())).thenReturn(0L);
|
||||||
when(userMapper.selectList(any())).thenReturn(List.of());
|
org.mockito.Mockito.lenient().when(userMapper.selectList(any())).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -78,4 +82,33 @@ class AdminConsoleControllerTest {
|
|||||||
assertTrue(sql.contains("enterprise_type"), "用户列表必须过滤物业账号: " + sql);
|
assertTrue(sql.contains("enterprise_type"), "用户列表必须过滤物业账号: " + sql);
|
||||||
assertTrue(sql.contains("IS NULL"), "历史 NULL 数据需保留: " + 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user