From 3ea8d9ad0079e4321b3660a2c5e854671912b19a Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 22 Jul 2026 11:19:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E6=BB=91=E5=9D=97=E5=8A=A8=E7=94=BB=E5=8D=A1=E9=A1=BF?= =?UTF-8?q?=E3=80=81=E5=BF=AB=E6=8D=B7=E7=AA=97=E5=8F=A3=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E8=B7=A8=E5=AF=B9=E8=AF=9D=E4=B8=B2=E6=95=B0=E6=8D=AE=E3=80=81?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E9=AB=98=E5=BA=A6=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EffortSlider: 圆钮/填充改走 transform + clip-path 合成器属性,rAF 直写 DOM 绕过 Vue 响应式,消除每帧重渲染与 reflow - useLegacySocket: todo_updated 全局广播按 conversation_id 过滤,不再串入子智能体待办;移除用事件 conv id 覆盖当前对话 id 的危险逻辑 - watchers: 修复 currentConversationId 重复键互相覆盖导致清空逻辑从未注册的问题,合并为单个 watcher(/new 页面不再残留待办) - InputComposer: 输入框最大高度 6 行 -> 10 行 --- static/src/app/watchers.ts | 34 ++++---- static/src/components/input/EffortSlider.vue | 86 ++++++++++++------- static/src/components/input/InputComposer.vue | 3 +- static/src/composables/useLegacySocket.ts | 14 ++- 4 files changed, 82 insertions(+), 55 deletions(-) diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index 59248537..65839c50 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -8,23 +8,6 @@ export const watchers = { multiAgentMode(newValue) { useConversationStore().$patch({ multiAgentMode: !!newValue }); }, - currentConversationId(newId, oldId) { - if (newId === oldId) { - return; - } - // 同步到 conversationStore(QuickDock 等组件监听 store 侧的 id) - useConversationStore().setCurrentConversationId(newId || null); - const quickDock = useQuickDockStore(); - // 关闭详情/预览/菜单等瞬态(必须立即) - quickDock.resetTransient(); - if (!newId) { - // /new 等无对话场景:不会有 bootstrap 回填,立即清空让列折叠 - quickDock.setEditedFiles([]); - useFileStore().setTodoList(null); - } - // 切到另一对话:不清列表 —— 旧内容短暂保留,由 bootstrap/fetch 回填自然覆盖; - // 新对话无内容时窗口在回填后才消失,避免列「先收起再展开」的闪烁。 - }, inputMessage() { this.autoResizeInput(); if (typeof this.scheduleComposerDraftPersist === 'function') { @@ -85,6 +68,23 @@ export const watchers = { currentConversationId: { immediate: false, handler(newValue, oldValue) { + // 【合并自原同名函数 watcher】对象字面量中两个 currentConversationId + // 键会互相覆盖(后者覆盖前者),原函数版 watcher 从未生效,导致 + // /new 页面残留上一对话的快捷窗口待办。逻辑合并到此处统一执行。 + if (newValue !== oldValue) { + // 同步到 conversationStore(QuickDock 等组件监听 store 侧的 id) + useConversationStore().setCurrentConversationId(newValue || null); + const quickDock = useQuickDockStore(); + // 关闭详情/预览/菜单等瞬态(必须立即) + quickDock.resetTransient(); + if (!newValue) { + // /new 等无对话场景:不会有 bootstrap 回填,立即清空让列折叠 + quickDock.setEditedFiles([]); + useFileStore().setTodoList(null); + } + // 切到另一对话:不清列表 —— 旧内容短暂保留,由 bootstrap/fetch 回填自然覆盖; + // 新对话无内容时窗口在回填后才消失,避免列「先收起再展开」的闪烁。 + } debugLog('currentConversationId 变化', { oldValue, newValue, diff --git a/static/src/components/input/EffortSlider.vue b/static/src/components/input/EffortSlider.vue index 7b9f5161..c0b1fd42 100644 --- a/static/src/components/input/EffortSlider.vue +++ b/static/src/components/input/EffortSlider.vue @@ -15,21 +15,27 @@ const emit = defineEmits<{ }>(); const trackRef = ref(null); +const knobRef = ref(null); +const fillRef = ref(null); +// v-for 圆点元素收集(索引与 LEVELS 对齐) +const dotEls: (HTMLElement | null)[] = []; +const setDotRef = (el: Element | null, i: number) => { + dotEls[i] = (el as HTMLElement | null) ?? null; +}; const trackWidth = ref(0); // 当前档位(记忆值,勾选默认时也保留) const levelIndex = ref(2); -// 圆钮/填充的目标像素位置(拖动时连续,否则吸附档位) -const knobX = ref(0); -// 圆钮/填充的视觉像素位置(JS rAF 插值驱动):点击/吸附时从当前 -// 视觉位置平滑滑向目标,天然连续无瞬移;圆点 covered 也以此为准 —— -// 变色只在圆钮实际滑到/离开时发生,往低调档时被变色的点会被圆钮 -// 遮住渐变过程,不会暴露「白→黑」的闪烁 -const knobXVisual = ref(0); const activeLevel = ref(2); const dragging = ref(false); const liveDrag = ref(false); let downX = 0; let animFrame = 0; +// 圆钮/填充的视觉像素位置(普通变量,非响应式)。所有视觉更新经 +// applyVisualX 直接写 DOM:knob 用 transform、fill 用 clip-path(均为合成器 +// 属性,不触发 layout/paint)、圆点只翻转 class,全程不经过 Vue 渲染。 +// 此前用响应式 ref + left/width 驱动:rAF 每帧触发 Vue 重渲染并 reflow, +// 是滑动卡顿、经过档位点时「顿一下」的根因。 +let visualX = 0; const isDefault = computed(() => props.modelValue === null); @@ -55,6 +61,29 @@ const KNOB_ANIM_MS = 200; // 与 CSS ease 接近的 easeInOutQuad const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2); +// 把视觉位置直接写入 DOM(knob 水平位移走 translate、fill 可见宽度走 +// clip-path inset round 17px 保圆角、圆点 covered 变色时机与圆钮视觉位置 +// 严格同步 —— 往低调档时被变色的点会被圆钮遮住渐变过程,不暴露闪烁)。 +// 未测量(trackWidth=0 的首帧)时退回档位百分比表达式,与测量后像素值等价。 +const applyVisualX = (x: number) => { + const measured = trackWidth.value > EDGE * 2; + const expr = measured ? `${x}px` : posPct(levelIndex.value); + const knob = knobRef.value; + if (knob) { + knob.style.transform = `translate(calc(${expr} - 50%), -50%)`; + } + const fill = fillRef.value; + if (fill) { + fill.style.clipPath = `inset(0 calc(100% - (${expr})) 0 0 round 17px)`; + } + for (let i = 0; i < LEVELS.length; i++) { + const el = dotEls[i]; + if (!el) continue; + const covered = measured ? posPx(i) <= x : i <= levelIndex.value; + el.classList.toggle('covered', covered); + } +}; + const cancelKnobAnim = () => { if (animFrame) { cancelAnimationFrame(animFrame); @@ -64,18 +93,20 @@ const cancelKnobAnim = () => { const setKnobInstant = (x: number) => { cancelKnobAnim(); - knobXVisual.value = x; + visualX = x; + applyVisualX(x); }; // 从当前视觉位置插值滑动到目标(进行中的动画被接续,不会跳变) const animateKnobTo = (target: number) => { - const start = knobXVisual.value; + const start = visualX; if (start === target) return; cancelKnobAnim(); const t0 = performance.now(); const step = (now: number) => { const p = Math.min(1, (now - t0) / KNOB_ANIM_MS); - knobXVisual.value = start + (target - start) * easeInOut(p); + visualX = start + (target - start) * easeInOut(p); + applyVisualX(visualX); if (p < 1) { animFrame = requestAnimationFrame(step); } else { @@ -88,7 +119,6 @@ const animateKnobTo = (target: number) => { // animate=true:点击/吸附时平滑滑动;false:挂载/resize/外部变更时瞬间就位 const syncToLevel = (animate = false) => { const target = posPx(levelIndex.value); - knobX.value = target; activeLevel.value = levelIndex.value; if (animate && trackWidth.value > EDGE * 2) { animateKnobTo(target); @@ -97,18 +127,6 @@ const syncToLevel = (animate = false) => { } }; -// 未测量(trackWidth=0 的首帧)时无法用像素定位: -// 圆钮/填充退回百分比定位(与测量后像素值完全等价,切换无跳变), -// 圆点 covered 退回档位语义 —— 打开弹窗首帧即最终状态,不会闪 -const knobLeftStyle = computed(() => - trackWidth.value > EDGE * 2 ? `${knobXVisual.value}px` : posPct(levelIndex.value) -); -const fillWidthStyle = computed(() => - trackWidth.value > EDGE * 2 ? `${knobXVisual.value}px` : posPct(levelIndex.value) -); -const dotCovered = (i: number) => - trackWidth.value > EDGE * 2 ? posPx(i) <= knobXVisual.value : i <= levelIndex.value; - const commitLevel = () => { emit('update:modelValue', LEVELS[levelIndex.value]); }; @@ -131,8 +149,8 @@ const onPointerMove = (e: PointerEvent) => { // 真实拖动:无动画连续跟随 const x = clampEventX(e.clientX); cancelKnobAnim(); - knobX.value = x; - knobXVisual.value = x; + visualX = x; + applyVisualX(x); activeLevel.value = pxToLevel(x); } }; @@ -224,7 +242,7 @@ onBeforeUnmount(() => { @pointerup="onPointerUp" @pointercancel="onPointerUp" > -
+
{ v-for="(_, i) in LEVELS" :key="`dot-${i}`" class="dot" - :class="{ covered: dotCovered(i) }" + :ref="(el) => setDotRef(el as Element | null, i)" :style="{ left: posPct(i) }" >
-
+
{ touch-action: none; } -/* 填充:5 层叠放,当前档淡入、其余淡出 —— 任何颜色切换都是平滑 crossfade */ +/* 填充:5 层叠放,当前档淡入、其余淡出 —— 任何颜色切换都是平滑 crossfade。 + 宽度恒 100%,可见宽度由 JS 写入 clip-path 控制(合成器属性,无 reflow) */ .fill-stack { position: absolute; left: 0; top: 0; bottom: 0; - width: 23px; + width: 100%; border-radius: 17px; overflow: hidden; } @@ -458,8 +477,8 @@ onBeforeUnmount(() => { } } -/* 圆钮与填充位置由 JS rAF 插值驱动(见 script):点击/吸附平滑滑动、 - 拖动连续跟随、圆点变色时机与圆钮视觉位置严格同步 —— 不用 CSS transition */ +/* 圆钮与填充位置由 JS 直写 DOM 驱动(见 script applyVisualX): + knob 走 transform、fill 走 clip-path,均为合成器属性,滑动零 reflow */ /* 档位圆点 */ .dot { @@ -477,9 +496,10 @@ onBeforeUnmount(() => { background: var(--effort-dot-covered); } -/* 拖柄 */ +/* 拖柄:left 恒 0,水平位置由 JS 写入 transform translate */ .knob { position: absolute; + left: 0; top: 50%; width: 46px; height: 46px; diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 596ded20..c4c11261 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -2590,7 +2590,8 @@ const adjustTextareaSize = () => { target.style.height = 'auto'; const computedStyle = window.getComputedStyle(target); const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20; - const maxHeight = lineHeight * 6; + // 输入框最大高度:10 行(超出后内部滚动) + const maxHeight = lineHeight * 10; const targetHeight = Math.min(target.scrollHeight, maxHeight); const lines = Math.max(1, Math.round(targetHeight / lineHeight)); const multiline = targetHeight > lineHeight * 1.4; diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index 4d9c8ebc..58636491 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -727,11 +727,17 @@ export async function initializeLegacySocket(ctx: any) { ctx.socket.on('todo_updated', (data) => { socketLog('收到todo更新事件:', data); - // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 - if (ctx.initialRouteResolved && data && data.conversation_id) { - ctx.currentConversationId = data.conversation_id; + // todo_updated 是全局广播:子智能体/其他对话的待办也会推到这里, + // 必须只响应当前对话,否则快捷窗口会串出子智能体的待办。 + // 也不能用事件里的 conversation_id 覆盖当前对话 id —— 那会把 + // 子智能体的对话 id 安到当前会话上(对话 id 同步走 conversation_resolved)。 + if (!data || !data.conversation_id) { + return; } - ctx.fileSetTodoList((data && data.todo_list) || null); + if (data.conversation_id !== ctx.currentConversationId) { + return; + } + ctx.fileSetTodoList(data.todo_list || null); }); // 快捷窗口:本次对话编辑/创建文件记录更新