refactor(approval-grants-ui): strip Element Plus, reuse project list+pagination

This commit is contained in:
matevip 2026-05-27 15:09:04 +08:00
parent 6a754f5bfa
commit b34de4e078
4 changed files with 267 additions and 166 deletions

View File

@ -3732,6 +3732,7 @@ export default {
manage: 'Manage auto-approve rules...',
},
chipLabel: 'Auto-approve active ({count})',
chipShort: 'Auto-approve {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.',

View File

@ -3824,6 +3824,7 @@ export default {
manage: '管理自动批准策略...',
},
chipLabel: '自动批准已启用 ({count})',
chipShort: '自动批准 {count}',
menu: '自动批准',
title: '自动批准策略',
desc: '策略让特定工具调用跳过人审。地板规则(如 rm -rf /、pipe-to-shell始终生效CRITICAL 严重度永远人审。',

View File

@ -16,17 +16,12 @@
<div class="section-header">
<div class="section-header__title">
<h2 class="section-title">{{ t('approval.grant.title') }}</h2>
<el-tag
v-if="total > 0"
type="warning"
size="small"
effect="light"
round
disable-transitions
class="active-summary"
>
<!-- Inline summary pill same "danger-tinted info" tokens as the
sidebar chip so the two surfaces feel like one design language.
No el-tag here: keeps the page entirely native + token-driven. -->
<span v-if="total > 0" class="summary-pill">
{{ t('approval.grant.chipLabel', { count: total }) }}
</el-tag>
</span>
<p class="section-desc">{{ t('approval.grant.desc') }}</p>
</div>
<div class="section-header__actions">
@ -54,113 +49,93 @@
</div>
</div>
<div class="config-card">
<el-table
v-loading="loading"
:data="rows"
:empty-text="t('approval.grant.empty')"
size="small"
stripe
>
<el-table-column :label="t('approval.grant.columns.scope')" min-width="180">
<template #default="{ row }">
<el-tag
:type="scopeTagType(row.scopeType)"
size="small"
effect="light"
disable-transitions
>
{{ t(`approval.grant.scope.${scopeI18nKey(row.scopeType)}`) }}
</el-tag>
<span class="scope-id">{{ row.scopeId }}</span>
</template>
</el-table-column>
<!--
Table + pagination native HTML, no Element Plus components. Mirrors
the ToolGuard / AuditLogs pattern (rules-table-wrapper + .rules-table
+ .severity-badge / .action-btn from shared.css) so this page reads
like any other Security sub-view. Pagination uses the shared
McPagination component, which is also fully native (no el-pagination).
-->
<div class="rules-table-wrapper">
<table class="rules-table">
<thead>
<tr>
<th>{{ t('approval.grant.columns.scope') }}</th>
<th>{{ t('approval.grant.columns.tool') }}</th>
<th>{{ t('approval.grant.columns.rule') }}</th>
<th>{{ t('approval.grant.columns.severity') }}</th>
<th>{{ t('approval.grant.columns.kind') }}</th>
<th>{{ t('approval.grant.columns.expire') }}</th>
<th>{{ t('approval.grant.columns.grantedBy') }}</th>
<th>{{ t('approval.grant.columns.note') }}</th>
<th class="col-actions">{{ t('approval.grant.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
:key="row.id"
:class="{ 'row-revoked': row.revoked === 1 }"
>
<td>
<span
class="scope-badge"
:class="`scope-${scopeI18nKey(row.scopeType)}`"
>
{{ t(`approval.grant.scope.${scopeI18nKey(row.scopeType)}`) }}
</span>
<div class="scope-id" :title="row.scopeId">{{ row.scopeId }}</div>
</td>
<td>
<code v-if="row.toolName" class="mono">{{ row.toolName }}</code>
<span v-else class="severity-badge severity-high"> any</span>
</td>
<td>
<code v-if="row.ruleId" class="mono">{{ row.ruleId }}</code>
<span v-else class="muted"></span>
</td>
<td>
<span
class="severity-badge"
:class="`severity-${row.maxSeverity?.toLowerCase()}`"
>
{{ row.maxSeverity }}
</span>
</td>
<td>{{ t(`approval.grant.kind.${kindI18nKey(row.grantKind)}`) }}</td>
<td class="muted">{{ formatDate(row.expireAt) }}</td>
<td>{{ row.grantedBy }}</td>
<td class="note-cell" :title="row.note || ''">{{ row.note }}</td>
<td>
<button
v-if="row.revoked === 0"
class="action-btn danger"
:title="t('approval.grant.revokeBtn')"
@click="confirmRevoke(row)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
<span v-else class="muted">{{ t('common.revoked') }}</span>
</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="empty-state">{{ t('common.loading') }}</div>
<div v-else-if="!rows.length" class="empty-state">{{ t('approval.grant.empty') }}</div>
</div>
<el-table-column
:label="t('approval.grant.columns.tool')"
prop="toolName"
min-width="140"
>
<template #default="{ row }">
<code v-if="row.toolName" class="mono">{{ row.toolName }}</code>
<el-tag v-else type="danger" size="small" effect="dark"> any</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('approval.grant.columns.rule')" min-width="140">
<template #default="{ row }">
<code v-if="row.ruleId" class="mono">{{ row.ruleId }}</code>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column
:label="t('approval.grant.columns.severity')"
prop="maxSeverity"
width="110"
>
<template #default="{ row }">
<el-tag :type="severityTagType(row.maxSeverity)" size="small" disable-transitions>
{{ row.maxSeverity }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('approval.grant.columns.kind')" width="140">
<template #default="{ row }">
{{ t(`approval.grant.kind.${kindI18nKey(row.grantKind)}`) }}
</template>
</el-table-column>
<el-table-column :label="t('approval.grant.columns.expire')" width="160">
<template #default="{ row }">
<span class="muted">{{ formatDate(row.expireAt) }}</span>
</template>
</el-table-column>
<el-table-column
:label="t('approval.grant.columns.grantedBy')"
prop="grantedBy"
width="120"
/>
<el-table-column :label="t('approval.grant.columns.note')" min-width="160">
<template #default="{ row }">
<span :title="row.note || ''" class="note-cell">{{ row.note }}</span>
</template>
</el-table-column>
<el-table-column :label="t('approval.grant.columns.actions')" width="100" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.revoked === 0"
:icon="Delete"
type="danger"
size="small"
text
@click="confirmRevoke(row)"
>
{{ t('approval.grant.revokeBtn') }}
</el-button>
<el-tag v-else type="info" size="small" effect="plain" disable-transitions>
{{ t('common.revoked') }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
class="grants-pagination"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
<div v-if="total > 0" class="grants-pagination-row">
<McPagination
:page="currentPage"
:size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
small
@size-change="loadGrants"
@current-change="loadGrants"
:sizes="[10, 20, 50, 100]"
@update:page="onPageChange"
@update:size="onSizeChange"
/>
</div>
@ -312,6 +287,7 @@ import {
WarningFilled,
} from '@element-plus/icons-vue'
import { approvalApi } from '@/api'
import McPagination from '@/components/common/McPagination.vue'
import type {
ApprovalGrant,
CreateGrantPayload,
@ -365,6 +341,16 @@ const requiresPassword = computed(() => {
return noTool && (form.scopeType === 'WORKSPACE' || form.scopeType === 'AGENT')
})
function onPageChange(p: number) {
currentPage.value = p
loadGrants()
}
function onSizeChange(s: number) {
pageSize.value = s
currentPage.value = 1
loadGrants()
}
async function loadGrants() {
loading.value = true
try {
@ -471,23 +457,6 @@ 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'
@ -523,8 +492,19 @@ onMounted(loadGrants)
flex-basis: 100%;
margin: 4px 0 0;
}
.active-summary {
/* Summary pill reuses the same danger-tinted tokens as the sidebar chip
so the two surfaces feel like one design language; no el-tag here. */
.summary-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
background: var(--mc-danger-bg, rgba(192, 57, 43, 0.12));
color: var(--mc-danger, #C0392B);
border: 1px solid var(--mc-danger-border, rgba(192, 57, 43, 0.4));
font-size: 12px;
font-weight: 600;
line-height: 1.5;
}
/* Header actions: matches McpServers / ToolGuard layout (.section-header__actions
@ -567,17 +547,36 @@ onMounted(loadGrants)
color: #991b1b;
}
/* Scope badge same family as .severity-badge in shared.css, 4 token-driven
color variants for CONVERSATION / AGENT / USER / WORKSPACE. */
.scope-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
}
.scope-conversation { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.scope-agent { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
.scope-user { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.scope-workspace { background: var(--mc-danger-bg, rgba(192, 57, 43, 0.12));
color: var(--mc-danger, #C0392B); }
.scope-id {
margin-left: 8px;
margin-top: 2px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-size: 11px;
color: var(--mc-text-tertiary, #94a3b8);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: var(--mc-surface-tertiary, #f1f5f9);
background: var(--mc-surface-tertiary, var(--mc-bg-muted, rgba(0, 0, 0, 0.04)));
padding: 1px 6px;
border-radius: 3px;
}
@ -596,8 +595,29 @@ onMounted(loadGrants)
font-size: 12px;
}
.grants-pagination {
margin-top: 16px;
/* Revoked rows: dim out so live rows stay visually dominant. */
.row-revoked { opacity: 0.55; }
.row-revoked .scope-badge { filter: grayscale(0.5); }
/* Actions column header sits right-aligned, matches ToolGuard. */
.col-actions { text-align: center; width: 80px; }
/* This page has more columns than ToolGuard / AuditLogs, so the wrapper needs
to allow horizontal scroll on narrow viewports shared.css uses
overflow:hidden which would chop the rightmost columns off. Scoped override
only affects this view; the shared style stays untouched. */
.rules-table-wrapper {
overflow-x: auto;
}
.rules-table {
min-width: 900px;
}
/* Pagination row right-aligned beneath the table. McPagination provides
its own pill background, so just lay it out. */
.grants-pagination-row {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}

View File

@ -80,11 +80,34 @@
<!-- 底部 -->
<div class="sidebar-footer">
<template v-if="!sidebarCollapsed || isMobile">
<!-- Doctor 健康指示器 -->
<button class="health-indicator" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')">
<span class="health-dot"></span>
<span class="health-label">{{ t('doctor.title') }}</span>
</button>
<!--
Status row: two side-by-side status cards. Doctor health on the left
stays always visible; the auto-approve chip on the right only renders
when at least one grant is active. When the chip is hidden, flex
naturally lets the doctor card expand to full width again so the
row never wastes vertical space the way a stacked banner did.
-->
<div class="footer-status-row">
<button
class="health-indicator"
:class="[healthStatus, { 'is-half': autoApproveSummary && autoApproveSummary.count > 0 }]"
@click="showDoctor = true"
:title="t('doctor.title')"
>
<span class="health-dot"></span>
<span class="health-label">{{ t('doctor.title') }}</span>
</button>
<button
v-if="autoApproveSummary && autoApproveSummary.count > 0"
class="auto-approve-chip"
@click="goAutoApproveSettings"
:title="t('approval.grant.title')"
>
<span class="auto-approve-chip__dot"></span>
<el-icon :size="13"><Unlock /></el-icon>
<span class="auto-approve-chip__label">{{ t('approval.grant.chipShort', { count: autoApproveSummary.count }) }}</span>
</button>
</div>
<div class="sidebar-utility-card">
<div class="compact-utility-row">
@ -133,18 +156,6 @@
</button>
</div>
<!-- Auto-approve chip red on purpose: security-reducing setting needs
persistent visibility, not a friendly green badge. -->
<button
v-if="autoApproveSummary && autoApproveSummary.count > 0"
class="auto-approve-chip"
@click="goAutoApproveSettings"
:title="t('approval.grant.title')"
>
<el-icon :size="14"><Unlock /></el-icon>
<span>{{ t('approval.grant.chipLabel', { count: autoApproveSummary.count }) }}</span>
</button>
<div class="shortcuts-hint" :title="shortcutsHintText">
<kbd>Ctrl+K</kbd>
<span>{{ t('nav.shortcutAgents') }}</span>
@ -805,25 +816,93 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
backdrop-filter: blur(14px);
position: relative;
}
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-muted); border-radius: 12px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 8px; }
/* Status row: doctor health + (optional) auto-approve chip side by side, so
the footer never gives up a whole banner-row for a single state badge. When
the chip is hidden, .health-indicator naturally expands back to full width. */
.footer-status-row {
display: flex;
gap: 8px;
width: 100%;
margin-bottom: 8px;
}
.health-indicator {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
border-radius: 12px;
cursor: pointer;
color: var(--mc-text-secondary);
font-size: 12px;
}
.health-indicator:hover { background: var(--mc-bg-sunken); }
.health-indicator .health-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* When the chip is showing, the health card yields half of the row so the
two states stay visually balanced. */
.health-indicator.is-half { flex: 1 1 50%; }
/* Auto-approve chip red border + red text, the persistent reminder that
this workspace currently has active auto-approve rules. Clicking takes
the user to Security > 自动批准策略 so they can review or revoke. */
/*
Auto-approve chip persistent indicator that this workspace currently has
at least one active auto-approve rule. Designed to be informative, not
alarming: a soft danger-tinted pill with a steady pulse on the dot, sized
to fit beside the doctor indicator on one row. Colors come from the
mateclaw token system (`var(--mc-danger-*)`), so dark mode picks up the
appropriate dim variants automatically.
*/
.auto-approve-chip {
display: flex; align-items: center; gap: 6px;
width: 100%; padding: 6px 10px;
border: 1px solid #ef4444;
background: #fef2f2;
color: #b91c1c;
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1 1 50%;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--mc-danger-border, rgba(192, 57, 43, 0.4));
background: var(--mc-danger-bg, rgba(192, 57, 43, 0.12));
color: var(--mc-danger, #C0392B);
border-radius: 12px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
line-height: 1.2;
transition: background-color 0.15s, border-color 0.15s;
}
.auto-approve-chip:hover {
background: var(--mc-danger-bg, rgba(192, 57, 43, 0.18));
border-color: var(--mc-danger, #C0392B);
}
.auto-approve-chip__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Small live-status dot that gently pulses to suggest "this is active right
now" without being aggressive about it. The animation pauses when the user
prefers reduced motion. */
.auto-approve-chip__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mc-danger, #C0392B);
box-shadow: 0 0 0 0 var(--mc-danger, #C0392B);
animation: auto-approve-pulse 2.4s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes auto-approve-pulse {
0% { box-shadow: 0 0 0 0 var(--mc-danger, #C0392B); opacity: 1; }
60% { box-shadow: 0 0 0 6px transparent; opacity: 0.6; }
100% { box-shadow: 0 0 0 0 transparent; opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.auto-approve-chip__dot { animation: none; }
}
.auto-approve-chip:hover { background: #fee2e2; }
.health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.health-indicator.healthy .health-dot { background: var(--mc-success); }
.health-indicator.warning .health-dot { background: var(--mc-primary); }