Files
DevFlow/src/components/ToolCardList.vue
T
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00

550 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="ai-tool-calls" ref="rootEl">
<!-- 顶部操作栏:批量审批(轨迹卡 toggle 已覆盖整体展开/收起,分组行仅单组导航) -->
<div v-if="pendingCount > 0" class="ai-tool-topbar">
<!-- 批量操作栏(存在待审批项时显示) -->
<div class="ai-batch-approve">
<!-- 批量批准含 High 风险工具时前置二次确认(与单卡 onApprove 同门槛;
纯低风险批量保持一键直批) -->
<button class="ai-btn ai-btn--sm" :disabled="batchProcessing" @click="onBatchApprove">
<span v-if="batchProcessing" class="ai-btn-spinner" />
{{ $t('aiTool.approveAll', { n: pendingCount }) }}
</button>
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
{{ $t('aiTool.rejectAll') }}
</button>
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果(wave2 迁 .df-badge danger) -->
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint df-badge df-badge--danger">
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
</span>
</div>
</div>
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
<!-- 分组标题:同名工具 >=2 个时显示(仅分组导航语义:点开逐条。
调用统计收敛到执行轨迹卡头部一处,分组行不再重复计数) -->
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(wave2 迁 .df-badge danger) -->
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail df-badge df-badge--danger">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
</div>
<ToolCard
v-for="tc in group.calls"
:key="tc.id"
:tc="tc"
:isExpanded="isCardExpanded(tc, gi)"
:isContentExpanded="expandedTools.has(tc.id)"
@toggle="() => toggleCardExpand(tc, gi)"
@expand-content="toggleExpand"
@approve="(e) => emit('approve', e)"
@install-obscura="() => emit('install-obscura')"
:class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
/>
</template>
<!-- 批量批准 High 风险二次确认(本组件自治 useConfirm,不与父级串台) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="t('common.confirm')"
@result="answerConfirm"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiToolCallInfo } from '@/api/types'
import { useConfirm } from '@/composables/useConfirm'
import { toolRiskLevel } from '@/composables/ai/useToolApproval'
import { isToolFailure } from '@/composables/ai/useToolCard'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
const { t } = useI18n()
const props = defineProps<{
/** 当前消息的工具调用列表 */
toolCalls: AiToolCallInfo[]
}>()
const emit = defineEmits<{
/**
* 审批按钮 → 父组件转发 store.approveToolCall。
* path_auth 审批链阶段3b:path 类带 decision('once'|'session'|'always'|'deny'),risk 类仅 approved boolean。
*/
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
/** 批量审批 → 父组件调用 approveAll/rejectAll */
'batch-approve': [decision: 'approve' | 'reject']
/**
* fetch_url static_fallback(obscura 未装)时"AI 帮装"按钮 → 透传父级 MessageList,
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
*/
'install-obscura': []
/**
* 审批卡到达但用户不在底部附近(上滚读历史中):不强拉滚动,
* 由父(MessageList)出「有新审批」轻提示,尊重用户阅读位置。
*/
'pending-out-of-view': []
}>()
// 根元素 ref(scrollToFirstPending 查 DOM 用)
const rootEl = ref<HTMLElement>()
// 卡片级折叠(整卡 body 显隐,区别于 read_file 内容级)
const expandedCards = ref(new Set<string>())
// 用户主动展开的卡片(记忆态,不被自动收起清除)。
// 与 expandedCards 区分:expandedCards 是"展开"语义的当前态(含默认首卡),
// userExpandedCards 是"用户明确点开"的持久标记,collapseInactive 跳过这些卡。
// 语义:用户读过的卡不强制折叠,符合"跟随阅读位置"的增强目标。
const userExpandedCards = ref(new Set<string>())
// 用户主动折叠记忆(与 userExpandedCards 对偶,记录显式收起过的卡)。
// isCardExpanded 最优先判断,覆盖用户展开记忆。
const userCollapsedCards = ref(new Set<string>())
// 内容级展开(read_file "展开全部")
const expandedTools = ref(new Set<string>())
// 分组折叠状态(按分组索引);多卡片分组默认收起
const collapsedGroups = ref<Set<number>>(new Set())
// 用户手动操作过折叠的组(按组名记忆)。流式期间组卡片数增长会反复触发默认收起补齐,
// 用户手动展开过的组不再被自动收回——名字比索引稳(分组只追加不重排,但语义上名字更直白)。
const userTouchedGroups = ref(new Set<string>())
// ── R7 组头失败徽标:折叠组内失败卡扫组头即可发现 ──
// 病灶(R7 单行折叠信息量缺口):工具失败信号散在单卡红边,同名工具组默认折叠后
// 失败卡被 display:none 压住,10+ 卡需逐组展开逐行扫。轨迹卡聚合行/执行中步骤行
// 已有「失败 N」计数,唯一盲区是组头。此处补:组内失败卡 ≥1 时组头显红徽标
// 「{n} 失败」,折叠展开都可见。失败判定复用 useToolCard.isToolFailure 单点(与
// 单卡红边/轨迹卡统计同源,防双写判定漂移)。
const groupFailCounts = computed<number[]>(() =>
groupedToolCalls.value.map(g => g.calls.reduce((n, tc) => n + (isToolFailure(tc) ? 1 : 0), 0)),
)
interface ToolCallGroup {
name: string
calls: AiToolCallInfo[]
}
/** 按 tool.name 全局分组:同名工具归为一组(不要求相邻),保持原始顺序 */
const groupedToolCalls = computed<ToolCallGroup[]>(() => {
const map = new Map<string, AiToolCallInfo[]>()
// 第一遍:按 name 收集
for (const tc of props.toolCalls) {
const arr = map.get(tc.name)
if (arr) arr.push(tc)
else map.set(tc.name, [tc])
}
// 第二遍:按 toolCalls 首次出现顺序输出(保证显示顺序与调用顺序一致)
const seen = new Set<string>()
const groups: ToolCallGroup[] = []
for (const tc of props.toolCalls) {
if (!seen.has(tc.name)) {
seen.add(tc.name)
groups.push({ name: tc.name, calls: map.get(tc.name)! })
}
}
return groups
})
/** 待审批工具数(status === 'pending_approval'),控制批量操作栏显隐 */
const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
)
// -- 批量批准 High 风险拦截(治安全缺口:原批量批准绕过单卡二次确认直接放行) --
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
/** 待审批中的 High 风险项数(徽标提示 + 确认门槛判定) */
const highRiskPendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval' && toolRiskLevel(tc.name) === 'high').length
)
/** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */
const batchProcessing = ref(false)
/**
* 批量批准入口:含 High 风险项 → 先二次确认(文案对齐单卡 highRiskConfirmText 语义,
* 列出将放行的高危工具数);确认后才向父级派发 batch-approve('approve')。
* 拒绝批量无破坏性,不拦。
*/
async function onBatchApprove() {
if (highRiskPendingCount.value > 0) {
const ok = await confirmDialog(
t('aiTool.batchHighRiskConfirm', { n: highRiskPendingCount.value }),
)
if (!ok) return
}
batchProcessing.value = true
emit('batch-approve', 'approve')
// emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位
const stop = watch(pendingCount, (n, old) => {
if (n === 0 || n < old) { batchProcessing.value = false; stop() }
}, { flush: 'post' })
// 兜底:pendingCount 因新增挂起不清零时 3s 后自动复位
setTimeout(() => { batchProcessing.value = false }, 3000)
}
/**
* 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
* 此后组从 1 卡长到 N 卡不再折叠,批量读取的 N 张单行卡全展开刷屏;
* 签名(组名:卡数)变化即幂等补齐,新达到阈值的组收起,已 touched 的组不动。
*
* 活组豁免:含 running/pending_approval 卡或用户展开过卡的组(groupHasLiveCard)
* 不自动收;已被收的活组自动复活展开。治"执行轮整组(读取文件/搜索内容)被收只剩组头,
* running 卡强制展开也被组 hidden 压住,用户想看进度要点两层"。
*/
watch(
() => groupedToolCalls.value.map(g => `${g.name}:${g.calls.length}`).join('|'),
() => {
const next = new Set(collapsedGroups.value)
groupedToolCalls.value.forEach((g, gi) => {
if (g.calls.length < 2 || userTouchedGroups.value.has(g.name)) return
if (groupHasLiveCard(g)) next.delete(gi)
else next.add(gi)
})
collapsedGroups.value = next
},
{ immediate: true }
)
/** 组内是否存在"活着"的卡:running/pending_approval 进行中 或 用户主动展开过(userExpandedCards)。
* 自动收起补齐对这类组跳过(不误收用户正在看的/正在执行的),
* 且补齐时自动展开复活——治"组从 1 卡长到 2 卡瞬间整组被收、用户点开也被压回去"。 */
function groupHasLiveCard(g: ToolCallGroup): boolean {
return g.calls.some(tc =>
tc.status === 'running'
|| tc.status === 'pending_approval'
|| userExpandedCards.value.has(tc.id)
)
}
function isGroupCollapsed(gi: number): boolean {
return collapsedGroups.value.has(gi)
}
function toggleGroupCollapse(gi: number): void {
// 手动操作记 touched:此后该组不被默认收起补齐自动收回
userTouchedGroups.value.add(groupedToolCalls.value[gi]?.name ?? String(gi))
const next = new Set(collapsedGroups.value)
if (next.has(gi)) next.delete(gi)
else next.add(gi)
collapsedGroups.value = next
}
/**
* 分组显示名:工具名 → i18n 友好标签(英文化,原硬编码中文)。
* 用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。
*/
// tool.name → aiTool.toolGroup.* 子键(与后端 tool_registry 工具名一一对应)
const TOOL_GROUP_I18N_KEY: Record<string, string> = {
read_file: 'readFile',
write_file: 'writeFile',
list_directory: 'listDirectory',
grep: 'grep',
create_task: 'createTask',
create_project: 'createProject',
create_idea: 'createIdea',
list_tasks: 'listTasks',
list_projects: 'listProjects',
list_ideas: 'listIdeas',
delete_project: 'deleteProject',
restore_project: 'restoreProject',
purge_project: 'purgeProject',
update_project: 'updateProject',
run_workflow: 'runWorkflow',
}
function groupDisplayName(group: ToolCallGroup): string {
const subKey = TOOL_GROUP_I18N_KEY[group.name]
if (subKey) return t(`aiTool.toolGroup.${subKey}`)
return group.name.replace(/_/g, ' ')
}
/**
* 单卡是否展开:
* - 分组已折叠 → false(整组隐藏)。**例外**: 用户主动展开过的卡(userExpandedCards)
* 不被组折叠压制()——组收起只隐藏"无用户记忆"的卡,
* 用户点开过的卡保持展开,否则"点开又被组补齐自动压回去"死循环。
* - 未手动操作过 → 默认折叠(U-260629):completed 卡一律单行 header(工具名+目标+结果摘要),
* 杜绝"AI 一轮多工具各铺开一整卡"刷屏;点 header 才展开。
* running/pending/rejected/write_file 不受此值影响——ToolCard 内部
* v-show="isExpanded || shouldKeepOpen(tc)" 强制展开 body。
* - userExpandedCards(用户主动展开记忆)优先,即使父级 collapseInactive
* 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠)。
*/
function isCardExpanded(tc: AiToolCallInfo, gi: number): boolean {
// 用户主动折叠最优先(覆盖一切,含组折叠与展开记忆)
if (userCollapsedCards.value.has(tc.id)) return false
// 用户主动展开记忆次优先:压过组折叠(),点开过的卡组收起时仍展开
if (userExpandedCards.value.has(tc.id)) return true
// 组折叠(只影响无用户记忆的卡)
if (isGroupCollapsed(gi)) return false
// 用户手动切换过该卡(当前态)
if (expandedCards.value.has(tc.id)) return true
// 兜底:默认折叠(单行 header 已有 toolDisplayName 路径/pattern/host + toolResultSummary 摘要)
return false
}
/** 组收起时卡片是否整卡隐藏(display:none)。
* 用户主动展开过的卡豁免——组收起只隐藏无用户记忆的卡,
* 否则模板 class 的 display:none 会把 isCardExpanded 已放行的展开卡重新藏掉。 */
function isGroupHidden(tc: AiToolCallInfo, group: ToolCallGroup, gi: number): boolean {
if (group.calls.length < 2 || !isGroupCollapsed(gi)) return false
return !userExpandedCards.value.has(tc.id)
}
function toggleCardExpand(tc: AiToolCallInfo, gi: number) {
const id = tc.id
// 用 isCardExpanded 判当前真实态(含用户操作记忆),而非 expandedCards(记忆态不在其中,
// 否则已展开卡点 toggle 会误判"当前折叠"反向展开)
const currentlyExpanded = isCardExpanded(tc, gi)
if (currentlyExpanded) {
// 展开 → 折叠
expandedCards.value.delete(id)
userExpandedCards.value.delete(id)
userCollapsedCards.value.add(id)
} else {
// 折叠 → 展开
expandedCards.value.add(id)
userExpandedCards.value.add(id)
userCollapsedCards.value.delete(id)
}
}
function toggleExpand(id: string) {
if (expandedTools.value.has(id)) expandedTools.value.delete(id)
else expandedTools.value.add(id)
}
/**
* 收起不在 activeIds 中的已完成/已拒绝卡片(供父组件 auto-collapse watch 调用)。
* userExpandedCards(用户主动展开记忆)不被清除——用户读过的卡
* 即使不再 active(running/pending/write_file)也保持展开,避免"我刚才在看,突然被收起"。
* 仅清 expandedCards(默认态/首卡)与 expandedTools(内容级展开,跟随卡片折叠)。
*/
function collapseInactive(activeIds: Set<string>): void {
// 对称过滤 expandedTools(原只清 expandedCards,expandedTools 残留致折叠后内容展开态)
if (expandedCards.value.size > 0) {
expandedCards.value = new Set([...expandedCards.value].filter(id => activeIds.has(id)))
}
if (expandedTools.value.size > 0) {
expandedTools.value = new Set([...expandedTools.value].filter(id => activeIds.has(id)))
}
// 不清 userExpandedCards(记忆态),isCardExpanded 优先读它保持展开。
}
/**
* 滚动到第一个 pending_approval 卡片。
* 若该卡处于折叠分组(display:none → offsetParent 为 null),先展开所有折叠组,
* 下一帧重查并滚动。供 AiChat 头部审批徽标点击调用。
*/
function scrollToFirstPending(): void {
const root = rootEl.value
if (!root) return
const card = root.querySelector<HTMLElement>('.ai-tool-card--pending_approval')
if (!card) return
if (card.offsetParent === null) {
// 处于折叠分组内 → 展开后下一帧重查。
// 展开组记 touched:审批卡可见性优先于默认收起,否则流式新卡一触发补齐,
// 刚展开的含 pending 卡的组又被收回,BUG-260624-02(审批不可见)复发。
groupedToolCalls.value.forEach(g => {
if (g.calls.length >= 2) userTouchedGroups.value.add(g.name)
})
collapsedGroups.value = new Set()
nextTick(() => {
root.querySelector<HTMLElement>('.ai-tool-card--pending_approval')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
return
}
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):监听 pending 审批卡到达事件,
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
let _unlistenPending: UnlistenFn | null = null
// 论证 workflow 回归中优:防卸载竞态——await listen 解析前组件已卸载(HMR/快速切会话)时,
// onBeforeUnmount 跑到 _unlistenPending 仍 null 跳过 unlisten → 孤儿监听器泄漏。disposed 标志兜底。
let _disposed = false
/** 距滚动容器底部 <150px 视为"在底部附近",审批卡到达才跟随滚动(宽于 useMessageScroll 的 80px 在底判定) */
const PENDING_FOLLOW_PX = 150
/** 轻提示去重阈值:审批卡短时间连发多条时 5s 内只提示一次 */
const PENDING_HINT_INTERVAL_MS = 5000
// 模块级(跨实例共享):ai-pending-arrived 广播所有 ToolCardList 实例都会收到,去重须超组件实例
let _lastPendingHintTs = 0
/** 滚动容器(.ai-messages,MessageList 持有)是否在底部附近 */
function isContainerNearBottom(el: Element): boolean {
const he = el as HTMLElement
return he.scrollHeight - he.scrollTop - he.clientHeight < PENDING_FOLLOW_PX
}
/** 用户不在底部时的轻提示:不拉滚,经父转发 toast(5s 去重防连发刷屏) */
function notifyPendingOutOfView(): void {
const now = Date.now()
if (now - _lastPendingHintTs < PENDING_HINT_INTERVAL_MS) return
_lastPendingHintTs = now
emit('pending-out-of-view')
}
onMounted(async () => {
const fn = await listen('ai-pending-arrived', () => {
// BUG-260624-01 残留优化:本实例无 pending 卡时短路返回,避免大列表 N 个 ToolCardList 实例
// 每次广播都跑 querySelector(N 实例 × M 次 emit = N×M 次 DOM 查询)。原靠 scrollToFirstPending
// 内 querySelector 返 null no-op 天然过滤(功能正确),此处显式 props 过滤把无 pending 实例挡在
// 进入函数前。含 pending 的实例再走 nextTick + scrollToFirstPending(展开折叠组 + scroll)。
if (!props.toolCalls.some(tc => tc.status === 'pending_approval')) return
nextTick(() => {
// UX 走查-对话域 B1:尊重用户滚动意图。nextTick 等审批卡渲染完再测距底距离:
// 在底部附近(含本就贴底)才跟随滚到审批卡;用户上滚读历史时不强拉,改轻提示。
// 容器找不到(极端:分离窗口 DOM 层级变化)按可滚处理,保留原展开+滚动兜底。
const container = rootEl.value?.closest('.ai-messages')
if (!container || isContainerNearBottom(container)) {
scrollToFirstPending()
} else {
notifyPendingOutOfView()
}
})
})
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
if (_disposed) { fn(); return }
_unlistenPending = fn
})
onBeforeUnmount(() => {
_disposed = true
_unlistenPending?.()
_unlistenPending = null
})
defineExpose({ collapseInactive, scrollToFirstPending })
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */
function groupIcon(name: string): string {
if (name === 'read_file') return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'list_directory') return '<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
if (name.includes('create')) return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
}
</script>
<style scoped>
.ai-tool-calls {
display: flex;
flex-direction: column;
gap: 8px;
}
/* -- 顶部操作栏:批量审批 + 全局收起合并同行-- */
.ai-tool-topbar {
display: flex;
align-items: center;
gap: 8px;
}
/* -- 批量操作栏(wave2:白透明底残留改 token,浅色主题成立) -- */
.ai-batch-approve {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
}
/* 批量批准将二次确认的 High 风险项数徽标:配色由 df-badge--danger 接管,此处只留布局 */
.ai-batch-high-risk-hint {
white-space: nowrap;
}
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
.ai-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: var(--df-radius-sm);
background: var(--df-accent);
color: #fff;
font-family: var(--df-font-sans);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s, background 0.15s;
white-space: nowrap;
}
.ai-btn:hover { filter: brightness(1.1); }
.ai-btn--sm { padding: 4px 10px; font-size: 11px; }
.ai-btn--danger {
background: var(--df-danger);
color: #fff;
}
.ai-btn--danger:hover { filter: brightness(1.1); }
/* -- 分组标题 -- */
.ai-tool-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
/* max-width 对齐气泡/卡片(90%),不拉满 ai-msg-content 右侧 */
max-width: var(--df-msg-max-width);
cursor: pointer;
user-select: none;
border-radius: var(--df-radius-sm);
transition: background 0.15s var(--df-ease);
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-tool-group-header:hover {
background: var(--df-bg-card-hover);
}
/* R7 组头失败徽标:配色由 df-badge--danger 接管,此处只留布局(右对齐推到 chevron 前) */
.ai-tool-group-fail {
margin-left: auto;
margin-right: 2px;
font-weight: 600;
white-space: nowrap;
}
.ai-tool-group-icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--df-text-dim);
}
.ai-tool-group-name {
font-weight: 500;
color: var(--df-text-secondary);
}
.ai-tool-group-chevron {
margin-left: auto;
font-size: 10px;
color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease);
flex-shrink: 0;
}
.ai-tool-group-chevron.is-open {
transform: rotate(90deg);
}
/* -- 分组隐藏态的卡片。
保持 display:none(原行为)。
不改 max-height/opacity 动画的原因:flex 容器 gap:8px 对零高 item 仍贡献 gap,
会留双倍间距致布局回归;display:none 移出流才无 gap。
真正的高度动画需 <Transition> JS 钩子(高度 auto→0 不可纯 CSS transition),
本轮野人 lite 不重构 ToolCard 单卡级折叠(v-show 同因)。
触发时机扩展(新消息/新轮次)已落地,见 AiChat.vue watch。 -- */
.ai-tool-card--group-hidden {
display: none;
}
</style>