feat(ui): scope-typed pickers and severity-ceiling hints on the auto-approve grant form

This commit is contained in:
matevip 2026-07-23 11:45:42 +08:00
parent 1bb81234d0
commit aa54058e31
3 changed files with 144 additions and 8 deletions

View File

@ -4408,6 +4408,17 @@ export default {
expireAt: 'Expire at',
note: 'Note',
password: 'Login password (required for sensitive rules)',
workspaceHint: 'This grant is created in workspace {name} and only matches conversations belonging to it. IM-channel conversations (WeCom / DingTalk / …) follow the channel\'s workspace binding, not the console selection.',
severityHint: 'Only findings with severity ≤ the ceiling are auto-approved; CRITICAL always requires human approval.',
severityLow: 'auto-approve LOW findings only',
severityMedium: 'auto-approve LOW/MEDIUM findings',
severityHigh: 'auto-approve LOW/MEDIUM/HIGH findings',
scopeIdPickAgent: 'Pick an agent…',
scopeIdWorkspaceHint: 'Pick the workspace this grant applies to (must be the workspace the conversation actually belongs to).',
scopeIdAgentHint: 'Applies to every conversation of the selected agent within this workspace.',
scopeIdUserHint: 'USER-scope grants can only target the currently logged-in user.',
scopeIdConversationHint: 'Conversation id, as shown in the audit log "session" column (e.g. wecom:xxx).',
scopeIdConversationPlaceholder: 'e.g. wecom:xxx or a conversation id',
},
},
resolution: {

View File

@ -4500,6 +4500,17 @@ export default {
expireAt: '过期时间',
note: '备注',
password: '登录密码(敏感策略需要)',
workspaceHint: '策略将创建在工作区 {name}只对该工作区内的会话生效。IM 渠道(企微/钉钉等)会话所属工作区由渠道配置决定,与控制台当前选择无关。',
severityHint: '只自动放行严重度 ≤ 上限的命中CRITICAL 永远需要人工审批。',
severityLow: '仅放行低危命中',
severityMedium: '放行低/中危命中',
severityHigh: '放行低/中/高危命中',
scopeIdPickAgent: '选择智能体…',
scopeIdWorkspaceHint: '选择策略生效的工作区(必须与会话实际所属工作区一致)。',
scopeIdAgentHint: '选择智能体后,该智能体在本工作区的所有会话均适用。',
scopeIdUserHint: '只能为当前登录用户创建 USER 范围策略。',
scopeIdConversationHint: '填写会话 ID可在审计日志的"会话"列查看(如 wecom:xxx。',
scopeIdConversationPlaceholder: '如 wecom:xxx 或会话 ID',
},
},
resolution: {

View File

@ -181,6 +181,14 @@
<el-icon :size="16"><WarningFilled /></el-icon>
<span>{{ t('approval.grant.createWorkspaceWarning') }}</span>
</div>
<!-- The grant is tenant-bound to the console's current workspace and
only matches conversations that actually live in it IM-channel
conversations follow the channel's workspace binding, not the
console selection. Spelling this out up front prevents the
"configured but never fires" dead-grant trap. -->
<div class="info-banner">
{{ t('approval.grant.form.workspaceHint', { name: currentWorkspaceLabel }) }}
</div>
<div class="form-grid">
<div class="form-group">
<label>{{ t('approval.grant.form.scopeType') }} <span class="required">*</span></label>
@ -197,14 +205,51 @@
</div>
<div class="form-group">
<label>{{ t('approval.grant.form.scopeId') }} <span class="required">*</span></label>
<!-- Scope-typed pickers: free-text snowflake input made it far too
easy to paste the wrong kind of id (e.g. a workspace id into an
AGENT-scope grant), producing a grant that never matches. -->
<select
v-if="form.scopeType === 'WORKSPACE'"
v-model="form.scopeId"
class="form-input"
>
<option
v-for="ws in workspaceStore.workspaces"
:key="String(ws.id)"
:value="String(ws.id)"
>
{{ ws.name }} (#{{ ws.id }})
</option>
</select>
<select
v-else-if="form.scopeType === 'AGENT'"
v-model="form.scopeId"
class="form-input"
>
<option value="" disabled>{{ t('approval.grant.form.scopeIdPickAgent') }}</option>
<option
v-for="a in agentStore.agents"
:key="String(a.id)"
:value="String(a.id)"
>
{{ a.name }} (#{{ a.id }})
</option>
</select>
<input
v-else-if="form.scopeType === 'USER'"
:value="form.scopeId"
type="text"
class="form-input"
disabled
/>
<input
v-else
v-model.trim="form.scopeId"
type="text"
inputmode="numeric"
pattern="\d*"
placeholder="snowflake id"
class="form-input"
class="form-input mono"
:placeholder="t('approval.grant.form.scopeIdConversationPlaceholder')"
/>
<p v-if="scopeIdHint" class="field-hint">{{ scopeIdHint }}</p>
</div>
<div class="form-group">
<label>{{ t('approval.grant.form.toolName') }}</label>
@ -226,10 +271,11 @@
<div class="form-group">
<label>{{ t('approval.grant.form.maxSeverity') }}</label>
<select v-model="form.maxSeverity" class="form-input">
<option value="LOW">LOW</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH</option>
<option value="LOW">LOW {{ t('approval.grant.form.severityLow') }}</option>
<option value="MEDIUM">MEDIUM {{ t('approval.grant.form.severityMedium') }}</option>
<option value="HIGH">HIGH {{ t('approval.grant.form.severityHigh') }}</option>
</select>
<p class="field-hint">{{ t('approval.grant.form.severityHint') }}</p>
</div>
<div class="form-group">
<label>{{ t('approval.grant.form.grantKind') }}</label>
@ -293,7 +339,7 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus/es/components/message/index'
import { ref, computed, onMounted, reactive } from 'vue'
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Delete,
@ -305,6 +351,8 @@ import {
WarningFilled,
} from '@element-plus/icons-vue'
import { approvalApi } from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { useAgentStore } from '@/stores/useAgentStore'
import McPagination from '@/components/common/McPagination.vue'
import { mcConfirm } from '@/components/common/useConfirm'
import type {
@ -317,6 +365,9 @@ import type {
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const agentStore = useAgentStore()
const rows = ref<ApprovalGrant[]>([])
const total = ref(0)
// Active grants count for the summary pill separate from `total` because the
@ -364,6 +415,44 @@ const requiresPassword = computed(() => {
return noTool && (form.scopeType === 'WORKSPACE' || form.scopeType === 'AGENT')
})
const currentWorkspaceLabel = computed(() => {
const ws = workspaceStore.currentWorkspace
return ws ? `${ws.name} (#${ws.id})` : String(workspaceStore.currentWorkspaceId ?? '')
})
const scopeIdHint = computed(() => {
switch (form.scopeType) {
case 'WORKSPACE': return t('approval.grant.form.scopeIdWorkspaceHint')
case 'AGENT': return t('approval.grant.form.scopeIdAgentHint')
case 'USER': return t('approval.grant.form.scopeIdUserHint')
case 'CONVERSATION': return t('approval.grant.form.scopeIdConversationHint')
default: return ''
}
})
/**
* Prefill scopeId whenever the scope type changes: WORKSPACE defaults to the
* console's current workspace, USER is locked to the requesting user (the
* backend rejects anything else), AGENT/CONVERSATION start empty for an
* explicit pick.
*/
function prefillScopeId() {
switch (form.scopeType) {
case 'WORKSPACE':
form.scopeId = String(workspaceStore.currentWorkspaceId ?? '')
break
case 'USER':
form.scopeId = localStorage.getItem('userId') || ''
break
default:
form.scopeId = ''
}
}
watch(() => form.scopeType, () => {
if (dialogOpen.value) prefillScopeId()
})
function onPageChange(p: number) {
currentPage.value = p
loadGrants()
@ -425,6 +514,10 @@ function openCreateDialog(workspaceWide: boolean) {
dialogWorkspaceWide.value = false
}
dialogOpen.value = true
prefillScopeId()
// Lazy-load picker data sources; both are cheap and cached in their stores.
if (!workspaceStore.workspaces.length) workspaceStore.fetchWorkspaces()
if (!agentStore.agents.length) agentStore.fetchAgents()
}
async function submitCreate() {
@ -783,6 +876,27 @@ onMounted(loadGrants)
}
.danger-banner .el-icon { flex-shrink: 0; margin-top: 1px; }
/* Neutral info banner states which workspace the grant will be created in.
Deliberately calmer than .danger-banner: informational, not a warning. */
.info-banner {
padding: 10px 14px;
margin-bottom: 16px;
background: var(--mc-surface-tertiary, #f1f5f9);
border: 1px solid var(--mc-border-light, #e5e7eb);
border-radius: 8px;
color: var(--mc-text-secondary, #475569);
font-size: 12.5px;
line-height: 1.55;
}
/* Per-field helper line under a control (scope-id semantics, severity ceiling). */
.field-hint {
margin: 2px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--mc-text-tertiary, #94a3b8);
}
/* Form grid layout two columns on wide modal, one column when narrow.
form-group--full breaks across both columns (note, password). */
.form-grid {