refactor(approval-grants-ui): paginated list, Element Plus icons, shorter sidebar label

This commit is contained in:
matevip 2026-05-27 15:08:41 +08:00
parent 2f5e06f286
commit 65cf53779a
8 changed files with 336 additions and 203 deletions

View File

@ -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<ApprovalGrant>> list(
public R<IPage<ApprovalGrant>> 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.<ApprovalGrant>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<ApprovalGrant> pageObj = new Page<>(boundedPage, boundedSize);
return R.ok(grantMapper.selectPage(pageObj, wrapper));
}
// Active summary (chip "(N)")

View File

@ -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);
}

View File

@ -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<ApprovalGrant[]>('/approval/grants', { params }),
page?: number
size?: number
}) => http.get<ApprovalGrantPage>('/approval/grants', { params }),
/** Active-grant summary used by the global chip + ChatInput pill counters. */
activeSummary: () =>

View File

@ -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: {

View File

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

View File

@ -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 LongString 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.

View File

@ -6,142 +6,218 @@
<p class="section-desc">{{ t('approval.grant.desc') }}</p>
</div>
<div class="header-actions">
<button class="btn-secondary" @click="openCreateDialog(false)">
<el-button :icon="Refresh" plain @click="loadGrants" :loading="loading">
{{ t('common.refresh') }}
</el-button>
<el-button :icon="Plus" type="primary" plain @click="openCreateDialog(false)">
{{ t('approval.grant.createBtn') }}
</button>
<button class="btn-danger" @click="openCreateDialog(true)">
🔓 {{ t('approval.grant.createWorkspaceBtn') }}
</button>
</el-button>
<el-button :icon="Unlock" type="danger" plain @click="openCreateDialog(true)">
{{ t('approval.grant.createWorkspaceBtn') }}
</el-button>
</div>
</div>
<!-- Grants table -->
<div class="config-card">
<table v-if="grants.length" class="grants-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>{{ t('approval.grant.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="g in grants" :key="g.id" :class="{ 'row-revoked': g.revoked === 1 }">
<td>
<span class="scope-badge" :class="`scope-${g.scopeType.toLowerCase()}`">
{{ t(`approval.grant.scope.${scopeI18nKey(g.scopeType)}`) }}
</span>
<span class="scope-id">{{ g.scopeId }}</span>
</td>
<td>{{ g.toolName ?? '' }}</td>
<td>{{ g.ruleId ?? '' }}</td>
<td>{{ g.maxSeverity }}</td>
<td>{{ t(`approval.grant.kind.${kindI18nKey(g.grantKind)}`) }}</td>
<td>{{ formatDate(g.expireAt) }}</td>
<td>{{ g.grantedBy }}</td>
<td class="note-cell" :title="g.note ?? ''">{{ g.note }}</td>
<td>
<button
v-if="g.revoked === 0"
class="btn-link"
@click="confirmRevoke(g)">
{{ t('approval.grant.revokeBtn') }}
</button>
<span v-else class="muted">{{ t('common.revoked') }}</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty-state">
{{ t('approval.grant.empty') }}
</div>
<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>
<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"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
small
@size-change="loadGrants"
@current-change="loadGrants"
/>
</div>
<!-- Create dialog -->
<div v-if="dialogOpen" class="modal-backdrop" @click.self="dialogOpen = false">
<div class="modal-card">
<div class="modal-header">
<h3>{{ t('approval.grant.createBtn') }}</h3>
<button class="modal-close" @click="dialogOpen = false">&times;</button>
</div>
<el-dialog
v-model="dialogOpen"
:title="dialogWorkspaceWide ? t('approval.grant.createWorkspaceBtn') : t('approval.grant.createBtn')"
width="560px"
:close-on-click-modal="false"
destroy-on-close
>
<el-alert
v-if="dialogWorkspaceWide"
type="warning"
:closable="false"
show-icon
class="warning-banner"
>
{{ t('approval.grant.createWorkspaceWarning') }}
</el-alert>
<div v-if="dialogWorkspaceWide" class="warning-banner">
{{ t('approval.grant.createWorkspaceWarning') }}
</div>
<el-form :model="form" label-width="120px" class="grant-form">
<el-form-item :label="t('approval.grant.form.scopeType')">
<el-select v-model="form.scopeType" :disabled="dialogWorkspaceWide" style="width: 100%">
<el-option label="CONVERSATION" value="CONVERSATION" />
<el-option label="AGENT" value="AGENT" />
<el-option label="USER" value="USER" />
<el-option label="WORKSPACE" value="WORKSPACE" />
</el-select>
</el-form-item>
<el-form-item :label="t('approval.grant.form.scopeId')">
<el-input
v-model.trim="form.scopeId"
type="text"
inputmode="numeric"
pattern="\d*"
placeholder="snowflake id"
/>
</el-form-item>
<el-form-item :label="t('approval.grant.form.toolName')">
<el-input v-model.trim="form.toolName" :disabled="dialogWorkspaceWide" />
</el-form-item>
<el-form-item :label="t('approval.grant.form.ruleId')">
<el-input v-model.trim="form.ruleId" placeholder="(optional)" />
</el-form-item>
<el-form-item :label="t('approval.grant.form.maxSeverity')">
<el-select v-model="form.maxSeverity" style="width: 100%">
<el-option label="LOW" value="LOW" />
<el-option label="MEDIUM" value="MEDIUM" />
<el-option label="HIGH" value="HIGH" />
</el-select>
</el-form-item>
<el-form-item :label="t('approval.grant.form.grantKind')">
<el-select v-model="form.grantKind" style="width: 100%">
<el-option :label="t('approval.grant.kind.always')" value="ALWAYS" />
<el-option :label="t('approval.grant.kind.until')" value="UNTIL_TIMESTAMP" />
<el-option :label="t('approval.grant.kind.conversationEnd')" value="UNTIL_CONVERSATION_END" />
</el-select>
</el-form-item>
<el-form-item
v-if="form.grantKind === 'UNTIL_TIMESTAMP'"
:label="t('approval.grant.form.expireAt')"
>
<el-date-picker
v-model="form.expireAt"
type="datetime"
style="width: 100%"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
</el-form-item>
<el-form-item :label="t('approval.grant.form.note')">
<el-input v-model.trim="form.note" />
</el-form-item>
<el-form-item
v-if="requiresPassword"
:label="t('approval.grant.form.password')"
>
<el-input
v-model="form.password"
type="password"
show-password
:prefix-icon="Lock"
/>
</el-form-item>
</el-form>
<div class="modal-body">
<div class="form-row">
<label>{{ t('approval.grant.form.scopeType') }}</label>
<select v-model="form.scopeType" :disabled="dialogWorkspaceWide">
<option value="CONVERSATION">{{ t('approval.grant.scope.conversation') }}</option>
<option value="AGENT">{{ t('approval.grant.scope.agent') }}</option>
<option value="USER">{{ t('approval.grant.scope.user') }}</option>
<option value="WORKSPACE">{{ t('approval.grant.scope.workspace') }}</option>
</select>
</div>
<div class="form-row">
<label>{{ t('approval.grant.form.scopeId') }}</label>
<input
v-model.trim="form.scopeId"
type="text"
inputmode="numeric"
pattern="\d*"
placeholder="snowflake id" />
</div>
<div class="form-row">
<label>{{ t('approval.grant.form.toolName') }}</label>
<input v-model.trim="form.toolName" type="text" :disabled="dialogWorkspaceWide" />
</div>
<div class="form-row">
<label>{{ t('approval.grant.form.ruleId') }}</label>
<input v-model.trim="form.ruleId" type="text" placeholder="(optional)" />
</div>
<div class="form-row">
<label>{{ t('approval.grant.form.maxSeverity') }}</label>
<select v-model="form.maxSeverity">
<option value="LOW">LOW</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH</option>
</select>
</div>
<div class="form-row">
<label>{{ t('approval.grant.form.grantKind') }}</label>
<select v-model="form.grantKind">
<option value="ALWAYS">{{ t('approval.grant.kind.always') }}</option>
<option value="UNTIL_TIMESTAMP">{{ t('approval.grant.kind.until') }}</option>
<option value="UNTIL_CONVERSATION_END">{{ t('approval.grant.kind.conversationEnd') }}</option>
</select>
</div>
<div v-if="form.grantKind === 'UNTIL_TIMESTAMP'" class="form-row">
<label>{{ t('approval.grant.form.expireAt') }}</label>
<input v-model="form.expireAt" type="datetime-local" />
</div>
<div class="form-row">
<label>{{ t('approval.grant.form.note') }}</label>
<input v-model.trim="form.note" type="text" />
</div>
<div v-if="requiresPassword" class="form-row">
<label>{{ t('approval.grant.form.password') }}</label>
<input v-model="form.password" type="password" />
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="dialogOpen = false">{{ t('common.cancel') }}</button>
<button
class="btn-primary"
:disabled="creating"
@click="submitCreate">
{{ creating ? t('common.processing') : t('common.confirm') }}
</button>
</div>
</div>
</div>
<template #footer>
<el-button @click="dialogOpen = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">
{{ t('common.confirm') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
@ -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<ApprovalGrant[]>([])
const rows = ref<ApprovalGrant[]>([])
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)
<style scoped>
@import '@/views/Security/shared.css';
.header-actions { display: flex; gap: 8px; align-items: center; }
.btn-danger {
padding: 8px 14px;
border-radius: 6px;
border: 1px solid #ef4444;
background: #fef2f2;
color: #b91c1c;
font-weight: 500;
cursor: pointer;
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.btn-danger:hover { background: #fee2e2; }
.grants-table { width: 100%; border-collapse: collapse; }
.grants-table th,
.grants-table td {
padding: 10px 12px;
font-size: 13px;
border-bottom: 1px solid var(--mc-border-light, #e5e7eb);
text-align: left;
.scope-id {
margin-left: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--mc-text-tertiary, #94a3b8);
}
.grants-table th { font-weight: 600; color: var(--mc-text-secondary, #64748b); }
.row-revoked { opacity: 0.5; }
.scope-badge {
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: var(--mc-surface-tertiary, #f1f5f9);
padding: 1px 6px;
border-radius: 3px;
}
.note-cell {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
margin-right: 6px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.scope-conversation { background: #e0f2fe; color: #075985; }
.scope-agent { background: #fef3c7; color: #92400e; }
.scope-user { background: #ddd6fe; color: #5b21b6; }
.scope-workspace { background: #fee2e2; color: #991b1b; }
.scope-id { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; color: var(--mc-text-tertiary, #94a3b8); }
.note-cell { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-link { background: none; border: none; color: #ef4444; cursor: pointer; padding: 0; }
.empty-state { padding: 32px; text-align: center; color: var(--mc-text-tertiary, #94a3b8); }
.muted { color: var(--mc-text-tertiary, #94a3b8); font-size: 12px; }
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
.muted {
color: var(--mc-text-tertiary, #94a3b8);
font-size: 12px;
}
.modal-card {
background: var(--mc-surface-primary, #fff);
border-radius: 8px;
width: min(540px, 92vw);
max-height: 88vh;
display: flex; flex-direction: column;
.grants-pagination {
margin-top: 16px;
justify-content: flex-end;
}
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--mc-border-light, #e5e7eb); display: flex; justify-content: space-between; align-items: center; }
.modal-header h3 { margin: 0; font-size: 16px; }
.modal-close { background: none; border: none; font-size: 20px; cursor: pointer; color: var(--mc-text-tertiary, #94a3b8); }
.modal-body { padding: 16px 20px; overflow-y: auto; }
.modal-footer { padding: 12px 20px; border-top: 1px solid var(--mc-border-light, #e5e7eb); display: flex; justify-content: flex-end; gap: 8px; }
.warning-banner { background: #fef2f2; color: #991b1b; padding: 12px 20px; margin: 0; font-size: 13px; line-height: 1.5; border-bottom: 1px solid #fecaca; }
.form-row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: center; margin-bottom: 12px; }
.form-row label { font-size: 13px; color: var(--mc-text-secondary, #64748b); }
.form-row input, .form-row select {
padding: 6px 10px;
border: 1px solid var(--mc-border-light, #e5e7eb);
border-radius: 4px;
font-size: 13px;
background: var(--mc-surface-primary, #fff);
color: var(--mc-text-primary, #0f172a);
.warning-banner {
margin-bottom: 16px;
}
.grant-form {
padding-top: 4px;
}
.form-row input:disabled, .form-row select:disabled { background: var(--mc-surface-tertiary, #f1f5f9); cursor: not-allowed; }
</style>

View File

@ -87,7 +87,7 @@ const sections = computed(() => [
{
id: 'autoApprove',
path: '/security/auto-approve',
label: t('approval.grant.title'),
label: t('approval.grant.menu'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
},
])