feat(chat): 编辑摘要卡片弹窗改为锚定定位并修复深色近黑样式

- 弹窗水平相对编辑卡片居中,垂直优先锚定条目上方(空间不足自动翻到下方),高度吃满可用空间
- 行 hover 改用 --hover-bg(深色提亮),弹窗头部去近黑衬底改用分隔线
- 行高亮 is-active 仅钉住态挂载,修复 hover 消失延迟
This commit is contained in:
JOJO 2026-08-11 23:38:47 +08:00
parent 56a0acdad2
commit 79f8ffa368

View File

@ -49,6 +49,11 @@ const totals = computed(() => {
// ---------------- ---------------- // ---------------- ----------------
const activePath = ref<string | null>(null); const activePath = ref<string | null>(null);
const pinned = ref(false); const pinned = ref(false);
const cardRef = ref<HTMLElement | null>(null);
const anchorEl = ref<HTMLElement | null>(null);
//
// 退 CSS
const panelStyle = ref<Record<string, string>>({});
let openTimer: ReturnType<typeof setTimeout> | null = null; let openTimer: ReturnType<typeof setTimeout> | null = null;
let closeTimer: ReturnType<typeof setTimeout> | null = null; let closeTimer: ReturnType<typeof setTimeout> | null = null;
@ -57,6 +62,76 @@ const activeFile = computed<SummaryFile | null>(() => {
return files.value.find((f) => f.path === activePath.value) || null; 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<string, string> = {
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() { function clearOpenTimer() {
if (openTimer) { if (openTimer) {
clearTimeout(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(); clearOpenTimer();
clearCloseTimer(); clearCloseTimer();
activePath.value = path; activePath.value = path;
pinned.value = pin; pinned.value = pin;
anchorEl.value = rowEl;
computePanelStyle();
} }
function scheduleOpen(path: string) { function scheduleOpen(path: string, rowEl: HTMLElement | null) {
clearOpenTimer(); clearOpenTimer();
openTimer = setTimeout(() => open(path, false), 150); openTimer = setTimeout(() => open(path, false, rowEl), 150);
} }
function scheduleClose() { function scheduleClose() {
@ -91,10 +168,10 @@ function scheduleClose() {
}, 200); }, 200);
} }
function onRowMouseEnter(path: string) { function onRowMouseEnter(path: string, e: MouseEvent) {
if (isMobile.value) return; if (isMobile.value) return;
clearCloseTimer(); clearCloseTimer();
scheduleOpen(path); scheduleOpen(path, (e.currentTarget as HTMLElement) || null);
} }
function onRowMouseLeave() { function onRowMouseLeave() {
@ -111,9 +188,9 @@ function onPanelMouseLeave() {
if (!isMobile.value) scheduleClose(); if (!isMobile.value) scheduleClose();
} }
function onRowClick(path: string) { function onRowClick(path: string, e: MouseEvent) {
// = hover // = hover
open(path, true); open(path, true, (e.currentTarget as HTMLElement) || null);
} }
function close() { function close() {
@ -121,6 +198,7 @@ function close() {
clearCloseTimer(); clearCloseTimer();
pinned.value = false; pinned.value = false;
activePath.value = null; activePath.value = null;
anchorEl.value = null;
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
@ -132,8 +210,13 @@ function onKeydown(e: KeyboardEvent) {
watch(activePath, (v) => { watch(activePath, (v) => {
if (v) { if (v) {
window.addEventListener('keydown', onKeydown); window.addEventListener('keydown', onKeydown);
window.addEventListener('resize', onWindowReposition);
//
window.addEventListener('scroll', onWindowReposition, true);
} else { } else {
window.removeEventListener('keydown', onKeydown); window.removeEventListener('keydown', onKeydown);
window.removeEventListener('resize', onWindowReposition);
window.removeEventListener('scroll', onWindowReposition, true);
} }
}); });
@ -141,6 +224,8 @@ onBeforeUnmount(() => {
clearOpenTimer(); clearOpenTimer();
clearCloseTimer(); clearCloseTimer();
window.removeEventListener('keydown', onKeydown); window.removeEventListener('keydown', onKeydown);
window.removeEventListener('resize', onWindowReposition);
window.removeEventListener('scroll', onWindowReposition, true);
}); });
// ---------------- diff ---------------- // ---------------- diff ----------------
@ -163,7 +248,7 @@ function lineNumber(line: any): string {
</script> </script>
<template> <template>
<div v-if="files.length" class="edit-summary-card"> <div v-if="files.length" class="edit-summary-card" ref="cardRef">
<div class="edit-summary-card__header"> <div class="edit-summary-card__header">
<span <span
class="icon icon-sm edit-summary-card__icon" class="icon icon-sm edit-summary-card__icon"
@ -181,10 +266,10 @@ function lineNumber(line: any): string {
v-for="file in files" v-for="file in files"
:key="file.path" :key="file.path"
class="edit-summary-card__row" class="edit-summary-card__row"
:class="{ 'is-active': activePath === file.path }" :class="{ 'is-active': pinned && activePath === file.path }"
@mouseenter="onRowMouseEnter(file.path)" @mouseenter="onRowMouseEnter(file.path, $event)"
@mouseleave="onRowMouseLeave" @mouseleave="onRowMouseLeave"
@click.stop="onRowClick(file.path)" @click.stop="onRowClick(file.path, $event)"
> >
<span class="edit-summary-card__path" :title="file.path">{{ file.path }}</span> <span class="edit-summary-card__path" :title="file.path">{{ file.path }}</span>
<span class="edit-summary-card__delta"> <span class="edit-summary-card__delta">
@ -205,6 +290,7 @@ function lineNumber(line: any): string {
></div> ></div>
<div <div
class="es-modal__panel" class="es-modal__panel"
:style="panelStyle"
role="dialog" role="dialog"
:aria-label="`文件变更:${activeFile.path}`" :aria-label="`文件变更:${activeFile.path}`"
@mouseenter="onPanelMouseEnter" @mouseenter="onPanelMouseEnter"
@ -313,9 +399,12 @@ function lineNumber(line: any): string {
font-size: 12.5px; font-size: 12.5px;
} }
/* is-active hover
否则鼠标离开后会被 200ms 关闭计时器拖住看起来像 hover 消失有延迟 */
.edit-summary-card__row:hover, .edit-summary-card__row:hover,
.edit-summary-card__row.is-active { .edit-summary-card__row.is-active {
background: var(--surface-soft); /* 用全局 hover token深色下是白色微量 tint提亮避免 surface-soft 的近黑衬底 */
background: var(--hover-bg);
} }
.edit-summary-card__path { .edit-summary-card__path {
@ -374,8 +463,8 @@ function lineNumber(line: any): string {
height: 44px; height: 44px;
padding: 0 12px; padding: 0 12px;
flex: none; flex: none;
/* 与版本管理弹窗一致:头部不加衬底,靠分隔线区分(深色下避免近黑色块) */
border-bottom: 1px solid var(--border-default); border-bottom: 1px solid var(--border-default);
background: var(--surface-soft);
} }
.es-modal__header-icon { .es-modal__header-icon {