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) {
|
||||
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,
|
||||
|
||||
@ -15,21 +15,27 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
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 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"
|
||||
>
|
||||
<div class="fill-stack" :style="{ width: fillWidthStyle }">
|
||||
<div ref="fillRef" class="fill-stack">
|
||||
<div
|
||||
v-for="(_, i) in LEVELS"
|
||||
:key="`lv-${i}`"
|
||||
@ -236,10 +254,10 @@ onBeforeUnmount(() => {
|
||||
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) }"
|
||||
></div>
|
||||
<div class="knob" :style="{ left: knobLeftStyle }"></div>
|
||||
<div ref="knobRef" class="knob"></div>
|
||||
</div>
|
||||
<div class="level-labels">
|
||||
<span
|
||||
@ -373,13 +391,14 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
// 快捷窗口:本次对话编辑/创建文件记录更新
|
||||
|
||||
Loading…
Reference in New Issue
Block a user