From 79f8ffa3689c14be5beb4d223d596ee2f6fba1a8 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 11 Aug 2026 23:38:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E7=BC=96=E8=BE=91=E6=91=98?= =?UTF-8?q?=E8=A6=81=E5=8D=A1=E7=89=87=E5=BC=B9=E7=AA=97=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=94=9A=E5=AE=9A=E5=AE=9A=E4=BD=8D=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=B7=B1=E8=89=B2=E8=BF=91=E9=BB=91=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 弹窗水平相对编辑卡片居中,垂直优先锚定条目上方(空间不足自动翻到下方),高度吃满可用空间 - 行 hover 改用 --hover-bg(深色提亮),弹窗头部去近黑衬底改用分隔线 - 行高亮 is-active 仅钉住态挂载,修复 hover 消失延迟 --- .../src/components/chat/EditSummaryCard.vue | 115 ++++++++++++++++-- 1 file changed, 102 insertions(+), 13 deletions(-) diff --git a/static/src/components/chat/EditSummaryCard.vue b/static/src/components/chat/EditSummaryCard.vue index b63a999b..53a454b5 100644 --- a/static/src/components/chat/EditSummaryCard.vue +++ b/static/src/components/chat/EditSummaryCard.vue @@ -49,6 +49,11 @@ const totals = computed(() => { // ---------------- 弹窗状态 ---------------- const activePath = ref(null); const pinned = ref(false); +const cardRef = ref(null); +const anchorEl = ref(null); +// 桌面端弹窗的内联定位样式(水平相对卡片居中、垂直锚定条目上方); +// 移动端保持空对象,回退到 CSS 的屏幕居中布局。 +const panelStyle = ref>({}); let openTimer: ReturnType | null = null; let closeTimer: ReturnType | null = null; @@ -57,6 +62,76 @@ const activeFile = computed(() => { return files.value.find((f) => f.path === activePath.value) || null; }); +// ---------------- 弹窗定位(桌面端) ---------------- +const PANEL_MAX_WIDTH = 720; +const VIEWPORT_MARGIN = 16; +const MIN_PANEL_HEIGHT = 140; +// 条目上方至少要有这么多可用空间才放上面,否则优先考虑下方 +const PREFER_ABOVE_MIN_HEIGHT = 260; + +function computePanelStyle() { + if (isMobile.value || !cardRef.value || !anchorEl.value) { + // 移动端 / 无锚点:回退到 CSS 的屏幕居中布局 + panelStyle.value = {}; + return; + } + const vw = window.innerWidth; + const vh = window.innerHeight; + const width = Math.min(PANEL_MAX_WIDTH, vw - VIEWPORT_MARGIN * 2); + + // 水平:相对编辑卡片左右居中(而不是相对屏幕),并夹取在视口内 + const cardRect = cardRef.value.getBoundingClientRect(); + const centerX = cardRect.left + cardRect.width / 2; + const left = Math.min( + Math.max(centerX - width / 2, VIEWPORT_MARGIN), + vw - width - VIEWPORT_MARGIN + ); + + // 垂直:优先放条目上方(底边 = 条目上边缘 - 半个条目高,向上生长); + // 上方空间不足且下方更宽裕时改放下方(顶边 = 条目下边缘 + 半个条目高,向下生长)。 + // 两个方向的高度都吃满该方向的可用空间,无固定上限。 + const rowRect = anchorEl.value.getBoundingClientRect(); + const anchorAbove = rowRect.top - rowRect.height / 2; + const anchorBelow = rowRect.bottom + rowRect.height / 2; + const spaceAbove = anchorAbove - VIEWPORT_MARGIN; + const spaceBelow = vh - VIEWPORT_MARGIN - anchorBelow; + const placeAbove = spaceAbove >= PREFER_ABOVE_MIN_HEIGHT || spaceAbove >= spaceBelow; + + const base: Record = { + left: `${Math.round(left)}px`, + width: `${Math.round(width)}px`, + transform: 'none' + }; + if (placeAbove) { + const bottomEdge = Math.min( + Math.max(anchorAbove, VIEWPORT_MARGIN + MIN_PANEL_HEIGHT), + vh - VIEWPORT_MARGIN + ); + panelStyle.value = { + ...base, + top: 'auto', + bottom: `${Math.round(vh - bottomEdge)}px`, + maxHeight: `${Math.round(bottomEdge - VIEWPORT_MARGIN)}px` + }; + } else { + const topEdge = Math.min( + Math.max(anchorBelow, VIEWPORT_MARGIN), + vh - VIEWPORT_MARGIN - MIN_PANEL_HEIGHT + ); + panelStyle.value = { + ...base, + top: `${Math.round(topEdge)}px`, + bottom: 'auto', + maxHeight: `${Math.round(vh - VIEWPORT_MARGIN - topEdge)}px` + }; + } +} + +function onWindowReposition() { + if (!activePath.value) return; + computePanelStyle(); +} + function clearOpenTimer() { if (openTimer) { clearTimeout(openTimer); @@ -71,16 +146,18 @@ function clearCloseTimer() { } } -function open(path: string, pin: boolean) { +function open(path: string, pin: boolean, rowEl: HTMLElement | null = null) { clearOpenTimer(); clearCloseTimer(); activePath.value = path; pinned.value = pin; + anchorEl.value = rowEl; + computePanelStyle(); } -function scheduleOpen(path: string) { +function scheduleOpen(path: string, rowEl: HTMLElement | null) { clearOpenTimer(); - openTimer = setTimeout(() => open(path, false), 150); + openTimer = setTimeout(() => open(path, false, rowEl), 150); } function scheduleClose() { @@ -91,10 +168,10 @@ function scheduleClose() { }, 200); } -function onRowMouseEnter(path: string) { +function onRowMouseEnter(path: string, e: MouseEvent) { if (isMobile.value) return; clearCloseTimer(); - scheduleOpen(path); + scheduleOpen(path, (e.currentTarget as HTMLElement) || null); } function onRowMouseLeave() { @@ -111,9 +188,9 @@ function onPanelMouseLeave() { if (!isMobile.value) scheduleClose(); } -function onRowClick(path: string) { +function onRowClick(path: string, e: MouseEvent) { // 点击 = 钉住(桌面与移动端一致;移动端无 hover,这是唯一入口) - open(path, true); + open(path, true, (e.currentTarget as HTMLElement) || null); } function close() { @@ -121,6 +198,7 @@ function close() { clearCloseTimer(); pinned.value = false; activePath.value = null; + anchorEl.value = null; } function onKeydown(e: KeyboardEvent) { @@ -132,8 +210,13 @@ function onKeydown(e: KeyboardEvent) { watch(activePath, (v) => { if (v) { window.addEventListener('keydown', onKeydown); + window.addEventListener('resize', onWindowReposition); + // 捕获阶段监听滚动,让弹窗在对话区滚动时始终贴着锚定条目 + window.addEventListener('scroll', onWindowReposition, true); } else { window.removeEventListener('keydown', onKeydown); + window.removeEventListener('resize', onWindowReposition); + window.removeEventListener('scroll', onWindowReposition, true); } }); @@ -141,6 +224,8 @@ onBeforeUnmount(() => { clearOpenTimer(); clearCloseTimer(); window.removeEventListener('keydown', onKeydown); + window.removeEventListener('resize', onWindowReposition); + window.removeEventListener('scroll', onWindowReposition, true); }); // ---------------- diff 行渲染辅助 ---------------- @@ -163,7 +248,7 @@ function lineNumber(line: any): string {