优化: 前端批(审批体系一致 + 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:
lxy
2026-08-02 12:42:52 +08:00
parent ffb202e2e9
commit caaabf0c15
19 changed files with 654 additions and 335 deletions
+159 -6
View File
@@ -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);