新增: 审批挂起时长提示(BUG-260624-02 方案 C)

背景:AI 调用需审批的工具后,ToolCard 弹审批卡常驻直到用户批/拒,
用户走开忘了批则 AI loop 卡死,且卡片无任何时长提示。

方案选型(A/B/C 三选一,选 C):
  A 不做 - 挂起审批容易被遗忘
  B 加超时+倒计时 - 超时后行为难定义(自动拒丢改动/自动批危险)
  C 显示挂起时长(本方案) - 纯前端展示,不动后端,不改审批行为

实现:
- ToolCard pending_approval 态显示已等待 mm:ss
- >2分钟橙色脉冲,>5分钟红色脉冲
- 注释中标注方案选型理由,便于后人理解决策上下文
This commit is contained in:
2026-06-28 00:39:18 +08:00
parent cf223fc08b
commit 22362a77b8
2 changed files with 64 additions and 1 deletions

View File

@@ -65,7 +65,7 @@ graph TD
| **G1** 目标钉扎 | 🔨 代码✅`8ce18cb` 待实测 | G1/G2/G4 落地 | — |
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) |
| **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — |
| **父③** AI对话体验 | 🔨 ③.1✅ / ③.2待办 | ③.1 B-260619-04 ToolCard✅(跨轮收起已有+标题增强) / ③.2 REFACTOR-260619-04 审批状态机拆分 | — |
| **父④** F-09 per-conv | ✅ 前端per-conv | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
| **父⑥** Phase2-5 | 🔨 Phase4 注入进行中 | ⑥.1事件流✅(V30) / ⑥.2基础设施✅(V31) / ⑥.3注入✅(resolver 增强+extra 渲染) / ⑥.4前端📋

View File

@@ -40,6 +40,10 @@
</div>
</div>
<div v-if="tc.reason" class="ai-tool-approval-reason"> {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) -->
<div v-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
已等待 {{ formatWaitTime(waitSecs) }}
</div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)旧文件不存在tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
@@ -176,6 +180,45 @@ const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── 审批挂起时长显示(BUG-260624-02) ──
// 背景:AI 调用需审批的工具(patch_file/write_file 等)后,ToolCard 弹审批卡常驻
// 直到用户批/拒。用户可能走开忘了批,卡片无任何时长提示。
// 方案选型(A/B/C 三选一,选 C):
// A 不做(删 todo)——挂起审批容易被遗忘,AI loop 卡死
// B 加超时+倒计时——后端加 timer + 超时后行为难定义(自动拒=丢改动/自动批=危险)
// C 显示挂起时长(本方案)——纯前端展示,不动后端,不改审批行为
// 实现:进入 pending_approval 时记录起始时刻,每秒刷新 waitSecs。
// >2分钟变橙色,>5分钟变红色,提示用户尽快审批。
const pendingSince = ref<number | null>(null)
const waitSecs = ref(0)
let waitTimer: ReturnType<typeof setInterval> | null = null
function startPendingTimer() {
if (pendingSince.value !== null) return
pendingSince.value = Date.now()
waitSecs.value = 0
if (waitTimer) clearInterval(waitTimer)
waitTimer = setInterval(() => {
if (pendingSince.value !== null) {
waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000)
}
}, 1000)
}
function stopPendingTimer() {
if (waitTimer) { clearInterval(waitTimer); waitTimer = null }
pendingSince.value = null
waitSecs.value = 0
}
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')}`
}
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
if (waitSecs.value >= 300) return 'urgent' // 5分钟
if (waitSecs.value >= 120) return 'long' // 2分钟
return 'normal'
})
// ── 审批超时兜底 toast(本卡自管,分离窗口运行时 App 根 toast 不在 DOM) ──
const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
@@ -246,12 +289,16 @@ watch(() => props.tc.status, (s) => {
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
stopPendingTimer()
} else {
startPendingTimer()
}
}, { immediate: true })
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
stopPendingTimer()
})
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
@@ -380,6 +427,22 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
border-radius: var(--df-radius-sm, 4px);
}
/* BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
.ai-tool-wait-time {
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
font-family: var(--df-font-mono);
color: var(--df-text-dim, #888);
border-radius: var(--df-radius-sm, 4px);
}
.ai-tool-wait-time.is-long {
color: var(--df-warning, #d97706);
animation: pulseGlow 1.5s ease-in-out infinite;
}
.ai-tool-wait-time.is-urgent {
color: var(--df-danger, #e5484d);
animation: pulseGlow 1s ease-in-out infinite;
}
/* AE-2025-03: write_file 审批 diff 预览区(红删绿增行级) */
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
.ai-tool-diff-pre {