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...', manage: 'Manage auto-approve rules...',
}, },
chipLabel: 'Auto-approve active ({count})', chipLabel: 'Auto-approve active ({count})',
chipShort: 'Auto-approve {count}',
menu: 'Auto-approve', menu: 'Auto-approve',
title: 'Auto-approve rules', 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.', 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: '管理自动批准策略...', manage: '管理自动批准策略...',
}, },
chipLabel: '自动批准已启用 ({count})', chipLabel: '自动批准已启用 ({count})',
chipShort: '自动批准 {count}',
menu: '自动批准', menu: '自动批准',
title: '自动批准策略', title: '自动批准策略',
desc: '策略让特定工具调用跳过人审。地板规则(如 rm -rf /、pipe-to-shell始终生效CRITICAL 严重度永远人审。', desc: '策略让特定工具调用跳过人审。地板规则(如 rm -rf /、pipe-to-shell始终生效CRITICAL 严重度永远人审。',

View File

@ -16,17 +16,12 @@
<div class="section-header"> <div class="section-header">
<div class="section-header__title"> <div class="section-header__title">
<h2 class="section-title">{{ t('approval.grant.title') }}</h2> <h2 class="section-title">{{ t('approval.grant.title') }}</h2>
<el-tag <!-- Inline summary pill same "danger-tinted info" tokens as the
v-if="total > 0" sidebar chip so the two surfaces feel like one design language.
type="warning" No el-tag here: keeps the page entirely native + token-driven. -->
size="small" <span v-if="total > 0" class="summary-pill">
effect="light"
round
disable-transitions
class="active-summary"
>
{{ t('approval.grant.chipLabel', { count: total }) }} {{ t('approval.grant.chipLabel', { count: total }) }}
</el-tag> </span>
<p class="section-desc">{{ t('approval.grant.desc') }}</p> <p class="section-desc">{{ t('approval.grant.desc') }}</p>
</div> </div>
<div class="section-header__actions"> <div class="section-header__actions">
@ -54,113 +49,93 @@
</div> </div>
</div> </div>
<div class="config-card"> <!--
<el-table Table + pagination native HTML, no Element Plus components. Mirrors
v-loading="loading" the ToolGuard / AuditLogs pattern (rules-table-wrapper + .rules-table
:data="rows" + .severity-badge / .action-btn from shared.css) so this page reads
:empty-text="t('approval.grant.empty')" like any other Security sub-view. Pagination uses the shared
size="small" McPagination component, which is also fully native (no el-pagination).
stripe -->
> <div class="rules-table-wrapper">
<el-table-column :label="t('approval.grant.columns.scope')" min-width="180"> <table class="rules-table">
<template #default="{ row }"> <thead>
<el-tag <tr>
:type="scopeTagType(row.scopeType)" <th>{{ t('approval.grant.columns.scope') }}</th>
size="small" <th>{{ t('approval.grant.columns.tool') }}</th>
effect="light" <th>{{ t('approval.grant.columns.rule') }}</th>
disable-transitions <th>{{ t('approval.grant.columns.severity') }}</th>
> <th>{{ t('approval.grant.columns.kind') }}</th>
{{ t(`approval.grant.scope.${scopeI18nKey(row.scopeType)}`) }} <th>{{ t('approval.grant.columns.expire') }}</th>
</el-tag> <th>{{ t('approval.grant.columns.grantedBy') }}</th>
<span class="scope-id">{{ row.scopeId }}</span> <th>{{ t('approval.grant.columns.note') }}</th>
</template> <th class="col-actions">{{ t('approval.grant.columns.actions') }}</th>
</el-table-column> </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 <div v-if="total > 0" class="grants-pagination-row">
:label="t('approval.grant.columns.tool')" <McPagination
prop="toolName" :page="currentPage"
min-width="140" :size="pageSize"
>
<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"
:total="total" :total="total"
:page-sizes="[10, 20, 50, 100]" :sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper" @update:page="onPageChange"
background @update:size="onSizeChange"
small
@size-change="loadGrants"
@current-change="loadGrants"
/> />
</div> </div>
@ -312,6 +287,7 @@ import {
WarningFilled, WarningFilled,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { approvalApi } from '@/api' import { approvalApi } from '@/api'
import McPagination from '@/components/common/McPagination.vue'
import type { import type {
ApprovalGrant, ApprovalGrant,
CreateGrantPayload, CreateGrantPayload,
@ -365,6 +341,16 @@ const requiresPassword = computed(() => {
return noTool && (form.scopeType === 'WORKSPACE' || form.scopeType === 'AGENT') 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() { async function loadGrants() {
loading.value = true loading.value = true
try { 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 { function kindI18nKey(kind: GrantKind): string {
switch (kind) { switch (kind) {
case 'ALWAYS': return 'always' case 'ALWAYS': return 'always'
@ -523,8 +492,19 @@ onMounted(loadGrants)
flex-basis: 100%; flex-basis: 100%;
margin: 4px 0 0; 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; font-weight: 600;
line-height: 1.5;
} }
/* Header actions: matches McpServers / ToolGuard layout (.section-header__actions /* Header actions: matches McpServers / ToolGuard layout (.section-header__actions
@ -567,17 +547,36 @@ onMounted(loadGrants)
color: #991b1b; 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 { .scope-id {
margin-left: 8px; margin-top: 2px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 11px;
color: var(--mc-text-tertiary, #94a3b8); color: var(--mc-text-tertiary, #94a3b8);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.mono { .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; 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; padding: 1px 6px;
border-radius: 3px; border-radius: 3px;
} }
@ -596,8 +595,29 @@ onMounted(loadGrants)
font-size: 12px; font-size: 12px;
} }
.grants-pagination { /* Revoked rows: dim out so live rows stay visually dominant. */
margin-top: 16px; .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; justify-content: flex-end;
} }

View File

@ -80,11 +80,34 @@
<!-- 底部 --> <!-- 底部 -->
<div class="sidebar-footer"> <div class="sidebar-footer">
<template v-if="!sidebarCollapsed || isMobile"> <template v-if="!sidebarCollapsed || isMobile">
<!-- Doctor 健康指示器 --> <!--
<button class="health-indicator" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')"> Status row: two side-by-side status cards. Doctor health on the left
<span class="health-dot"></span> stays always visible; the auto-approve chip on the right only renders
<span class="health-label">{{ t('doctor.title') }}</span> when at least one grant is active. When the chip is hidden, flex
</button> 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="sidebar-utility-card">
<div class="compact-utility-row"> <div class="compact-utility-row">
@ -133,18 +156,6 @@
</button> </button>
</div> </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"> <div class="shortcuts-hint" :title="shortcutsHintText">
<kbd>Ctrl+K</kbd> <kbd>Ctrl+K</kbd>
<span>{{ t('nav.shortcutAgents') }}</span> <span>{{ t('nav.shortcutAgents') }}</span>
@ -805,25 +816,93 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
position: relative; 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: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 Auto-approve chip persistent indicator that this workspace currently has
the user to Security > 自动批准策略 so they can review or revoke. */ 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 { .auto-approve-chip {
display: flex; align-items: center; gap: 6px; display: inline-flex;
width: 100%; padding: 6px 10px; align-items: center;
border: 1px solid #ef4444; gap: 6px;
background: #fef2f2; flex: 1 1 50%;
color: #b91c1c; 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; border-radius: 12px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
font-weight: 600; 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-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.health-indicator.healthy .health-dot { background: var(--mc-success); } .health-indicator.healthy .health-dot { background: var(--mc-success); }
.health-indicator.warning .health-dot { background: var(--mc-primary); } .health-indicator.warning .health-dot { background: var(--mc-primary); }