From 65cf53779aa154fe8a9b6b6ccfd681cc39673880 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 27 May 2026 15:08:41 +0800 Subject: [PATCH] refactor(approval-grants-ui): paginated list, Element Plus icons, shorter sidebar label --- .../controller/ApprovalGrantController.java | 15 +- .../ApprovalGrantControllerTest.java | 8 +- mateclaw-ui/src/api/index.ts | 10 +- mateclaw-ui/src/i18n/locales/en-US.ts | 1 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 1 + mateclaw-ui/src/types/index.ts | 15 + .../Security/AutoApproveGrants/index.vue | 487 +++++++++++------- mateclaw-ui/src/views/Security/Layout.vue | 2 +- 8 files changed, 336 insertions(+), 203 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java index 36a99acd..b852a9b5 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java @@ -1,6 +1,8 @@ package vip.mate.approval.grant.controller; +import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -96,14 +98,16 @@ public class ApprovalGrantController { // ─── List ─────────────────────────────────────────────────────────── - @Operation(summary = "列出当前 workspace 的自动批准策略") + @Operation(summary = "列出当前 workspace 的自动批准策略(分页)") @GetMapping("/grants") @RequireWorkspaceRole("member") - public R> list( + public R> list( @RequestParam(required = false) String scopeType, @RequestParam(required = false) String toolName, @RequestParam(required = false) Integer revoked, @RequestParam(required = false, defaultValue = "false") boolean mine, + @RequestParam(required = false, defaultValue = "1") long page, + @RequestParam(required = false, defaultValue = "20") long size, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, Authentication auth) { Long actorId = resolveUserId(auth); @@ -114,6 +118,10 @@ public class ApprovalGrantController { workspaceService.requirePermission(ws, actorId, "admin"); } + // Bound page size so a malformed client can't blow up the UI / mapper. + long boundedSize = Math.min(Math.max(size, 1), 200); + long boundedPage = Math.max(page, 1); + var wrapper = Wrappers.lambdaQuery() .eq(ApprovalGrant::getWorkspaceId, ws) .eq(ApprovalGrant::getDeleted, 0) @@ -130,7 +138,8 @@ public class ApprovalGrantController { if (mine) { wrapper.eq(ApprovalGrant::getGrantedBy, actorId); } - return R.ok(grantMapper.selectList(wrapper)); + Page pageObj = new Page<>(boundedPage, boundedSize); + return R.ok(grantMapper.selectPage(pageObj, wrapper)); } // ─── Active summary (chip "(N)") ──────────────────────────────────── diff --git a/mateclaw-server/src/test/java/vip/mate/approval/grant/controller/ApprovalGrantControllerTest.java b/mateclaw-server/src/test/java/vip/mate/approval/grant/controller/ApprovalGrantControllerTest.java index 36520f53..1085efab 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/grant/controller/ApprovalGrantControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/grant/controller/ApprovalGrantControllerTest.java @@ -202,9 +202,11 @@ class ApprovalGrantControllerTest { @Test void list_mine_does_not_require_admin() { - when(grantMapper.selectList(any())).thenReturn(List.of()); + // selectPage returns a Page object; the test only cares about the auth + // path, so the mapper stub just needs to not NPE. + when(grantMapper.selectPage(any(), any())).thenReturn(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>()); - controller.list(null, null, null, /*mine*/ true, WORKSPACE_ID, memberAuth); + controller.list(null, null, null, /*mine*/ true, 1L, 20L, WORKSPACE_ID, memberAuth); verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString()); } @@ -215,7 +217,7 @@ class ApprovalGrantControllerTest { .when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin"); assertThatThrownBy(() -> - controller.list(null, null, null, /*mine*/ false, WORKSPACE_ID, memberAuth)) + controller.list(null, null, null, /*mine*/ false, 1L, 20L, WORKSPACE_ID, memberAuth)) .isInstanceOf(MateClawException.class); } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 7fdcfd9d..d5a73987 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -2,6 +2,7 @@ import axios from 'axios' import { handleAuthFailure, updateTokenFromHeader } from '@/utils/auth' import type { ApprovalGrant, + ApprovalGrantPage, ActiveGrantsSummary, CreateGrantPayload, ResolutionLog, @@ -1291,13 +1292,18 @@ export const goalApi = { * keep them as strings end-to-end and never run them through Number(). */ export const approvalApi = { - /** List grants visible in the current workspace. mine=true skips the admin gate. */ + /** + * List grants visible in the current workspace, paged. mine=true skips the + * admin gate. Page is 1-based; size is bounded server-side to [1, 200]. + */ listGrants: (params?: { scopeType?: GrantScope toolName?: string revoked?: 0 | 1 mine?: boolean - }) => http.get('/approval/grants', { params }), + page?: number + size?: number + }) => http.get('/approval/grants', { params }), /** Active-grant summary used by the global chip + ChatInput pill counters. */ activeSummary: () => diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index c0ae094f..a3aee32d 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -3732,6 +3732,7 @@ export default { manage: 'Manage auto-approve rules...', }, chipLabel: 'Auto-approve active ({count})', + menu: 'Auto-approve', title: 'Auto-approve rules', desc: 'Rules let specific tool calls skip manual approval. Safety-floor patterns (rm -rf /, pipe-to-shell, etc.) always apply, and CRITICAL severity always falls back to human approval.', scope: { diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 91b55e4a..6adc5177 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -3824,6 +3824,7 @@ export default { manage: '管理自动批准策略...', }, chipLabel: '自动批准已启用 ({count})', + menu: '自动批准', title: '自动批准策略', desc: '策略让特定工具调用跳过人审。地板规则(如 rm -rf /、pipe-to-shell)始终生效,CRITICAL 严重度永远人审。', scope: { diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index b5f3c513..c41b5286 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -1056,6 +1056,21 @@ export interface ActiveGrantsSummary { hasWorkspaceWide: boolean } +/** + * Paged response shape from /approval/grants. Mirrors the MyBatis Plus + * {@code IPage} JSON layout already used by skills and other paged endpoints in + * mateclaw. {@code total/size/current/pages} arrive as JSON strings because the + * global Long→String serializer catches them; the consumer coerces via + * {@code Number(...)} at the use site so the el-pagination component gets numbers. + */ +export interface ApprovalGrantPage { + records: ApprovalGrant[] + total: number | string + size: number | string + current: number | string + pages: number | string +} + /** * Approval-layer final decision row. workspaceId can be null for HARD_BLOCK * events that fired before workspace resolution. diff --git a/mateclaw-ui/src/views/Security/AutoApproveGrants/index.vue b/mateclaw-ui/src/views/Security/AutoApproveGrants/index.vue index c7dbd212..b2e160c5 100644 --- a/mateclaw-ui/src/views/Security/AutoApproveGrants/index.vue +++ b/mateclaw-ui/src/views/Security/AutoApproveGrants/index.vue @@ -6,142 +6,218 @@

{{ t('approval.grant.desc') }}

- - + + + {{ t('approval.grant.createWorkspaceBtn') }} +
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ t('approval.grant.columns.scope') }}{{ t('approval.grant.columns.tool') }}{{ t('approval.grant.columns.rule') }}{{ t('approval.grant.columns.severity') }}{{ t('approval.grant.columns.kind') }}{{ t('approval.grant.columns.expire') }}{{ t('approval.grant.columns.grantedBy') }}{{ t('approval.grant.columns.note') }}{{ t('approval.grant.columns.actions') }}
- - {{ t(`approval.grant.scope.${scopeI18nKey(g.scopeType)}`) }} - - {{ g.scopeId }} - {{ g.toolName ?? '∗' }}{{ g.ruleId ?? '∗' }}{{ g.maxSeverity }}{{ t(`approval.grant.kind.${kindI18nKey(g.grantKind)}`) }}{{ formatDate(g.expireAt) }}{{ g.grantedBy }}{{ g.note }} - - {{ t('common.revoked') }} -
-
- {{ t('approval.grant.empty') }} -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- + + @@ -149,6 +225,13 @@ import { ref, computed, onMounted, reactive } from 'vue' import { useI18n } from 'vue-i18n' import { ElMessage, ElMessageBox } from 'element-plus' +import { + Delete, + Lock, + Plus, + Refresh, + Unlock, +} from '@element-plus/icons-vue' import { approvalApi } from '@/api' import type { ApprovalGrant, @@ -160,7 +243,12 @@ import type { const { t } = useI18n() -const grants = ref([]) +const rows = ref([]) +const total = ref(0) +const currentPage = ref(1) +const pageSize = ref(20) +const loading = ref(false) + const dialogOpen = ref(false) const dialogWorkspaceWide = ref(false) const creating = ref(false) @@ -194,15 +282,29 @@ function emptyForm(): FormState { } const requiresPassword = computed(() => { - // Backend §2.4.5: password is required for (WORKSPACE/AGENT + tool=null). const noTool = !form.toolName return noTool && (form.scopeType === 'WORKSPACE' || form.scopeType === 'AGENT') }) async function loadGrants() { - const res = await approvalApi.listGrants({ mine: false }) - const payload = (res as any).data ?? res - grants.value = Array.isArray(payload) ? payload : [] + loading.value = true + try { + const res = await approvalApi.listGrants({ + page: currentPage.value, + size: pageSize.value, + }) + const data = (res as any).data ?? res + // Backend serializes Long as string (snowflake precision convention); coerce + // numeric page metadata at the boundary so el-pagination gets real numbers. + rows.value = Array.isArray(data?.records) ? data.records : [] + total.value = Number(data?.total ?? 0) + } catch (e: any) { + ElMessage.error(e?.message || 'Failed to load grants') + rows.value = [] + total.value = 0 + } finally { + loading.value = false + } } function openCreateDialog(workspaceWide: boolean) { @@ -246,6 +348,8 @@ async function submitCreate() { await approvalApi.createGrant(payload) ElMessage.success(t('common.success')) dialogOpen.value = false + // Reset to page 1 so the just-created row is visible at the top. + currentPage.value = 1 await loadGrants() } catch (e: any) { ElMessage.error(e?.message || 'Failed to create grant') @@ -282,6 +386,23 @@ function scopeI18nKey(scope: GrantScope): string { } } +function scopeTagType(scope: GrantScope): 'primary' | 'success' | 'warning' | 'danger' | 'info' { + switch (scope) { + case 'CONVERSATION': return 'primary' + case 'AGENT': return 'warning' + case 'USER': return 'success' + case 'WORKSPACE': return 'danger' + } +} + +function severityTagType(sev: GrantSeverity): 'success' | 'warning' | 'danger' { + switch (sev) { + case 'LOW': return 'success' + case 'MEDIUM': return 'warning' + case 'HIGH': return 'danger' + } +} + function kindI18nKey(kind: GrantKind): string { switch (kind) { case 'ALWAYS': return 'always' @@ -292,7 +413,9 @@ function kindI18nKey(kind: GrantKind): string { function formatDate(s: string | null): string { if (!s) return '—' - return new Date(s).toLocaleString() + const d = new Date(s) + if (Number.isNaN(d.getTime())) return s + return d.toLocaleString() } onMounted(loadGrants) @@ -301,75 +424,51 @@ onMounted(loadGrants) diff --git a/mateclaw-ui/src/views/Security/Layout.vue b/mateclaw-ui/src/views/Security/Layout.vue index 868034e5..fbd3ac74 100644 --- a/mateclaw-ui/src/views/Security/Layout.vue +++ b/mateclaw-ui/src/views/Security/Layout.vue @@ -87,7 +87,7 @@ const sections = computed(() => [ { id: 'autoApprove', path: '/security/auto-approve', - label: t('approval.grant.title'), + label: t('approval.grant.menu'), icon: '', }, ])