优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)
持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage
AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化
数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)
GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数
原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)
P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="ci-status" v-if="statuses.length > 0">
|
||||
<div class="ci-header">
|
||||
<span class="ci-title">CI 检查</span>
|
||||
<span class="ci-title">{{ $t('aiChat.ciTitle') }}</span>
|
||||
<span class="ci-summary" :class="summaryClass">{{ summaryText }}</span>
|
||||
</div>
|
||||
<div class="ci-list">
|
||||
@@ -23,8 +23,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface CheckStatus {
|
||||
name: string
|
||||
status: string
|
||||
@@ -50,9 +53,9 @@ const summaryText = computed(() => {
|
||||
const pass = statuses.value.filter(s => s.status === 'success').length
|
||||
const fail = statuses.value.filter(s => s.status === 'failure').length
|
||||
const pending = statuses.value.filter(s => s.status === 'pending').length
|
||||
if (fail > 0) return `${fail} 项失败`
|
||||
if (pending > 0) return `${pending} 项进行中`
|
||||
return `${pass} 项通过`
|
||||
if (fail > 0) return t('aiChat.ciNFailed', { n: fail })
|
||||
if (pending > 0) return t('aiChat.ciNPending', { n: pending })
|
||||
return t('aiChat.ciNPassed', { n: pass })
|
||||
})
|
||||
|
||||
function statusIcon(status: string): string {
|
||||
|
||||
@@ -110,6 +110,7 @@ import SkillMention from './SkillMention.vue'
|
||||
import ImageInput from './ImageInput.vue'
|
||||
import MentionPopover from './MentionPopover.vue'
|
||||
import EnrichmentPanel from './EnrichmentPanel.vue'
|
||||
import { taskApi } from '../../api/task'
|
||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||
import type { MentionItem } from './MentionItem'
|
||||
|
||||
@@ -323,7 +324,8 @@ function clearSkill() {
|
||||
|
||||
// ── UX-10 §1.4: @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记,后端 system prompt 注入摘要) ──
|
||||
// 架构复用 / 技能联想:独立状态(mentionOpen/mentionIndex/mentionQuery),不与 skill 混。
|
||||
// 联想源只读 projectsStore.projects / projectsStore.tasks(前端 store 现有数据,不新增 IPC)。
|
||||
// 联想源:projects/ideas 读 store;tasks 联想候选读 store.tasks,但 @项目 enrichment
|
||||
// 反查任务走 projectTasksCache(独立 list_tasks,隔离 Tasks 视图筛选,见 P1-g)。
|
||||
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
|
||||
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
|
||||
// MentionItem 接口已抽至 ./MentionItem.ts(MentionPopover 子组件共用)。
|
||||
@@ -340,6 +342,32 @@ const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
|
||||
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
|
||||
// 在输入区下方显式 badge + 可展开的上下文参考面板。
|
||||
|
||||
// P1-g: 任务联想源隔离 store.tasks(后者反映 Tasks 视图当前筛选+分页,B-29 契约)。
|
||||
// @项目 enrichment 按项目反查时读 store.tasks 会被当前页筛选污染(只见到筛进来的子集)。
|
||||
// 改为按 project_id 独立 invoke list_tasks({project_id, limit}) + 本地 Map 缓存,
|
||||
// 切项目(projectsStore.projects 列表重载/项目删除)清空缓存防陈旧。
|
||||
const MENTION_TASK_LIMIT = 50
|
||||
const projectTasksCache = ref<Map<string, TaskRecord[]>>(new Map())
|
||||
|
||||
/** 拉取指定项目的任务并缓存(已缓存则跳过);失败静默降级为空数组(不阻塞 enrichment 渲染) */
|
||||
async function ensureProjectTasks(projectId: string): Promise<void> {
|
||||
if (projectTasksCache.value.has(projectId)) return
|
||||
try {
|
||||
const list = await taskApi.list({ project_id: projectId, limit: MENTION_TASK_LIMIT })
|
||||
// 写新 Map(响应式触发:Map 重赋值让 computed 重算,mutate 旧 Map 不可靠)
|
||||
const next = new Map(projectTasksCache.value)
|
||||
next.set(projectId, list)
|
||||
projectTasksCache.value = next
|
||||
} catch (e) {
|
||||
// 失败占位空数组避免反复重试,下次切项目自然清缓存重拉
|
||||
const next = new Map(projectTasksCache.value)
|
||||
next.set(projectId, [])
|
||||
projectTasksCache.value = next
|
||||
console.error('[ChatInput] 加载项目任务失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
interface ProjectEnrichment {
|
||||
id: string
|
||||
name: string
|
||||
@@ -358,13 +386,14 @@ const projectEnrichments = computed<ProjectEnrichment[]>(() => {
|
||||
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
||||
if (projectSpans.length === 0) return []
|
||||
const projects = (projectsStore.projects || []) as ProjectRecord[]
|
||||
const tasks = (projectsStore.tasks || []) as TaskRecord[]
|
||||
// P1-g: 任务从 projectTasksCache 读(按 project_id 独立拉取),
|
||||
// 不读 store.tasks(后者反映 Tasks 视图筛选+分页,会污染 @项目 enrichment)。
|
||||
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
|
||||
const results: ProjectEnrichment[] = []
|
||||
for (const span of projectSpans) {
|
||||
const project = projects.find(p => p.id === span.refId)
|
||||
if (!project) continue
|
||||
const relTasks = tasks.filter(t => t.project_id === project.id)
|
||||
const relTasks = (projectTasksCache.value.get(project.id) || []) as TaskRecord[]
|
||||
// ideas linking to this project via promoted_to (project name matching)
|
||||
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
|
||||
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
|
||||
@@ -430,7 +459,9 @@ function dismissEnrichment() {
|
||||
for (const span of projectSpans) {
|
||||
const label = text.slice(span.start, span.start + span.length)
|
||||
// 替换标记 + 前后可能的空格为单个空格
|
||||
const pattern = new RegExp(`\\s*${escapeRegex(label)}\s*`)
|
||||
// M2: 原尾段 \s* 在模板字符串里是单反斜杠 → 解析成字面 s(零或多个 s 字母),
|
||||
// 应为 \\s*(双反斜杠 → 正则 \s*),与首段 \s* 对称匹配尾部空白。
|
||||
const pattern = new RegExp(`\\s*${escapeRegex(label)}\\s*`)
|
||||
text = text.replace(pattern, ' ').trim()
|
||||
}
|
||||
inputText.value = text
|
||||
@@ -443,6 +474,27 @@ function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。
|
||||
// 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。
|
||||
watch(
|
||||
() => pendingMentionSpans.value.filter(sp => sp.kind === 'project').map(sp => sp.refId),
|
||||
(projectIds) => {
|
||||
for (const pid of projectIds) {
|
||||
if (pid) void ensureProjectTasks(pid)
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// P1-g: 切项目清缓存 —— projectsStore.projects 列表重载/增删会让缓存 id 失效(项目删除后
|
||||
// 缓存条目悬空)。监听 projects 引用变化即清空,下次 @项目 自然重拉最新。
|
||||
watch(
|
||||
() => projectsStore.projects,
|
||||
() => {
|
||||
if (projectTasksCache.value.size > 0) projectTasksCache.value = new Map()
|
||||
},
|
||||
)
|
||||
|
||||
const mentionGroupLabel = computed(() => ({
|
||||
project: t('aiChat.mentionGroupProject'),
|
||||
task: t('aiChat.mentionGroupTask'),
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
v-for="conv in group.items"
|
||||
v-show="!store.state.foldedGroups[group.key]"
|
||||
:key="conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
@@ -168,6 +169,7 @@
|
||||
v-for="conv in archivedConvs"
|
||||
v-show="!store.state.archivedCollapsed"
|
||||
:key="'a-' + conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item ai-conv-item--archived"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
@@ -230,7 +232,7 @@
|
||||
* 本组件自管的纯侧栏态:titleFlash/重命名/更多操作菜单/侧栏拖拽/搜索结果/时间分组。
|
||||
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
||||
*/
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { formatRelative } from '../../utils/time'
|
||||
@@ -322,10 +324,51 @@ function onActionsOutsideClick(e: MouseEvent) {
|
||||
|
||||
/**
|
||||
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
|
||||
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
void store.switchConversation(id)
|
||||
store.state.searchQuery = ''
|
||||
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
|
||||
void nextTick(() => ensureActiveVisible())
|
||||
}
|
||||
|
||||
// ── M3:活跃会话项 DOM ref 收集(id → el),用于展开折叠组后 scrollIntoView ──
|
||||
// Vue 3 函数 ref:每项 :ref="(el) => setItemEl(id, el)"。卸载时 el 为 null,删 key 防 Map 泄漏。
|
||||
const itemEls = new Map<string, Element>()
|
||||
function setItemEl(id: string, el: Element | ComponentPublicInstance | null): void {
|
||||
if (!el) {
|
||||
itemEls.delete(id)
|
||||
return
|
||||
}
|
||||
// 组件实例取 $el,但本项是普通 div(el 即 Element)
|
||||
itemEls.set(id, el as Element)
|
||||
}
|
||||
|
||||
/**
|
||||
* M3:确保当前活跃会话在分组视图可见。若它落在折叠的时间分组(今天/昨天/更早)或折叠的归档分组,
|
||||
* 展开该组让 v-show 渲染出来,再 scrollIntoView。用于搜索点选、切对话后选中态不被折叠吞掉。
|
||||
*/
|
||||
function ensureActiveVisible(): void {
|
||||
const id = store.state.activeConversationId
|
||||
if (!id) return
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
if (!conv) return
|
||||
// 先按归档态/时间桶决定应展开哪个组折叠态
|
||||
if (conv.archived) {
|
||||
if (store.state.archivedCollapsed) store.state.archivedCollapsed = false
|
||||
} else {
|
||||
const bucket = timeBucket(conv.updated_at)
|
||||
if (store.state.foldedGroups[bucket]) store.state.foldedGroups[bucket] = false
|
||||
}
|
||||
// 折叠态翻转后 v-show 需一帧才渲染,再 nextTick 取 el 滚动
|
||||
void nextTick(() => {
|
||||
const el = itemEls.get(id)
|
||||
if (el) {
|
||||
;(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-for="item in visibleDirAuths" :key="item.id" class="ai-dir-auth">
|
||||
<div v-for="item in visibleDirAuths" :key="item.id" ref="dirAuthRefs" class="ai-dir-auth">
|
||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
||||
<span class="ai-dir-auth-hint">
|
||||
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { reactive, computed, watch, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
|
||||
@@ -77,6 +77,10 @@ const visibleDirAuths = computed(() => {
|
||||
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
||||
const actingIds = reactive(new Set<string>())
|
||||
|
||||
// M8:卡片根元素 ref 数组(v-for ref 收集)。挂起弹窗出现在输入区上方,常被消息流/进度条挤出视口,
|
||||
// 用户看不到按钮 → 挂起无声卡死。新增项时滚动定位到卡片让审批入口立即可见。
|
||||
const dirAuthRefs = ref<HTMLElement[]>([])
|
||||
|
||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||
@@ -105,6 +109,16 @@ watch(() => pendingDirAuths.value, (items) => {
|
||||
if (!liveIds.has(id)) actingIds.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
/** M8:visibleDirAuths 出现新挂起项时,scrollIntoView 让审批入口立刻可见(挂起弹窗在输入区上方,
|
||||
* 易被消息流/进度条挤出视口,用户不滚动看不到 → 挂起无声卡死)。仅新增项触发滚动,避免已可见项
|
||||
* 反复跳动打扰。 */
|
||||
watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
||||
await nextTick()
|
||||
// 滚到最新(数组末)项,block:'center' 让按钮组居中可见
|
||||
const last = dirAuthRefs.value[dirAuthRefs.value.length - 1]
|
||||
if (last) last.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||
<div v-if="helpActive" class="ai-help-required">
|
||||
<div v-if="helpActive" ref="helpRef" class="ai-help-required">
|
||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
|
||||
<div class="ai-help-required-options">
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingHelp } from '../../composables/ai/useAiEvents'
|
||||
@@ -49,6 +49,18 @@ const helpActive = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// M8:卡片根 ref。断路器熔断常发生在多轮迭代后,消息流已长,求助卡被挤出视口用户看不到 → 求助无声
|
||||
// 卡死。卡片显示时 scrollIntoView 让 option 按钮组立即进入视口。
|
||||
const helpRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** M8:helpActive 从 null→非 null(求助挂起出现)时 scrollIntoView 让操作入口立即可见。
|
||||
* 仅监 truthy 翻转,不监 reason/context 变化避免已可见时反复跳。 */
|
||||
watch(helpActive, async (p) => {
|
||||
if (!p) return
|
||||
await nextTick()
|
||||
helpRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
|
||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
|
||||
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-if="showMaxRoundsCard" class="ai-max-rounds">
|
||||
<div v-if="showMaxRoundsCard" ref="maxRoundsRef" class="ai-max-rounds">
|
||||
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
|
||||
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
|
||||
<div class="ai-max-rounds-actions">
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
|
||||
@@ -73,6 +73,10 @@ const showMaxRoundsCard = computed(() =>
|
||||
&& isViewingGeneratingState.value,
|
||||
)
|
||||
|
||||
// M8:卡片根 ref。达 max 挂起卡位于输入区上方,多轮迭代后消息流已很长,卡片常被挤出视口 → 用户看不到
|
||||
// "继续/停止" → generating 永真卡死。卡片显示时 scrollIntoView 让操作入口立即可见。
|
||||
const maxRoundsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
|
||||
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
|
||||
@@ -108,6 +112,14 @@ async function handleStopLoop(): Promise<void> {
|
||||
watch(() => pendingMaxRounds.value, (v) => {
|
||||
if (!v) maxRoundsActing.value = false
|
||||
})
|
||||
|
||||
/** M8:卡片显示(从无→有)时 scrollIntoView 让"继续/停止"按钮组立即进入视口。
|
||||
* 仅监 showMaxRoundsCard 真值翻转,不监 acting 等内部态避免已可见时反复跳。 */
|
||||
watch(showMaxRoundsCard, async (show) => {
|
||||
if (!show) return
|
||||
await nextTick()
|
||||
maxRoundsRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -53,8 +53,14 @@
|
||||
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。 -->
|
||||
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。
|
||||
disabled:无活跃消息(空会话)或流式中(避免并发删/写),对齐 clear-context 项语义。 -->
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:disabled="!hasActiveMessages || store.state.streaming"
|
||||
:title="$t('aiChat.clearChat')"
|
||||
@click="emit('clear-chat')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -391,8 +397,22 @@ const historyExpanded = ref(false)
|
||||
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
|
||||
function scrollToMessage(msgId: string) {
|
||||
historyExpanded.value = false
|
||||
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位
|
||||
const el = document.querySelector(`[data-msg-id="${msgId}"]`)
|
||||
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
|
||||
// BUG-260802:msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
|
||||
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
|
||||
// 用 CSS.escape 转义 msgId,确保任意字符串安全嵌入属性选择器。
|
||||
// 旧浏览器无 CSS.escape 的兜底:encodeURIComponent + attr*= 模糊匹配(非精确,降级路径)。
|
||||
let el: Element | null = null
|
||||
const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(msgId)
|
||||
: encodeURIComponent(msgId)
|
||||
try {
|
||||
el = document.querySelector(`[data-msg-id="${escaped}"]`)
|
||||
} catch {
|
||||
// 转义后仍异常(理论不会)→ 退化为遍历查找
|
||||
const all = document.querySelectorAll('[data-msg-id]')
|
||||
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
|
||||
}
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user