优化: 界面交互完善与设置体验
- 审批卡尊重滚动意图,等长切换回底,排队超时按钮修活 - 带图编辑提示不静默丢,删除目标回写,切换文案消歧 - 会话键盘可达+搜索态重命名,筛选计数口径对齐 - 子任务提示正名,看板加载态 - 主题跟随系统,表单脏态拦截,密钥显隐留空提示
This commit is contained in:
@@ -93,3 +93,4 @@ devflow-dev.db-wal
|
||||
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
||||
apps/df-miniapp/src/manifest.json
|
||||
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
||||
temp/
|
||||
|
||||
@@ -413,9 +413,6 @@ const activeQueue = computed(() =>
|
||||
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
|
||||
)
|
||||
|
||||
/** 队首排队是否已超 30s(仅看当前活跃会话队列) */
|
||||
const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeConversationId))
|
||||
|
||||
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
||||
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
||||
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
|
||||
@@ -423,6 +420,15 @@ const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeCon
|
||||
const nowTick = ref(Date.now())
|
||||
let queueTickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
|
||||
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
|
||||
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
|
||||
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
|
||||
const queueTimedOut = computed(() => {
|
||||
void nowTick.value
|
||||
return store.isQueueTimedOut(store.state.activeConversationId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => activeQueue.value.length > 0,
|
||||
(waiting) => {
|
||||
|
||||
@@ -65,6 +65,11 @@ const emit = defineEmits<{
|
||||
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
|
||||
*/
|
||||
'install-obscura': []
|
||||
/**
|
||||
* 审批卡到达但用户不在底部附近(上滚读历史中):不强拉滚动,
|
||||
* 由父(MessageList)出「有新审批」轻提示,尊重用户阅读位置。
|
||||
*/
|
||||
'pending-out-of-view': []
|
||||
}>()
|
||||
|
||||
// 根元素 ref(scrollToFirstPending 查 DOM 用)
|
||||
@@ -306,6 +311,28 @@ 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 实例
|
||||
@@ -313,7 +340,17 @@ onMounted(async () => {
|
||||
// 内 querySelector 返 null no-op 天然过滤(功能正确),此处显式 props 过滤把无 pending 实例挡在
|
||||
// 进入函数前。含 pending 的实例再走 nextTick + scrollToFirstPending(展开折叠组 + scroll)。
|
||||
if (!props.toolCalls.some(tc => tc.status === 'pending_approval')) return
|
||||
nextTick(() => scrollToFirstPending())
|
||||
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 }
|
||||
|
||||
@@ -894,6 +894,15 @@ async function sendExamplePrompt(i18nKey: string) {
|
||||
|
||||
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
|
||||
function startEditFocus(msg: AiMessage): void {
|
||||
// UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换
|
||||
// content 文本,不接受 parts——编辑重发后图片静默丢失(重载后图消失),无任何提示。
|
||||
// 后端契约未扩前,入口挡住并提示,杜绝静默丢数据。父已先置 editingMsgId 再调本方法,
|
||||
// 故 emit cancel-edit 让父复位编辑态,状态保持一致。
|
||||
if (msg.parts?.some(p => p.type === 'image')) {
|
||||
emit('cancel-edit')
|
||||
emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' })
|
||||
return
|
||||
}
|
||||
inputText.value = msg.content
|
||||
nextTick(() => {
|
||||
inputEl.value?.focus()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -326,6 +326,12 @@ function scrollToFirstPending(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批卡到达但用户不在底部(上滚读历史中):不强拉滚动,出「有新审批」轻提示。
|
||||
* ToolCardList 侧已做 5s 去重,此处仅转发父 toast(单一出口)。 */
|
||||
function onPendingOutOfView(): void {
|
||||
emit('toast', { msg: t('aiChat.newApprovalPending'), type: 'info' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
|
||||
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
|
||||
@@ -1108,6 +1114,10 @@ watch(() => store.state.activeConversationId, () => {
|
||||
}
|
||||
// 切会话同步清流式块级 memo 缓存(_blockCache),防旧会话已缓存块被新会话命中。
|
||||
resetStreamCache()
|
||||
// UX 走查-对话域 B2:切会话显式回底。仅 watch messages.length 时,两个等长消息数的会话
|
||||
// 互切不触发任何滚动,scrollTop 残留旧会话位置(可能停在中段)。setFollowing(true)已声明
|
||||
// 跟随意图,此处补上实际滚动;nextTick 等新会话消息渲染出真实 scrollHeight 再滚。
|
||||
nextTick(() => scrollToBottom())
|
||||
})
|
||||
|
||||
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
|
||||
@@ -1205,6 +1215,7 @@ defineExpose({
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1459,6 +1470,7 @@ defineExpose({
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
class="ai-provider-bar"
|
||||
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
|
||||
v-if="store.state.providers.length > 0"
|
||||
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
|
||||
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
|
||||
@click="cycleProvider"
|
||||
>
|
||||
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
|
||||
@@ -536,6 +536,11 @@ async function removeGoal(i: number) {
|
||||
if (next.length === 0) goalsExpanded.value = false
|
||||
// API 接受 string[] 或 GoalEntry[]
|
||||
await aiApi.updateConversationGoals(convId, next.map(g => g.text))
|
||||
// UX 走查-对话域 B4:删除后同步回写 store 的 conv.pinned_goals(对齐 AiCompleted 回写
|
||||
// useAiLifecycleEvents:126-129 的结构)。不回写则 loadGoals(:519)从 store 重读旧列表,
|
||||
// 已删目标在下次 messages.length watch 触发刷新时"复活"。
|
||||
const conv = store.state.conversations.find(c => c.id === convId)
|
||||
if (conv) conv.pinned_goals = next
|
||||
}
|
||||
|
||||
// 循环切换 Provider(provider bar 点击)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, onMounted, useTemplateRef } from 'vue'
|
||||
import { reactive, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
@@ -77,6 +77,14 @@ function applyTheme(theme: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 走查 P2-4:原实现只在挂载/改档时读一次 matchMedia.matches,OS 深浅色切换后应用停在旧色,
|
||||
// 需重启或重选档位才跟随。挂 change 监听实时重算;非 system 档是用户手动定色,不应被 OS
|
||||
// 切色打断,故回调内先判档。组件卸载必须移除监听(本 Section 随分类切换反复挂卸,不清理会叠监听器)。
|
||||
const systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
function onSystemThemeChange() {
|
||||
if (settings.theme === 'system') applyTheme('system')
|
||||
}
|
||||
|
||||
// 即时生效反馈:SettingRow refs,key 映射对应 row
|
||||
const rowTheme = useTemplateRef<InstanceType<typeof SettingRow>>('rowTheme')
|
||||
const rowLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowLanguage')
|
||||
@@ -113,6 +121,11 @@ watch(() => settings.language, (val) => {
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme(settings.theme)
|
||||
systemDarkQuery.addEventListener('change', onSystemThemeChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
systemDarkQuery.removeEventListener('change', onSystemThemeChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -92,7 +92,15 @@
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
|
||||
<input v-model="providerForm.apiKey" class="setting-input" type="password" :placeholder="$t('settings.phApiKey')" />
|
||||
<!-- 走查 P3-9:type 恒 password 无法核对已粘贴密钥,加显隐切换 -->
|
||||
<input v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'" :placeholder="$t('settings.phApiKey')" />
|
||||
<button type="button" class="btn-link" :title="$t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow')"
|
||||
@click="showApiKey = !showApiKey">
|
||||
{{ $t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow') }}
|
||||
</button>
|
||||
<!-- 走查 P3-9:编辑态留空=沿用已存密钥(后端 FR-S1),此前表单零说明,用户误以为必须重输。
|
||||
仅编辑态显(新建态密钥必填,不存在"留空"语义)。 -->
|
||||
<div v-if="providerForm.editId" class="form-field-hint">{{ $t('settings.apiKeyKeepHint') }}</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
|
||||
@@ -217,6 +225,9 @@ const props = defineProps<{
|
||||
|
||||
const aiProviders = ref<AiProviderConfig[]>([])
|
||||
|
||||
// 密钥显隐切换(走查 P3-9):默认隐藏;重开表单时复位,防上一家 provider 的显钥状态延续
|
||||
const showApiKey = ref(false)
|
||||
|
||||
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
|
||||
|
||||
const providerForm = reactive({
|
||||
@@ -422,6 +433,7 @@ function openProviderForm(p?: AiProviderConfig) {
|
||||
}
|
||||
providerForm.fetching = false
|
||||
providerForm.saveError = null // 重开表单清旧错误横幅
|
||||
showApiKey.value = false // 密钥默认隐藏,不延续上次表单的显钥状态(防窥)
|
||||
providerForm.visible = true
|
||||
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
|
||||
providerForm.savedSnapshot = formBusinessSnapshot()
|
||||
|
||||
@@ -323,7 +323,8 @@ async function confirmSubtask() {
|
||||
subtaskTitle.value = ''
|
||||
subtaskDesc.value = ''
|
||||
await loadChildren()
|
||||
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
|
||||
// UX走查 P2-3:新建子任务成功此前误复用批量推进文案「已推进 N 个任务」,语义误导
|
||||
showToast(t('taskDetail.subtaskCreated'), 'success')
|
||||
} catch (e: any) {
|
||||
showToast(e?.toString() ?? t('common.unknownError'), 'error')
|
||||
} finally {
|
||||
|
||||
@@ -43,6 +43,8 @@ export default {
|
||||
queueTimedOut: 'Queue timed out',
|
||||
approvalNotDelivered: 'Approval operation failed to reach the backend: {error}',
|
||||
queuedSendFailed: 'Failed to send queued message: {error}',
|
||||
// UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent)
|
||||
queueCleared: 'Cleared {n} queued message(s)',
|
||||
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
|
||||
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
|
||||
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
|
||||
|
||||
@@ -16,6 +16,8 @@ export default {
|
||||
unarchive: 'Unarchive',
|
||||
pinConversation: 'Pin',
|
||||
unpinConversation: 'Unpin',
|
||||
// UX走查 P3-1/P2-6: rename entry in the "..." menu (search-mode parity + keyboard reach)
|
||||
renameConversation: 'Rename',
|
||||
moreActions: 'More actions',
|
||||
archived: 'Archived',
|
||||
archivedCount: 'Archived ({n})',
|
||||
@@ -36,6 +38,8 @@ export default {
|
||||
maximize: 'Maximize',
|
||||
pendingApprovalCount: '{n} pending',
|
||||
pendingApprovalHint: 'Click to jump to the first pending approval',
|
||||
// UX walkthrough B1: approval card arrived while user scrolled up; no forced scroll, hint only (5s dedup in ToolCardList)
|
||||
newApprovalPending: 'New approval pending',
|
||||
restoreSidebar: 'Restore sidebar',
|
||||
sidebarResizeHint: 'Drag to resize sidebar',
|
||||
detachWindow: 'Detach window',
|
||||
@@ -60,6 +64,8 @@ export default {
|
||||
|
||||
// ── Provider status ──
|
||||
clickToSwitchProvider: 'Click to switch provider',
|
||||
// UX walkthrough B5: topbar switching shares the same IPC as Settings "set as default" (ai_set_provider rewrites is_default), label it honestly
|
||||
providerSwitchSetsDefault: 'Click to switch provider (also sets it as the global default)',
|
||||
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
||||
notConfigured: 'Not configured',
|
||||
|
||||
@@ -164,6 +170,8 @@ export default {
|
||||
editMessage: 'Edit',
|
||||
editMessageFailed: 'Edit failed: {msg}',
|
||||
editNotLastUser: 'Only the last user message can be edited',
|
||||
// UX walkthrough B3: editMessage IPC takes no parts; editing would silently drop images, block at entry
|
||||
editImageBlocked: 'This message contains images and cannot be edited (regenerating would lose them)',
|
||||
placeholderEditing: 'Edit message then Enter to regenerate (Esc to cancel)',
|
||||
|
||||
// ── Error bubble actions (UX-03 / ) ──
|
||||
|
||||
@@ -54,6 +54,10 @@ export default {
|
||||
phProviderName: 'e.g. DeepSeek, GLM-4',
|
||||
phBaseUrl: 'https://api.deepseek.com',
|
||||
phApiKey: 'sk-...',
|
||||
// UX-P2 walkthrough: API key show/hide toggle + "leave empty = keep" note
|
||||
apiKeyShow: 'Show',
|
||||
apiKeyHide: 'Hide',
|
||||
apiKeyKeepHint: 'Leave empty while editing to keep the saved API key unchanged',
|
||||
phDefaultModel: 'deepseek-chat',
|
||||
// Direction A (lightweight): defaultModel is only a "suggested starting point";
|
||||
// guide users to hit "Test connection" to pull the vendor's real model list and overwrite,
|
||||
|
||||
@@ -76,6 +76,8 @@ export default {
|
||||
addSubtask: '+ Add Subtask',
|
||||
progressTitle: 'Progress',
|
||||
addSubtaskFailed: 'Failed to create subtask: {msg}',
|
||||
// Subtask created (UX review P2-3): previously reused the batch-advance wording "Advanced N tasks"
|
||||
subtaskCreated: 'Subtask created',
|
||||
// P2-9 subtask hint
|
||||
subtaskHint: 'This is a subtask of: {title}',
|
||||
// P2-7 subtask creation validation
|
||||
|
||||
@@ -85,6 +85,8 @@ export default {
|
||||
emptyColumn: 'No tasks',
|
||||
dragHandle: 'Drag to move',
|
||||
parentNoDrag: 'Parent status is aggregated from subtasks and cannot be dragged',
|
||||
// Illegal transition rejected on drag (UX review P2-5): toast the reason when the card bounces back
|
||||
illegalMove: 'Cannot move: transition from "{from}" to "{to}" is not allowed',
|
||||
},
|
||||
|
||||
// New task modal
|
||||
|
||||
@@ -43,6 +43,8 @@ export default {
|
||||
queueTimedOut: '排队超时',
|
||||
approvalNotDelivered: '审批操作未送达后端:{error}',
|
||||
queuedSendFailed: '队列消息发送失败:{error}',
|
||||
// UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈)
|
||||
queueCleared: '已清 {n} 条排队消息',
|
||||
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
|
||||
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
|
||||
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
|
||||
|
||||
@@ -16,6 +16,8 @@ export default {
|
||||
unarchive: '取消归档',
|
||||
pinConversation: '置顶',
|
||||
unpinConversation: '取消置顶',
|
||||
// UX走查 P3-1/P2-6: 会话「...」菜单重命名入口(搜索态无双击降级补齐 + 键盘可达的重命名路径)
|
||||
renameConversation: '重命名',
|
||||
moreActions: '更多操作',
|
||||
archived: '已归档',
|
||||
archivedCount: '已归档 ({n})',
|
||||
@@ -36,6 +38,8 @@ export default {
|
||||
maximize: '最大化',
|
||||
pendingApprovalCount: '{n} 项待审批',
|
||||
pendingApprovalHint: '点击跳转到第一个待审批项',
|
||||
// UX 走查 B1:审批卡到达但用户上滚读历史,不强拉滚动,只出轻提示(ToolCardList 5s 去重)
|
||||
newApprovalPending: '有新的审批待处理',
|
||||
restoreSidebar: '还原侧栏',
|
||||
sidebarResizeHint: '拖拽调整侧栏宽度',
|
||||
detachWindow: '分离窗口',
|
||||
@@ -60,6 +64,8 @@ export default {
|
||||
|
||||
// ── Provider 状态 ──
|
||||
clickToSwitchProvider: '点击切换 Provider',
|
||||
// UX 走查 B5:顶栏切换与设置页「设为默认」同一条 IPC(ai_set_provider 互斥写 is_default),文案如实标注
|
||||
providerSwitchSetsDefault: '点击切换 Provider(切换即设为全局默认)',
|
||||
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
||||
notConfigured: '未配置',
|
||||
|
||||
@@ -165,6 +171,8 @@ export default {
|
||||
editMessage: '编辑',
|
||||
editMessageFailed: '编辑失败:{msg}',
|
||||
editNotLastUser: '只能编辑最后一条用户消息',
|
||||
// UX 走查 B3:editMessage IPC 不接 parts,编辑重发会静默丢图,入口禁止并提示
|
||||
editImageBlocked: '该消息包含图片,暂不支持编辑(重新生成会丢失图片)',
|
||||
placeholderEditing: '编辑消息后回车重新生成(Esc 取消)',
|
||||
|
||||
// ── 错误气泡操作入口(UX-03 / ) ──
|
||||
|
||||
@@ -54,6 +54,10 @@ export default {
|
||||
phProviderName: '如 DeepSeek、GLM-4',
|
||||
phBaseUrl: 'https://api.deepseek.com',
|
||||
phApiKey: 'sk-...',
|
||||
// 走查 P3-9:密钥显隐切换 + 编辑态「留空=不改」说明
|
||||
apiKeyShow: '显示',
|
||||
apiKeyHide: '隐藏',
|
||||
apiKeyKeepHint: '编辑时留空表示沿用已保存的密钥,不会清空',
|
||||
phDefaultModel: 'deepseek-chat',
|
||||
// 方向 A(轻量):defaultModel 仅作"建议起点",引导用户点「测试连接」拉真实模型列表覆盖,
|
||||
// 避免预设 model id 过时(DeepSeek chat 停用 / Gemini 2.5→3.5 等)误导。
|
||||
|
||||
@@ -76,6 +76,8 @@ export default {
|
||||
addSubtask: '+ 添加子任务',
|
||||
progressTitle: '完成进度',
|
||||
addSubtaskFailed: '创建子任务失败: {msg}',
|
||||
// 子任务创建成功(UX走查 P2-3):此前误复用批量推进文案「已推进 N 个任务」
|
||||
subtaskCreated: '子任务已创建',
|
||||
// P2-9 子任务提示
|
||||
subtaskHint: '该任务为子任务,隶属于:{title}',
|
||||
// P2-7 子任务创建校验
|
||||
|
||||
@@ -85,6 +85,8 @@ export default {
|
||||
emptyColumn: '暂无任务',
|
||||
dragHandle: '拖动以移动',
|
||||
parentNoDrag: '父任务状态由子任务聚合,不能直接拖动',
|
||||
// 拖拽非法流转被拒(UX走查 P2-5):卡片弹回时 toast 说明原因,不再静默
|
||||
illegalMove: '无法移动:状态不允许从「{from}」流转到「{to}」',
|
||||
},
|
||||
|
||||
// 新建任务模态
|
||||
|
||||
+23
-1
@@ -82,6 +82,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
@@ -122,9 +123,30 @@ const connectionPanelRef = shallowRef<InstanceType<typeof ConnectionPanel> | nul
|
||||
// 用 computed 包 useSetting,双向绑 SettingsNav v-model
|
||||
// ============================================================
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 走查 P2-7:ProviderPanel 已暴露 isFormDirty 但壳未接入,点左栏任一分类/离开路由
|
||||
// 都会 v-else-if 卸载面板,未保存改动无确认直接销毁。两条出口(切分类/路由离开)
|
||||
// 统一走本守卫:脏则二次确认,拒绝即原地不动(SettingsNav 是受控组件,不落值高亮自动回弹)。
|
||||
// 复用 ProviderPanel 自身的 confirmDiscardUnsaved 文案,语义与表单「取消」一致。
|
||||
async function confirmLeaveIfProviderDirty(): Promise<boolean> {
|
||||
if (!providerPanelRef.value?.isFormDirty) return true
|
||||
return await confirmDialog(ti18n('settings.confirmDiscardUnsaved'))
|
||||
}
|
||||
|
||||
const activeCategory = computed<SettingsCategory>({
|
||||
get: () => appSettings.get<SettingsCategory>('df-settings-active-category', 'appearance'),
|
||||
set: (v) => { void appSettings.set('df-settings-active-category', v) },
|
||||
set: (v) => {
|
||||
// 点当前分类是无效切换,不弹确认(脏表单下误点自身不应被质询)
|
||||
if (v === activeCategory.value) return
|
||||
void confirmLeaveIfProviderDirty().then((ok) => {
|
||||
if (ok) void appSettings.set('df-settings-active-category', v)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 路由离开(回主页等)同样拦截:Settings 是路由组件,返回 false 阻断本次导航
|
||||
onBeforeRouteLeave(async () => {
|
||||
return await confirmLeaveIfProviderDirty()
|
||||
})
|
||||
|
||||
// AI Prompt 缓存总开关(KV df-ai-anthropic-cache,默认关,与后端 prompt.rs cache_enabled 同键;
|
||||
|
||||
+27
-2
@@ -52,7 +52,10 @@
|
||||
<option value="status">{{ $t('tasks.sort.status') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="filter-count">{{ $t('common.pagination.total', { n: store.tasks.length }) }}</span>
|
||||
<!-- UX走查 P2-2: 计数对齐可见行口径——列表模式按状态筛选后的可见行数(batchScopeIds,
|
||||
含折叠父的子任务),看板模式各状态列恒全显仍取全量;原固定 store.tasks.length,
|
||||
勾选状态芯片后计数不变与可见行数矛盾 -->
|
||||
<span class="filter-count">{{ $t('common.pagination.total', { n: viewMode === 'list' ? batchScopeIds.length : store.tasks.length }) }}</span>
|
||||
<!-- 列表/看板切换(持久化) -->
|
||||
<div class="filter-group view-toggle">
|
||||
<button class="seg-btn" :class="{ 'is-active': viewMode === 'list' }" @click="viewMode = 'list'">{{ $t('tasks.kanban.toggleList') }}</button>
|
||||
@@ -271,8 +274,11 @@
|
||||
<div
|
||||
class="kanban-board"
|
||||
ref="kanbanBoardRef"
|
||||
:class="{ 'is-busy': kanban.kanbanBusy, 'is-hidden': viewMode !== 'kanban' }"
|
||||
:class="{ 'is-busy': kanban.kanbanBusy, 'is-loading': viewMode === 'kanban' && loading, 'is-hidden': viewMode !== 'kanban' }"
|
||||
>
|
||||
<!-- 看板加载指示(UX走查 P2-4):看板常驻渲染,切项目/搜索期间旧筛选卡片仍在展示,
|
||||
轻提示 + 卡片降透明度避免误读为最新数据;局部于看板容器,非全局遮罩,文案对齐列表模式 -->
|
||||
<div v-if="viewMode === 'kanban' && loading" class="kanban-loading">{{ $t('common.loading') }}</div>
|
||||
<div v-for="col in kanbanColumns" :key="col.status" class="kanban-col" :data-status="col.status">
|
||||
<div class="kanban-col-header">
|
||||
<span class="kanban-col-name">{{ $t(statusLabel(col.status)) }}</span>
|
||||
@@ -1000,7 +1006,26 @@ onUnmounted(() => {
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
position: relative; /* 承载局部加载浮标(UX走查 P2-4) */
|
||||
}
|
||||
/* 加载浮标:局部于看板容器的轻提示,压在列上方居中,不阻断列布局;pointer-events none 不挡拖拽热区 */
|
||||
.kanban-loading {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
padding: 3px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
color: var(--df-text-dim);
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 加载期间旧筛选卡片降透明度+禁交互,避免被误读为当前筛选的最新结果 */
|
||||
.kanban-board.is-loading .task-card { opacity: 0.45; pointer-events: none; }
|
||||
.kanban-board.is-busy .task-card { pointer-events: none; }
|
||||
.kanban-board.is-hidden { display: none; }
|
||||
.kanban-col {
|
||||
|
||||
Reference in New Issue
Block a user