优化: 对话交互修复与界面完善

- 修输入法组词回车直发/分离窗误关/出错后按钮死锁
- 新建任务失败弹窗内联提示,会话切换乐观高亮
- 状态多选联动刷新不再冲空可见行
- 测试连接保留已配开关与权重,防抖按提供商分键
- 代码块复制/长回复折叠带量/队列回填/排队时钟
- 子代理完成卡渲染,死链卡片与引用清理
- 截图渲染换 pro 版依赖,样式 token 化收尾
This commit is contained in:
lxy
2026-10-03 15:35:18 +08:00
parent b4fafa0e9a
commit 2d43f90e50
20 changed files with 609 additions and 255 deletions
+96 -7
View File
@@ -40,6 +40,7 @@
class="ai-conv-item"
:class="{
'ai-conv-item--active': conv.id === store.state.activeConversationId,
'ai-conv-item--switching': conv.id === pendingSwitchId,
'ai-conv-item--archived': conv.archived,
'ai-conv-item--pinned': conv.pinned,
}"
@@ -49,6 +50,8 @@
<span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
@@ -102,8 +105,8 @@
: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)"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
@click="beginSwitch(conv.id)"
>
<div class="ai-conv-item-main">
<input
@@ -111,8 +114,7 @@
:ref="setEditEl"
v-model="editText"
class="ai-conv-item-edit"
@keyup.enter="commitRename(conv.id)"
@keyup.esc="cancelRename"
@keydown="onRenameKeydown($event, conv.id)"
@blur="commitRename(conv.id)"
@click.stop
/>
@@ -132,6 +134,8 @@
><polyline points="20 6 9 17 4 12"/></svg>
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
@@ -171,13 +175,15 @@
: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)"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
@click="beginSwitch(conv.id)"
>
<div class="ai-conv-item-main">
<span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
@@ -235,6 +241,8 @@
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
import { convPendingApprovals } from '../../composables/ai/aiShared'
import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types'
@@ -302,6 +310,20 @@ function cancelRename() {
editingConvId.value = null
}
/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
* 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
function onRenameKeydown(e: KeyboardEvent, convId: string) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
commitRename(convId)
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
}
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
const actionsOpenId = ref<string | null>(null)
@@ -321,13 +343,31 @@ function onActionsOutsideClick(e: MouseEvent) {
actionsOpenId.value = null
}
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
const pendingSwitchId = ref<string | null>(null)
function beginSwitch(id: string) {
// 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
if (id === store.state.activeConversationId) {
void store.switchConversation(id)
return
}
pendingSwitchId.value = id
void store.switchConversation(id).finally(() => {
// 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
if (pendingSwitchId.value === id) pendingSwitchId.value = null
})
}
/**
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
*/
function onSelectSearchResult(id: string) {
void store.switchConversation(id)
// 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
beginSwitch(id)
store.state.searchQuery = ''
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
void nextTick(() => ensureActiveVisible())
@@ -370,6 +410,30 @@ function ensureActiveVisible(): void {
})
}
// ── V1: 跨会话待审批徽标 ──
// convPendingApprovals Map 本体非响应式(aiShared 注明渲染只依赖投影数组),故 computed 读 Map 的
// 同时锚定三类响应式源触发重算:
// - state.pendingApprovals(活跃会话投影,审批增删/切换恢复整体替换时必变)→ 活跃会话即时刷新;
// - conversations 列表(新建/删除/排序刷新);
// - pendingTick 2.5s 轮询兜底:后台会话新增/解除挂起不触碰任何响应式源(aiShared 只写裸 Map),
// 轻量周期重算补偿(徽标属弱实时信息,秒级延迟可接受)。
const pendingTick = ref(0)
let pendingPollTimer: ReturnType<typeof setInterval> | null = null
const pendingCountByConv = computed(() => {
void store.state.pendingApprovals.length
void store.state.conversations.length
void pendingTick.value
const out = new Map<string, number>()
for (const [convId, arr] of convPendingApprovals) {
if (arr.length > 0) out.set(convId, arr.length)
}
return out
})
function pendingCountOf(convId: string): number {
return pendingCountByConv.value.get(convId) ?? 0
}
// ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed<Record<string, string>>(() => ({
today: t('aiChat.today'),
@@ -447,10 +511,14 @@ function onResizeStart(e: MouseEvent) {
onMounted(() => {
// 点击外部关闭更多操作菜单
document.addEventListener('click', onActionsOutsideClick)
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick)
// 释放待审批徽标轮询计时器
if (pendingPollTimer) { clearInterval(pendingPollTimer); pendingPollTimer = null }
// 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
@@ -612,6 +680,12 @@ body.ai-sidebar-resizing * {
.ai-conv-item--active {
background: var(--df-sidebar-active);
}
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
.ai-conv-item--switching {
background: var(--df-sidebar-active);
opacity: 0.7;
}
.ai-conv-item--archived .ai-conv-item-title {
opacity: 0.6;
}
@@ -676,6 +750,21 @@ body.ai-sidebar-resizing * {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
.ai-conv-approve-badge {
flex-shrink: 0;
min-width: 12px;
height: 12px;
padding: 0 3px;
border-radius: 6px;
background: var(--df-warning, #f0c75e);
color: #000;
font-size: 9px;
font-weight: 600;
line-height: 12px;
text-align: center;
cursor: help;
}
.ai-conv-item-actions {
display: none;
align-items: center;