fix(frontend): 修复推理滑块动画卡顿、快捷窗口待办跨对话串数据、输入框高度回退
- EffortSlider: 圆钮/填充改走 transform + clip-path 合成器属性,rAF 直写 DOM 绕过 Vue 响应式,消除每帧重渲染与 reflow - useLegacySocket: todo_updated 全局广播按 conversation_id 过滤,不再串入子智能体待办;移除用事件 conv id 覆盖当前对话 id 的危险逻辑 - watchers: 修复 currentConversationId 重复键互相覆盖导致清空逻辑从未注册的问题,合并为单个 watcher(/new 页面不再残留待办) - InputComposer: 输入框最大高度 6 行 -> 10 行
This commit is contained in:
parent
77a271fa23
commit
3ea8d9ad00
@ -8,23 +8,6 @@ export const watchers = {
|
|||||||
multiAgentMode(newValue) {
|
multiAgentMode(newValue) {
|
||||||
useConversationStore().$patch({ 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() {
|
inputMessage() {
|
||||||
this.autoResizeInput();
|
this.autoResizeInput();
|
||||||
if (typeof this.scheduleComposerDraftPersist === 'function') {
|
if (typeof this.scheduleComposerDraftPersist === 'function') {
|
||||||
@ -85,6 +68,23 @@ export const watchers = {
|
|||||||
currentConversationId: {
|
currentConversationId: {
|
||||||
immediate: false,
|
immediate: false,
|
||||||
handler(newValue, oldValue) {
|
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 变化', {
|
debugLog('currentConversationId 变化', {
|
||||||
oldValue,
|
oldValue,
|
||||||
newValue,
|
newValue,
|
||||||
|
|||||||
@ -15,21 +15,27 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const trackRef = ref<HTMLElement | null>(null);
|
const trackRef = ref<HTMLElement | null>(null);
|
||||||
|
const knobRef = ref<HTMLElement | null>(null);
|
||||||
|
const fillRef = ref<HTMLElement | null>(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 trackWidth = ref(0);
|
||||||
// 当前档位(记忆值,勾选默认时也保留)
|
// 当前档位(记忆值,勾选默认时也保留)
|
||||||
const levelIndex = ref(2);
|
const levelIndex = ref(2);
|
||||||
// 圆钮/填充的目标像素位置(拖动时连续,否则吸附档位)
|
|
||||||
const knobX = ref(0);
|
|
||||||
// 圆钮/填充的视觉像素位置(JS rAF 插值驱动):点击/吸附时从当前
|
|
||||||
// 视觉位置平滑滑向目标,天然连续无瞬移;圆点 covered 也以此为准 ——
|
|
||||||
// 变色只在圆钮实际滑到/离开时发生,往低调档时被变色的点会被圆钮
|
|
||||||
// 遮住渐变过程,不会暴露「白→黑」的闪烁
|
|
||||||
const knobXVisual = ref(0);
|
|
||||||
const activeLevel = ref(2);
|
const activeLevel = ref(2);
|
||||||
const dragging = ref(false);
|
const dragging = ref(false);
|
||||||
const liveDrag = ref(false);
|
const liveDrag = ref(false);
|
||||||
let downX = 0;
|
let downX = 0;
|
||||||
let animFrame = 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);
|
const isDefault = computed(() => props.modelValue === null);
|
||||||
|
|
||||||
@ -55,6 +61,29 @@ const KNOB_ANIM_MS = 200;
|
|||||||
// 与 CSS ease 接近的 easeInOutQuad
|
// 与 CSS ease 接近的 easeInOutQuad
|
||||||
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
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 = () => {
|
const cancelKnobAnim = () => {
|
||||||
if (animFrame) {
|
if (animFrame) {
|
||||||
cancelAnimationFrame(animFrame);
|
cancelAnimationFrame(animFrame);
|
||||||
@ -64,18 +93,20 @@ const cancelKnobAnim = () => {
|
|||||||
|
|
||||||
const setKnobInstant = (x: number) => {
|
const setKnobInstant = (x: number) => {
|
||||||
cancelKnobAnim();
|
cancelKnobAnim();
|
||||||
knobXVisual.value = x;
|
visualX = x;
|
||||||
|
applyVisualX(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从当前视觉位置插值滑动到目标(进行中的动画被接续,不会跳变)
|
// 从当前视觉位置插值滑动到目标(进行中的动画被接续,不会跳变)
|
||||||
const animateKnobTo = (target: number) => {
|
const animateKnobTo = (target: number) => {
|
||||||
const start = knobXVisual.value;
|
const start = visualX;
|
||||||
if (start === target) return;
|
if (start === target) return;
|
||||||
cancelKnobAnim();
|
cancelKnobAnim();
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const step = (now: number) => {
|
const step = (now: number) => {
|
||||||
const p = Math.min(1, (now - t0) / KNOB_ANIM_MS);
|
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) {
|
if (p < 1) {
|
||||||
animFrame = requestAnimationFrame(step);
|
animFrame = requestAnimationFrame(step);
|
||||||
} else {
|
} else {
|
||||||
@ -88,7 +119,6 @@ const animateKnobTo = (target: number) => {
|
|||||||
// animate=true:点击/吸附时平滑滑动;false:挂载/resize/外部变更时瞬间就位
|
// animate=true:点击/吸附时平滑滑动;false:挂载/resize/外部变更时瞬间就位
|
||||||
const syncToLevel = (animate = false) => {
|
const syncToLevel = (animate = false) => {
|
||||||
const target = posPx(levelIndex.value);
|
const target = posPx(levelIndex.value);
|
||||||
knobX.value = target;
|
|
||||||
activeLevel.value = levelIndex.value;
|
activeLevel.value = levelIndex.value;
|
||||||
if (animate && trackWidth.value > EDGE * 2) {
|
if (animate && trackWidth.value > EDGE * 2) {
|
||||||
animateKnobTo(target);
|
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 = () => {
|
const commitLevel = () => {
|
||||||
emit('update:modelValue', LEVELS[levelIndex.value]);
|
emit('update:modelValue', LEVELS[levelIndex.value]);
|
||||||
};
|
};
|
||||||
@ -131,8 +149,8 @@ const onPointerMove = (e: PointerEvent) => {
|
|||||||
// 真实拖动:无动画连续跟随
|
// 真实拖动:无动画连续跟随
|
||||||
const x = clampEventX(e.clientX);
|
const x = clampEventX(e.clientX);
|
||||||
cancelKnobAnim();
|
cancelKnobAnim();
|
||||||
knobX.value = x;
|
visualX = x;
|
||||||
knobXVisual.value = x;
|
applyVisualX(x);
|
||||||
activeLevel.value = pxToLevel(x);
|
activeLevel.value = pxToLevel(x);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -224,7 +242,7 @@ onBeforeUnmount(() => {
|
|||||||
@pointerup="onPointerUp"
|
@pointerup="onPointerUp"
|
||||||
@pointercancel="onPointerUp"
|
@pointercancel="onPointerUp"
|
||||||
>
|
>
|
||||||
<div class="fill-stack" :style="{ width: fillWidthStyle }">
|
<div ref="fillRef" class="fill-stack">
|
||||||
<div
|
<div
|
||||||
v-for="(_, i) in LEVELS"
|
v-for="(_, i) in LEVELS"
|
||||||
:key="`lv-${i}`"
|
:key="`lv-${i}`"
|
||||||
@ -236,10 +254,10 @@ onBeforeUnmount(() => {
|
|||||||
v-for="(_, i) in LEVELS"
|
v-for="(_, i) in LEVELS"
|
||||||
:key="`dot-${i}`"
|
:key="`dot-${i}`"
|
||||||
class="dot"
|
class="dot"
|
||||||
:class="{ covered: dotCovered(i) }"
|
:ref="(el) => setDotRef(el as Element | null, i)"
|
||||||
:style="{ left: posPct(i) }"
|
:style="{ left: posPct(i) }"
|
||||||
></div>
|
></div>
|
||||||
<div class="knob" :style="{ left: knobLeftStyle }"></div>
|
<div ref="knobRef" class="knob"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-labels">
|
<div class="level-labels">
|
||||||
<span
|
<span
|
||||||
@ -373,13 +391,14 @@ onBeforeUnmount(() => {
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 填充:5 层叠放,当前档淡入、其余淡出 —— 任何颜色切换都是平滑 crossfade */
|
/* 填充:5 层叠放,当前档淡入、其余淡出 —— 任何颜色切换都是平滑 crossfade。
|
||||||
|
宽度恒 100%,可见宽度由 JS 写入 clip-path 控制(合成器属性,无 reflow) */
|
||||||
.fill-stack {
|
.fill-stack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 23px;
|
width: 100%;
|
||||||
border-radius: 17px;
|
border-radius: 17px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -458,8 +477,8 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 圆钮与填充位置由 JS rAF 插值驱动(见 script):点击/吸附平滑滑动、
|
/* 圆钮与填充位置由 JS 直写 DOM 驱动(见 script applyVisualX):
|
||||||
拖动连续跟随、圆点变色时机与圆钮视觉位置严格同步 —— 不用 CSS transition */
|
knob 走 transform、fill 走 clip-path,均为合成器属性,滑动零 reflow */
|
||||||
|
|
||||||
/* 档位圆点 */
|
/* 档位圆点 */
|
||||||
.dot {
|
.dot {
|
||||||
@ -477,9 +496,10 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--effort-dot-covered);
|
background: var(--effort-dot-covered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 拖柄 */
|
/* 拖柄:left 恒 0,水平位置由 JS 写入 transform translate */
|
||||||
.knob {
|
.knob {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 46px;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
|
|||||||
@ -2590,7 +2590,8 @@ const adjustTextareaSize = () => {
|
|||||||
target.style.height = 'auto';
|
target.style.height = 'auto';
|
||||||
const computedStyle = window.getComputedStyle(target);
|
const computedStyle = window.getComputedStyle(target);
|
||||||
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
|
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
|
||||||
const maxHeight = lineHeight * 6;
|
// 输入框最大高度:10 行(超出后内部滚动)
|
||||||
|
const maxHeight = lineHeight * 10;
|
||||||
const targetHeight = Math.min(target.scrollHeight, maxHeight);
|
const targetHeight = Math.min(target.scrollHeight, maxHeight);
|
||||||
const lines = Math.max(1, Math.round(targetHeight / lineHeight));
|
const lines = Math.max(1, Math.round(targetHeight / lineHeight));
|
||||||
const multiline = targetHeight > lineHeight * 1.4;
|
const multiline = targetHeight > lineHeight * 1.4;
|
||||||
|
|||||||
@ -727,11 +727,17 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
|
|
||||||
ctx.socket.on('todo_updated', (data) => {
|
ctx.socket.on('todo_updated', (data) => {
|
||||||
socketLog('收到todo更新事件:', data);
|
socketLog('收到todo更新事件:', data);
|
||||||
// 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突
|
// todo_updated 是全局广播:子智能体/其他对话的待办也会推到这里,
|
||||||
if (ctx.initialRouteResolved && data && data.conversation_id) {
|
// 必须只响应当前对话,否则快捷窗口会串出子智能体的待办。
|
||||||
ctx.currentConversationId = data.conversation_id;
|
// 也不能用事件里的 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 快捷窗口:本次对话编辑/创建文件记录更新
|
// 快捷窗口:本次对话编辑/创建文件记录更新
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user