|
|
|
@@ -28,6 +28,7 @@
|
|
|
|
|
:placeholder="t('aiChat.searchPlaceholder')"
|
|
|
|
|
:title="t('aiChat.searchShortcutHint')"
|
|
|
|
|
type="text"
|
|
|
|
|
@keydown="onSearchKeydown"
|
|
|
|
|
/>
|
|
|
|
|
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
|
|
|
|
|
</div>
|
|
|
|
@@ -44,10 +45,28 @@
|
|
|
|
|
'ai-conv-item--archived': conv.archived,
|
|
|
|
|
'ai-conv-item--pinned': conv.pinned,
|
|
|
|
|
}"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
@click="onSelectSearchResult(conv.id)"
|
|
|
|
|
@keydown="onItemKeydown($event, conv.id)"
|
|
|
|
|
>
|
|
|
|
|
<div class="ai-conv-item-main">
|
|
|
|
|
<span class="ai-conv-item-title" :title="conv.title || ''">
|
|
|
|
|
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
|
|
|
|
|
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
|
|
|
|
|
<input
|
|
|
|
|
v-if="editingConvId === conv.id"
|
|
|
|
|
:ref="setEditEl"
|
|
|
|
|
v-model="editText"
|
|
|
|
|
class="ai-conv-item-edit"
|
|
|
|
|
@keydown="onRenameKeydown($event, conv.id)"
|
|
|
|
|
@blur="commitRename(conv.id)"
|
|
|
|
|
@click.stop
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
v-else
|
|
|
|
|
class="ai-conv-item-title"
|
|
|
|
|
:title="conv.title || t('aiChat.doubleClickToRename')"
|
|
|
|
|
@dblclick.stop="startRename(conv)"
|
|
|
|
|
>
|
|
|
|
|
{{ 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 文案) -->
|
|
|
|
@@ -61,6 +80,10 @@
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
|
|
|
|
{{ t('aiChat.renameConversation') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
|
|
|
@@ -106,7 +129,9 @@
|
|
|
|
|
:ref="(el) => setItemEl(conv.id, el)"
|
|
|
|
|
class="ai-conv-item"
|
|
|
|
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
@click="beginSwitch(conv.id)"
|
|
|
|
|
@keydown="onItemKeydown($event, conv.id)"
|
|
|
|
|
>
|
|
|
|
|
<div class="ai-conv-item-main">
|
|
|
|
|
<input
|
|
|
|
@@ -145,6 +170,10 @@
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
|
|
|
|
{{ t('aiChat.renameConversation') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
|
|
|
@@ -176,7 +205,9 @@
|
|
|
|
|
: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--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
@click="beginSwitch(conv.id)"
|
|
|
|
|
@keydown="onItemKeydown($event, conv.id)"
|
|
|
|
|
>
|
|
|
|
|
<div class="ai-conv-item-main">
|
|
|
|
|
<span class="ai-conv-item-title" :title="conv.title || ''">
|
|
|
|
@@ -216,6 +247,11 @@
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- UX走查 P2-7: 会话操作(rename/archive/pin)失败的就近反馈,定位侧栏顶部而非推进聊天流
|
|
|
|
|
(操作发起与视线焦点都在侧栏;且消息区被清空/切换后气泡可能不可见) -->
|
|
|
|
|
<Transition name="conv-toast">
|
|
|
|
|
<div v-if="convToast.visible" class="ai-conv-toast">{{ convToast.msg }}</div>
|
|
|
|
|
</Transition>
|
|
|
|
|
</div>
|
|
|
|
|
</transition>
|
|
|
|
|
</template>
|
|
|
|
@@ -243,6 +279,9 @@ import { useI18n } from 'vue-i18n'
|
|
|
|
|
import { useAiStore } from '../../stores/ai'
|
|
|
|
|
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
|
|
|
|
|
import { convPendingApprovals } from '../../composables/ai/aiShared'
|
|
|
|
|
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
|
|
|
|
|
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
|
|
|
|
|
import { useToast } from '../../composables/useToast'
|
|
|
|
|
import { formatRelative } from '../../utils/time'
|
|
|
|
|
import type { AiConversationSummary } from '../../api/types'
|
|
|
|
|
|
|
|
|
@@ -324,6 +363,27 @@ function onRenameKeydown(e: KeyboardEvent, convId: string) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UX走查 P3-1: 搜索框 Esc 清空(对齐 Tasks 搜索框行为)。同款 IME 守卫:组词中按 Esc 是
|
|
|
|
|
// 取消组词,keyup 阶段 isComposing 已复位拦不住,故挂 keydown
|
|
|
|
|
function onSearchKeydown(e: KeyboardEvent) {
|
|
|
|
|
if (e.isComposing || e.keyCode === 229) return
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
store.state.searchQuery = ''
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
|
|
|
|
|
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发
|
|
|
|
|
function onItemKeydown(e: KeyboardEvent, convId: string) {
|
|
|
|
|
if (e.isComposing || e.keyCode === 229) return
|
|
|
|
|
if (e.target !== e.currentTarget) return
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
beginSwitch(convId)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
|
|
|
|
const actionsOpenId = ref<string | null>(null)
|
|
|
|
|
|
|
|
|
@@ -343,6 +403,20 @@ function onActionsOutsideClick(e: MouseEvent) {
|
|
|
|
|
actionsOpenId.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UX走查 P2-6: 操作菜单原仅外部点击可关,键盘用户开菜单后无 Esc 逃逸;IME 守卫防组词取消误关
|
|
|
|
|
function onActionsEscClose(e: KeyboardEvent) {
|
|
|
|
|
if (!actionsOpenId.value) return
|
|
|
|
|
if (e.isComposing || e.keyCode === 229) return
|
|
|
|
|
if (e.key === 'Escape') actionsOpenId.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UX走查 P2-7: rename/archive/pin 失败反馈改侧栏就近 toast(原以错误气泡推进聊天流,视线错位)。
|
|
|
|
|
// 每次失败发布新对象,watch 即重触发;useToast 自动重置计时与卸载清理
|
|
|
|
|
const { toast: convToast, showToast: showConvToast } = useToast()
|
|
|
|
|
watch(convOpFailNotice, (n) => {
|
|
|
|
|
if (n) showConvToast(t(`ai.${n.key}`, n.params ?? {}), 'error')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
|
|
|
|
|
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
|
|
|
|
|
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
|
|
|
|
@@ -511,12 +585,15 @@ function onResizeStart(e: MouseEvent) {
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
// 点击外部关闭更多操作菜单
|
|
|
|
|
document.addEventListener('click', onActionsOutsideClick)
|
|
|
|
|
// 操作菜单 Esc 关闭(P2-6,与外部点击同生命周期)
|
|
|
|
|
document.addEventListener('keydown', onActionsEscClose)
|
|
|
|
|
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
|
|
|
|
|
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
document.removeEventListener('click', onActionsOutsideClick)
|
|
|
|
|
document.removeEventListener('keydown', onActionsEscClose)
|
|
|
|
|
// 释放待审批徽标轮询计时器
|
|
|
|
|
if (pendingPollTimer) { clearInterval(pendingPollTimer); pendingPollTimer = null }
|
|
|
|
|
// 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
|
|
|
@@ -774,6 +851,11 @@ body.ai-sidebar-resizing * {
|
|
|
|
|
.ai-conv-item:hover .ai-conv-item-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
}
|
|
|
|
|
/* UX走查 P2-6: 键盘 Tab 进会话项(项自身 tabindex=0,或焦点在项内按钮/输入框)时同样浮出
|
|
|
|
|
操作按钮——原仅 :hover 渲染,键盘用户永远够不到「...」菜单触发点 */
|
|
|
|
|
.ai-conv-item:focus-within .ai-conv-item-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
}
|
|
|
|
|
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
|
|
|
|
|
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
|
|
|
|
|
display: flex;
|
|
|
|
@@ -853,6 +935,27 @@ body.ai-sidebar-resizing * {
|
|
|
|
|
color: var(--df-text-dim);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast,
|
|
|
|
|
但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */
|
|
|
|
|
.ai-conv-toast {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 16px;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
padding: 8px 14px;
|
|
|
|
|
border-radius: var(--df-radius);
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */
|
|
|
|
|
max-width: 85%;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
|
|
|
background: var(--df-danger-bg);
|
|
|
|
|
color: var(--df-danger);
|
|
|
|
|
border: 0.5px solid var(--df-danger);
|
|
|
|
|
}
|
|
|
|
|
.conv-toast-enter-active, .conv-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
|
|
|
|
.conv-toast-enter-from, .conv-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
|
|
|
|
|
|
|
|
|
/* 侧边栏滑入动画 */
|
|
|
|
|
.sidebar-slide-enter-active { transition: width 0.2s var(--df-ease), opacity 0.15s; }
|
|
|
|
|
.sidebar-slide-leave-active { transition: width 0.15s var(--df-ease), opacity 0.1s; }
|
|
|
|
|