优化: 前端批(审批体系一致 + GitChanges UI + token/控件统一 + 死代码)
审批体系一致:ApprovalPopup 浮窗加挂起时长提示(H1)+session 按钮+path deny 二次确认(M4/M5);ToolCard/useToolApproval 加 session+onDeny 确认(对齐 DirAuthDialog 四按钮)+ToolCardList decision 类型加 session;ApprovalDialog 失败显 toast(治 store 吞错 M31);ApprovalOverlay dismiss 改响应式(L26) GitChanges UI(问题12 前端 12b-12h):showCommitFileDiff 精确匹配(12b)+去重 git log(12c)+详情滑出不挤压(12d)+双行提交行(12f)+搜索过滤(12g)+历史 Tab 显分支(12h)+diff 侧边栏内更宽可折叠面板(12e:FilePreview 不支持任意提交 diff,折中) token+控件:global.css 定义 --df-primary(治幽灵变量 M26)+settings.css 加 .setting-number/.setting-input/.setting-textarea+4 Section 套用(问题8) 死代码:删 PlanProgress.vue(零引用)+PlanLayerInfo 类型+i18n 孤儿 key(L1)
This commit is contained in:
+159
-6
@@ -52,11 +52,17 @@
|
||||
</div>
|
||||
<!-- 风险说明(后端 build_approval_reason 已含工具/参数/risk 描述) -->
|
||||
<div v-if="tc.reason" class="popup-item-reason">⚠ {{ tc.reason }}</div>
|
||||
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
|
||||
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
|
||||
基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 -->
|
||||
<div v-if="waitSecsFor(tc.id) > 0" class="popup-item-wait" :class="'is-' + waitLevelFor(tc.id)">
|
||||
{{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecsFor(tc.id)) }) }}
|
||||
</div>
|
||||
<!-- path 类:目录提示 -->
|
||||
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
|
||||
📁 {{ tc.dir }}
|
||||
</div>
|
||||
<!-- 操作按钮组:path 类显 once/always/deny 三选项(与 ToolCard 同款,补 always);
|
||||
<!-- 操作按钮组:path 类显 once/session/always/deny 四选项(与 ToolCard/DirAuthDialog 同款);
|
||||
risk 类显 approve/reject 两选项。IPC 分派统一走 dispatchApprovalIPC。 -->
|
||||
<div v-if="tc.kind === 'path'" class="popup-item-actions popup-item-actions--path">
|
||||
<button
|
||||
@@ -68,6 +74,14 @@
|
||||
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthOnce') }}
|
||||
</button>
|
||||
<button
|
||||
class="popup-btn popup-btn--session"
|
||||
:disabled="processingId === tc.id"
|
||||
@click.stop="onApproveSession(tc)"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthSession') }}
|
||||
</button>
|
||||
<button
|
||||
class="popup-btn popup-btn--always"
|
||||
:disabled="processingId === tc.id"
|
||||
@@ -114,18 +128,31 @@
|
||||
</svg>
|
||||
{{ $t('aiChat.popupOpenPanel') }}
|
||||
</button>
|
||||
|
||||
<!-- M5:path 类 deny 二次确认(拒绝不可逆,与 ToolCard 同款;独立窗口本地 ConfirmDialog,
|
||||
不复用主窗口 useConfirm reactive——跨 WebviewWindow JS 上下文隔离) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="$t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
||||
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
|
||||
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** 主窗口推送的快照(唯一真相源,本组件不监听 AI 事件) */
|
||||
const approvals = ref<AiToolCallInfo[]>([])
|
||||
/** 操作中 id(防抖 + 按钮加载态) */
|
||||
@@ -133,6 +160,81 @@ const processingId = ref<string>('')
|
||||
/** 拖动态(样式反馈) */
|
||||
const isDragging = ref(false)
|
||||
|
||||
// ── M5:path 类 deny 二次确认(独立窗口本地 confirm;拒绝不可逆——工具返 Err、loop 续跑不再重试)──
|
||||
// 浮窗是独立 WebviewWindow,与主窗口 JS 上下文隔离,不复用 useConfirm 跨窗口 reactive。
|
||||
// pendingDenyTc 持有「待确认拒绝」的 tc;answerConfirm 据 result 决定续跑 runApproval 或放弃。
|
||||
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null }>({
|
||||
visible: false,
|
||||
msg: '',
|
||||
tc: null,
|
||||
})
|
||||
function answerConfirm(ok: boolean) {
|
||||
const tc = confirmState.tc
|
||||
confirmState.visible = false
|
||||
confirmState.tc = null
|
||||
if (ok && tc) {
|
||||
void runApproval(tc, false, 'deny')
|
||||
}
|
||||
}
|
||||
|
||||
// ── H1:审批挂起时长计时(对齐 ToolCard useToolApproval 的 waitSecs/waitLevel)──
|
||||
// 浮窗独立于主窗口,不直接复用 useToolApproval(那个绑定单 tc + 完整状态机 emit/防抖,
|
||||
// 浮窗已用 processingId/runApproval 自管)。这里基于「到达时间」(popup 首次见到 tc.id 的时刻)
|
||||
// 每秒刷新。语义/样式/阈值与 ToolCard 同款:120s long / 300s urgent。
|
||||
/** tc.id → 到达时刻(ms)。审批首次出现在 approvals 即记时;移除即清。 */
|
||||
const arrivalTimes = reactive(new Map<string, number>())
|
||||
/** tc.id → 已等待秒数(每秒 tick 更新)。 */
|
||||
const waitSecsMap = reactive(new Map<string, number>())
|
||||
let _waitTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 格式化秒为 MM:SS(与 useToolApproval.formatWaitTime 同款) */
|
||||
function formatWaitTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
/** 取某条审批已等待秒数(无记录返回 0) */
|
||||
function waitSecsFor(id: string): number {
|
||||
return waitSecsMap.get(id) ?? 0
|
||||
}
|
||||
/** 取等待级别(对齐 ToolCard: 300s urgent / 120s long / 其余 normal) */
|
||||
function waitLevelFor(id: string): 'normal' | 'long' | 'urgent' {
|
||||
const s = waitSecsFor(id)
|
||||
if (s >= 300) return 'urgent'
|
||||
if (s >= 120) return 'long'
|
||||
return 'normal'
|
||||
}
|
||||
/** 每秒刷新所有挂起审批的 waitSecs(单 timer 驱动多 tc,避免每条一个 interval) */
|
||||
function _tickWaitSecs() {
|
||||
const now = Date.now()
|
||||
for (const [id, t0] of arrivalTimes.entries()) {
|
||||
waitSecsMap.set(id, Math.floor((now - t0) / 1000))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步到达时间到当前 approvals 快照。
|
||||
* - 新出现的 id(popup 首次见到该审批)→ 记 Date.now() 起算。
|
||||
* - 已不在快照的 id(被处理 / 后端清掉)→ 清 arrival + waitSecs,防 Map 无界增长 + 防幽灵计时。
|
||||
* - 已记录的 id 保留原时刻(快照重推不影响计时,审批真实挂起时长不被重置)。
|
||||
*/
|
||||
function _syncArrivalTimes(list: AiToolCallInfo[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const tc of list) {
|
||||
seen.add(tc.id)
|
||||
if (!arrivalTimes.has(tc.id)) {
|
||||
arrivalTimes.set(tc.id, Date.now())
|
||||
waitSecsMap.set(tc.id, 0)
|
||||
}
|
||||
}
|
||||
for (const id of Array.from(arrivalTimes.keys())) {
|
||||
if (!seen.has(id)) {
|
||||
arrivalTimes.delete(id)
|
||||
waitSecsMap.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _unlistenUpdate: UnlistenFn | null = null
|
||||
let _positioned = false
|
||||
/** 关窗兜底:防止 close() promise 卡住或被拒导致「正在关闭…」永久停留。
|
||||
@@ -176,7 +278,10 @@ onMounted(async () => {
|
||||
_unlistenUpdate = await listen<{ approvals: AiToolCallInfo[] }>(
|
||||
ApprovalPopupEvents.EVT_UPDATE,
|
||||
(e) => {
|
||||
approvals.value = e.payload?.approvals || []
|
||||
const next = e.payload?.approvals || []
|
||||
approvals.value = next
|
||||
// H1:同步到达时间(新 id 记时;消失 id 清时 + 清秒数,避免 Map 无界增长)
|
||||
_syncArrivalTimes(next)
|
||||
// BUG2 修复:主窗口推空快照(所有 pending 已清)→ 立即关浮窗,避免停留在空态转圈
|
||||
void maybeAutoClose()
|
||||
},
|
||||
@@ -195,6 +300,12 @@ onMounted(async () => {
|
||||
// Esc 快捷键关浮窗(键盘可达性:无需鼠标即可关)。Tab/Enter 原生 button 已支持,不另加。
|
||||
// _onKeyDown 是模块级引用,onBeforeUnmount 据此 removeEventListener(保证移除的是同一引用)
|
||||
window.addEventListener('keydown', _onKeyDown)
|
||||
|
||||
// H1:启动挂起时长计时(每秒刷新 waitSecsMap)。单 timer 驱动所有 tc,
|
||||
// onBeforeUnmount 清理。立即 tick 一次让首屏(若已有 approvals)马上显示。
|
||||
_tickWaitSecs()
|
||||
if (_waitTimer) clearInterval(_waitTimer)
|
||||
_waitTimer = setInterval(_tickWaitSecs, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -202,6 +313,8 @@ onBeforeUnmount(() => {
|
||||
_unlistenUpdate = null
|
||||
// 清 Esc listener(防组件卸载后仍监听 window 致内存泄漏/重复触发)
|
||||
window.removeEventListener('keydown', _onKeyDown)
|
||||
// H1:清挂起计时器(防卸载后仍 tick 致空 Map 操作/内存泄漏)
|
||||
if (_waitTimer) { clearInterval(_waitTimer); _waitTimer = null }
|
||||
if (_closeFallbackTimer) {
|
||||
clearTimeout(_closeFallbackTimer)
|
||||
_closeFallbackTimer = null
|
||||
@@ -251,6 +364,10 @@ async function closeWithFallback(): Promise<void> {
|
||||
async function onApproveOnce(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, false, 'once')
|
||||
}
|
||||
/** M4:path 类:当前会话授权(decision='session',对齐 ToolCard/DirAuthDialog 四按钮) */
|
||||
async function onApproveSession(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, false, 'session')
|
||||
}
|
||||
/** path 类:写持久白名单(decision='always') */
|
||||
async function onApproveAlways(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, false, 'always')
|
||||
@@ -259,15 +376,22 @@ async function onApproveAlways(tc: AiToolCallInfo) {
|
||||
async function onApprove(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, true)
|
||||
}
|
||||
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派) */
|
||||
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
|
||||
* M5:path 类拒绝前二次确认(拒绝不可逆);risk reject 直接执行。 */
|
||||
async function onReject(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, false, tc.kind === 'path' ? 'deny' : undefined)
|
||||
if (tc.kind === 'path') {
|
||||
confirmState.msg = t('aiChat.dirAuthDenyConfirm')
|
||||
confirmState.tc = tc
|
||||
confirmState.visible = true
|
||||
return
|
||||
}
|
||||
await runApproval(tc, false, undefined)
|
||||
}
|
||||
|
||||
/** 审批执行统一壳:防抖(processingId)+ 调共享 IPC 分派 + 乐观移除 + 空列表自动关浮窗。
|
||||
* IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC,
|
||||
* 与 useAiApproval.approveToolCall 同源,消除「同语义两套逻辑」漂移。 */
|
||||
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'always' | 'deny') {
|
||||
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
|
||||
if (processingId.value === tc.id) return
|
||||
processingId.value = tc.id
|
||||
try {
|
||||
@@ -515,6 +639,27 @@ function onDragEnd() { isDragging.value = false }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* H1:审批挂起时长提示(对齐 ToolCard .ai-tool-wait-time)。
|
||||
>2min 橙色脉冲 / >5min 红色更急脉冲;pulseGlow 复用 styles/global.css 全局 keyframes。
|
||||
用户主要从浮窗审批,后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝前给可见预警。 */
|
||||
.popup-item-wait {
|
||||
padding: 2px 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-dim, #888);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
background: var(--df-bg-card, transparent);
|
||||
}
|
||||
.popup-item-wait.is-long {
|
||||
color: var(--df-warning, #d97706);
|
||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
.popup-item-wait.is-urgent {
|
||||
color: var(--df-danger, #e5484d);
|
||||
animation: pulseGlow 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.popup-item-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -545,6 +690,14 @@ function onDragEnd() { isDragging.value = false }
|
||||
.popup-btn--approve:hover:not(:disabled) {
|
||||
background: var(--df-accent-hover, #6558d8);
|
||||
}
|
||||
/* M4:session 按钮(弱填充态,介于 approve 强填充与 always 描边之间,视觉层级 once>session>always) */
|
||||
.popup-btn--session {
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.popup-btn--session:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||
}
|
||||
.popup-btn--always {
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
|
||||
Reference in New Issue
Block a user