refactor(approval-grants-ui): rebuild create dialog to match ToolGuard pattern

This commit is contained in:
matevip 2026-05-27 15:08:56 +08:00
parent b9c5ec03d4
commit 6a754f5bfa

View File

@ -164,96 +164,137 @@
/> />
</div> </div>
<!-- Create dialog --> <!--
<el-dialog Create dialog matches the ToolGuard "edit rule" modal pattern:
v-model="dialogOpen" Teleport to body + native .modal-overlay / .modal / .modal-header /
:title="dialogWorkspaceWide ? t('approval.grant.createWorkspaceBtn') : t('approval.grant.createBtn')" .modal-body / .modal-footer + .form-grid / .form-group / .form-input.
width="560px" The workspace-wide path reuses the same modal with a red warning banner
:close-on-click-modal="false" at the top so the destructive variant looks identifiably different
destroy-on-close from the routine create flow.
> -->
<el-alert <Teleport to="body">
v-if="dialogWorkspaceWide" <div v-if="dialogOpen" class="modal-overlay" @click.self="dialogOpen = false">
type="warning" <div class="modal">
:closable="false" <div class="modal-header">
show-icon <h3>
class="warning-banner" <el-icon v-if="dialogWorkspaceWide" :size="18" class="modal-header__icon"><Unlock /></el-icon>
> {{ dialogWorkspaceWide
{{ t('approval.grant.createWorkspaceWarning') }} ? t('approval.grant.createWorkspaceBtn')
</el-alert> : t('approval.grant.createBtn') }}
</h3>
<el-form :model="form" label-width="120px" class="grant-form"> <button class="modal-close" @click="dialogOpen = false">&times;</button>
<el-form-item :label="t('approval.grant.form.scopeType')"> </div>
<el-select v-model="form.scopeType" :disabled="dialogWorkspaceWide" style="width: 100%"> <div class="modal-body">
<el-option label="CONVERSATION" value="CONVERSATION" /> <div v-if="dialogWorkspaceWide" class="danger-banner">
<el-option label="AGENT" value="AGENT" /> <el-icon :size="16"><WarningFilled /></el-icon>
<el-option label="USER" value="USER" /> <span>{{ t('approval.grant.createWorkspaceWarning') }}</span>
<el-option label="WORKSPACE" value="WORKSPACE" /> </div>
</el-select> <div class="form-grid">
</el-form-item> <div class="form-group">
<el-form-item :label="t('approval.grant.form.scopeId')"> <label>{{ t('approval.grant.form.scopeType') }} <span class="required">*</span></label>
<el-input <select
v-model.trim="form.scopeId" v-model="form.scopeType"
type="text" class="form-input"
inputmode="numeric" :disabled="dialogWorkspaceWide"
pattern="\d*" >
placeholder="snowflake id" <option value="CONVERSATION">CONVERSATION</option>
/> <option value="AGENT">AGENT</option>
</el-form-item> <option value="USER">USER</option>
<el-form-item :label="t('approval.grant.form.toolName')"> <option value="WORKSPACE">WORKSPACE</option>
<el-input v-model.trim="form.toolName" :disabled="dialogWorkspaceWide" /> </select>
</el-form-item> </div>
<el-form-item :label="t('approval.grant.form.ruleId')"> <div class="form-group">
<el-input v-model.trim="form.ruleId" placeholder="(optional)" /> <label>{{ t('approval.grant.form.scopeId') }} <span class="required">*</span></label>
</el-form-item> <input
<el-form-item :label="t('approval.grant.form.maxSeverity')"> v-model.trim="form.scopeId"
<el-select v-model="form.maxSeverity" style="width: 100%"> type="text"
<el-option label="LOW" value="LOW" /> inputmode="numeric"
<el-option label="MEDIUM" value="MEDIUM" /> pattern="\d*"
<el-option label="HIGH" value="HIGH" /> placeholder="snowflake id"
</el-select> class="form-input"
</el-form-item> />
<el-form-item :label="t('approval.grant.form.grantKind')"> </div>
<el-select v-model="form.grantKind" style="width: 100%"> <div class="form-group">
<el-option :label="t('approval.grant.kind.always')" value="ALWAYS" /> <label>{{ t('approval.grant.form.toolName') }}</label>
<el-option :label="t('approval.grant.kind.until')" value="UNTIL_TIMESTAMP" /> <input
<el-option :label="t('approval.grant.kind.conversationEnd')" value="UNTIL_CONVERSATION_END" /> v-model.trim="form.toolName"
</el-select> class="form-input mono"
</el-form-item> :disabled="dialogWorkspaceWide"
<el-form-item placeholder="(empty = any tool)"
v-if="form.grantKind === 'UNTIL_TIMESTAMP'" />
:label="t('approval.grant.form.expireAt')" </div>
> <div class="form-group">
<el-date-picker <label>{{ t('approval.grant.form.ruleId') }}</label>
v-model="form.expireAt" <input
type="datetime" v-model.trim="form.ruleId"
style="width: 100%" class="form-input mono"
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="(optional)"
/> />
</el-form-item> </div>
<el-form-item :label="t('approval.grant.form.note')"> <div class="form-group">
<el-input v-model.trim="form.note" /> <label>{{ t('approval.grant.form.maxSeverity') }}</label>
</el-form-item> <select v-model="form.maxSeverity" class="form-input">
<el-form-item <option value="LOW">LOW</option>
v-if="requiresPassword" <option value="MEDIUM">MEDIUM</option>
:label="t('approval.grant.form.password')" <option value="HIGH">HIGH</option>
> </select>
<el-input </div>
v-model="form.password" <div class="form-group">
type="password" <label>{{ t('approval.grant.form.grantKind') }}</label>
show-password <select v-model="form.grantKind" class="form-input">
:prefix-icon="Lock" <option value="ALWAYS">{{ t('approval.grant.kind.always') }}</option>
/> <option value="UNTIL_TIMESTAMP">{{ t('approval.grant.kind.until') }}</option>
</el-form-item> <option value="UNTIL_CONVERSATION_END">{{ t('approval.grant.kind.conversationEnd') }}</option>
</el-form> </select>
</div>
<template #footer> <div v-if="form.grantKind === 'UNTIL_TIMESTAMP'" class="form-group">
<el-button @click="dialogOpen = false">{{ t('common.cancel') }}</el-button> <label>{{ t('approval.grant.form.expireAt') }} <span class="required">*</span></label>
<el-button type="primary" :loading="creating" @click="submitCreate"> <input
{{ t('common.confirm') }} v-model="form.expireAt"
</el-button> type="datetime-local"
</template> class="form-input"
</el-dialog> />
</div>
<div class="form-group form-group--full">
<label>{{ t('approval.grant.form.note') }}</label>
<input
v-model.trim="form.note"
class="form-input"
:placeholder="dialogWorkspaceWide ? '请说明为什么需要全工具白名单' : ''"
/>
</div>
<div v-if="requiresPassword" class="form-group form-group--full">
<label>
{{ t('approval.grant.form.password') }}
<span class="required">*</span>
</label>
<div class="password-wrap">
<el-icon :size="14" class="password-wrap__icon"><Lock /></el-icon>
<input
v-model="form.password"
type="password"
class="form-input form-input--with-icon"
autocomplete="current-password"
/>
</div>
</div>
</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>
</Teleport>
</div> </div>
</template> </template>
@ -268,6 +309,7 @@ import {
Plus, Plus,
Refresh, Refresh,
Unlock, Unlock,
WarningFilled,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { approvalApi } from '@/api' import { approvalApi } from '@/api'
import type { import type {
@ -559,11 +601,145 @@ onMounted(loadGrants)
justify-content: flex-end; justify-content: flex-end;
} }
.warning-banner { /* ─── Modal (mirrors ToolGuard / edit-rule pattern) ──────────────────── */
margin-bottom: 16px; .modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 24px;
}
.modal {
background: var(--mc-surface-primary, #fff);
border-radius: 12px;
width: min(640px, 100%);
max-height: 88vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--mc-border-light, #e5e7eb);
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary, #0f172a);
display: inline-flex;
align-items: center;
gap: 8px;
}
.modal-header__icon {
color: var(--mc-danger, #b91c1c);
}
.modal-close {
background: none;
border: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--mc-text-tertiary, #94a3b8);
}
.modal-close:hover { color: var(--mc-text-primary, #0f172a); }
.modal-body {
padding: 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;
} }
.grant-form { /* Danger banner inside the workspace-wide create modal bright red so the
padding-top: 4px; destructive variant looks instantly different from the routine flow. */
.danger-banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #991b1b;
font-size: 13px;
line-height: 1.5;
}
.danger-banner .el-icon { flex-shrink: 0; margin-top: 1px; }
/* Form grid layout two columns on wide modal, one column when narrow.
form-group--full breaks across both columns (note, password). */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group--full { grid-column: 1 / -1; }
.form-group label {
font-size: 13px;
font-weight: 500;
color: var(--mc-text-secondary, #475569);
}
.form-group label .required {
color: var(--mc-danger, #b91c1c);
margin-left: 2px;
}
.form-input {
padding: 8px 10px;
border: 1px solid var(--mc-border-light, #e5e7eb);
border-radius: 6px;
background: var(--mc-surface-primary, #fff);
color: var(--mc-text-primary, #0f172a);
font-size: 13px;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: var(--mc-primary, #d97757);
}
.form-input:disabled {
background: var(--mc-surface-tertiary, #f1f5f9);
cursor: not-allowed;
}
.form-input.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
/* Password input with the Lock icon as a visual prefix. */
.password-wrap {
position: relative;
display: flex;
align-items: center;
}
.password-wrap__icon {
position: absolute;
left: 10px;
color: var(--mc-text-tertiary, #94a3b8);
pointer-events: none;
}
.form-input--with-icon { padding-left: 32px; }
@media (max-width: 560px) {
.form-grid { grid-template-columns: 1fr; }
} }
</style> </style>