agent-Specialization/static/src/components/chat/EditSummaryCard.vue
JOJO bc8337ab09 feat(ui): 编辑摘要弹窗位置钉死、外部滚动与窗口缩放即关、hover 延迟 600ms 且打开后瞬间切换
Co-authored-by: Astrion powered by DeepSeek-V4-Flash <astrion-agent@users.noreply.github.com>
2026-08-29 15:26:39 +08:00

650 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
/**
* 本次工作编辑摘要卡片Edit Summary Card
*
* 数据来自发起本轮工作的 user 消息 metadata.edit_summary后端在
* write_file/edit_file 成功后实时写入并广播),渲染在该工作段最后一条
* assistant 消息末尾。同一文件多次编辑只展示合并后的最终结果(净变化)。
*
* 交互:
* - 桌面端 hover 行600ms 意图延迟)打开居中浮窗;离开行与浮窗后自动关闭;
* 弹窗已打开时鼠标移到其他行,瞬间切换内容(不再等延迟)
* - 点击行「钉住」弹窗(带遮罩 + 关闭按钮);移动端无 hover点击为唯一入口
* - 弹窗位置/高度在展开瞬间一次性计算并钉死,之后不再重算
* - 外部滚动 / 窗口缩放:直接关闭弹窗;钉住弹窗内部滚动放行(可滚动查看长 diff
* hover 弹窗内部滚动同样关闭
* - Esc 关闭
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useUiStore } from '@/stores/ui';
const props = defineProps<{
summary: any;
iconStyle: (key: string, size?: string) => Record<string, string>;
}>();
const uiStore = useUiStore();
const isMobile = computed(() => !!uiStore.isMobileViewport);
interface SummaryFile {
path: string;
status?: string;
added?: number;
removed?: number;
lines?: any[];
truncated?: boolean;
}
const files = computed<SummaryFile[]>(() => {
const list = props.summary?.files;
return Array.isArray(list) ? list.filter((f: any) => f && f.path) : [];
});
const totals = computed(() => {
let added = 0;
let removed = 0;
for (const f of files.value) {
added += Number(f.added) || 0;
removed += Number(f.removed) || 0;
}
return { added, removed };
});
// ---------------- 弹窗状态 ----------------
const activePath = ref<string | null>(null);
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 closeTimer: ReturnType<typeof setTimeout> | null = null;
const activeFile = computed<SummaryFile | null>(() => {
if (!activePath.value) return 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;
// 桌面端弹窗最大高度上限2026-08-22 恢复):与 CSS 基础值 min(70vh, 640px) 对齐,
// 在动态可用空间之上再取一次 min避免高视口下弹窗占满半屏以上
const PANEL_MAX_HEIGHT_VH_RATIO = 0.7;
const PANEL_MAX_HEIGHT_PX = 640;
// 条目上方至少要有这么多可用空间才放上面,否则优先考虑下方
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
);
// 垂直:优先放条目上方(底边 = 条目上边缘 - 半个条目高,向上生长);
// 上方空间不足且下方更宽裕时改放下方(顶边 = 条目下边缘 + 半个条目高,向下生长)。
// 高度上限 = min(该方向可用空间, 70vh, 640px),保底 MIN_PANEL_HEIGHT。
const maxCapHeight = Math.min(vh * PANEL_MAX_HEIGHT_VH_RATIO, PANEL_MAX_HEIGHT_PX);
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(Math.min(bottomEdge - VIEWPORT_MARGIN, maxCapHeight))}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(Math.min(vh - VIEWPORT_MARGIN - topEdge, maxCapHeight))}px`
};
}
}
// 弹窗打开后位置/高度钉死(只在 open 时计算一次),滚动不再重算位置:
// - 外部滚动(对话区/页面):一律关闭弹窗
// - 内部滚动diff 内容钉住弹窗放行hover 弹窗也关闭
// - 滚动/缩放关闭后短暂抑制重开,避免弹窗消失瞬间鼠标落在文件行上导致「关了又开」
let suppressOpenUntil = 0;
function onScrollCapture(e: Event) {
if (!activePath.value) return;
const target = e.target as Element | null;
const isInsidePanel = target instanceof Element && !!target.closest('.es-modal');
if (isInsidePanel && pinned.value) return;
suppressOpenUntil = Date.now() + 250;
close();
}
function onResizeClose() {
if (!activePath.value) return;
suppressOpenUntil = Date.now() + 250;
close();
}
function clearOpenTimer() {
if (openTimer) {
clearTimeout(openTimer);
openTimer = null;
}
}
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
}
function open(path: string, pin: boolean, rowEl: HTMLElement | null = null) {
clearOpenTimer();
clearCloseTimer();
activePath.value = path;
pinned.value = pin;
anchorEl.value = rowEl;
computePanelStyle();
}
// hover 打开意图延迟;弹窗已打开时切换到另一行则瞬间切换,不等待此延迟
const HOVER_OPEN_DELAY = 600;
function scheduleOpen(path: string, rowEl: HTMLElement | null) {
clearOpenTimer();
if (Date.now() < suppressOpenUntil) return;
if (activePath.value) {
// 弹窗已打开:鼠标移到另一行,瞬间切换(重新定位 + 换内容)
open(path, false, rowEl);
return;
}
openTimer = setTimeout(() => open(path, false, rowEl), HOVER_OPEN_DELAY);
}
function scheduleClose() {
if (pinned.value) return;
clearCloseTimer();
closeTimer = setTimeout(() => {
activePath.value = null;
}, 200);
}
function onRowMouseEnter(path: string, e: MouseEvent) {
if (isMobile.value) return;
clearCloseTimer();
scheduleOpen(path, (e.currentTarget as HTMLElement) || null);
}
function onRowMouseLeave() {
if (isMobile.value) return;
clearOpenTimer();
scheduleClose();
}
function onPanelMouseEnter() {
if (!isMobile.value) clearCloseTimer();
}
function onPanelMouseLeave() {
if (!isMobile.value) scheduleClose();
}
function onRowClick(path: string, e: MouseEvent) {
// 点击 = 钉住(桌面与移动端一致;移动端无 hover这是唯一入口
open(path, true, (e.currentTarget as HTMLElement) || null);
}
function close() {
clearOpenTimer();
clearCloseTimer();
pinned.value = false;
activePath.value = null;
anchorEl.value = null;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && activePath.value) {
close();
}
}
watch(activePath, (v) => {
if (v) {
window.addEventListener('keydown', onKeydown);
// 窗口缩放:直接关闭(钉死位置可能跑出视口),不做重算
window.addEventListener('resize', onResizeClose);
// 捕获阶段监听滚动:外部滚动一律关闭,钉住弹窗内部滚动放行
window.addEventListener('scroll', onScrollCapture, true);
} else {
window.removeEventListener('keydown', onKeydown);
window.removeEventListener('resize', onResizeClose);
window.removeEventListener('scroll', onScrollCapture, true);
}
});
onBeforeUnmount(() => {
clearOpenTimer();
clearCloseTimer();
window.removeEventListener('keydown', onKeydown);
window.removeEventListener('resize', onResizeClose);
window.removeEventListener('scroll', onScrollCapture, true);
});
// ---------------- diff 行渲染辅助 ----------------
function lineClass(line: any): string {
if (line.type === 'add') return 'es-diff-add';
if (line.type === 'remove') return 'es-diff-remove';
return 'es-diff-context';
}
function lineMarker(line: any): string {
if (line.type === 'add') return '+';
if (line.type === 'remove') return '-';
return '';
}
function lineNumber(line: any): string {
const n = line.type === 'remove' ? line.old_no : line.new_no;
return typeof n === 'number' && Number.isFinite(n) ? String(n) : '';
}
</script>
<template>
<div v-if="files.length" class="edit-summary-card" ref="cardRef">
<div class="edit-summary-card__header">
<span
class="icon icon-sm edit-summary-card__icon"
:style="props.iconStyle('filePen')"
aria-hidden="true"
></span>
<span class="edit-summary-card__title">{{ $t('chat.filesEdited', { n: files.length }) }}</span>
<span class="edit-summary-card__totals">
<span class="edit-summary-plus">+{{ totals.added }}</span>
<span class="edit-summary-minus">-{{ totals.removed }}</span>
</span>
</div>
<div class="edit-summary-card__list">
<div
v-for="file in files"
:key="file.path"
class="edit-summary-card__row"
:class="{ 'is-active': pinned && activePath === file.path }"
@mouseenter="onRowMouseEnter(file.path, $event)"
@mouseleave="onRowMouseLeave"
@click.stop="onRowClick(file.path, $event)"
>
<span class="edit-summary-card__path" :title="file.path">{{ file.path }}</span>
<span class="edit-summary-card__delta">
<span class="edit-summary-plus">+{{ file.added || 0 }}</span>
<span class="edit-summary-minus">-{{ file.removed || 0 }}</span>
</span>
</div>
</div>
<!-- 居中 diff 弹窗:桌面 hover 非模态,钉住/移动端带透明点击层(点击外部关闭,不虚化周围) -->
<teleport to="body">
<div v-if="activeFile" class="es-modal">
<div
v-if="pinned || isMobile"
class="es-modal__overlay"
aria-hidden="true"
@click="close"
></div>
<div
class="es-modal__panel"
:style="panelStyle"
role="dialog"
:aria-label="$t('chat.fileChanges', { path: activeFile.path })"
@mouseenter="onPanelMouseEnter"
@mouseleave="onPanelMouseLeave"
>
<div class="es-modal__header">
<span
class="icon icon-sm es-modal__header-icon"
:style="props.iconStyle('filePen')"
aria-hidden="true"
></span>
<span class="es-modal__path" :title="activeFile.path">{{ activeFile.path }}</span>
<span class="es-modal__delta">
<span class="edit-summary-plus">+{{ activeFile.added || 0 }}</span>
<span class="edit-summary-minus">-{{ activeFile.removed || 0 }}</span>
</span>
<button
v-if="pinned || isMobile"
type="button"
class="es-modal__close"
:aria-label="$t('common.close')"
@click.stop="close"
>
<span class="icon icon-sm" :style="props.iconStyle('x')" aria-hidden="true"></span>
</button>
</div>
<div class="es-modal__body">
<template v-if="(activeFile.lines || []).length">
<template v-for="(line, i) in activeFile.lines" :key="i">
<div v-if="line.type === 'sep'" class="es-diff-sep" aria-hidden="true">⋮</div>
<div v-else class="es-diff-line" :class="lineClass(line)">
<span class="es-diff-line-number">{{ lineNumber(line) }}</span>
<span class="es-diff-marker">{{ lineMarker(line) }}</span>
<span class="es-diff-content">{{ line.content }}</span>
</div>
</template>
<div v-if="activeFile.truncated" class="es-diff-note">{{ $t('chat.diffTruncated') }}</div>
</template>
<div v-else class="es-diff-empty">{{ $t('chat.diffEmpty') }}</div>
</div>
</div>
</div>
</teleport>
</div>
</template>
<style>
/* ===== 卡片本体(消息流内嵌) ===== */
.edit-summary-card {
margin-top: 10px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-raised);
overflow: hidden;
}
.edit-summary-card__header {
display: flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 12px;
border-bottom: 1px solid var(--border-default);
}
.edit-summary-card__icon {
color: var(--text-secondary);
flex: none;
}
.edit-summary-card__title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-summary-card__totals {
display: flex;
align-items: center;
gap: 6px;
flex: none;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.edit-summary-plus {
color: var(--state-success);
}
.edit-summary-minus {
color: var(--state-danger);
}
/* 列表最多显示 5 行32px/行),超出内部滚动;滚动条隐藏(同 QuickDock 惯例) */
.edit-summary-card__list {
max-height: 160px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.edit-summary-card__list::-webkit-scrollbar {
display: none;
}
.edit-summary-card__row {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 12px;
cursor: pointer;
font-size: 12.5px;
}
/* is-active 仅「点击钉住」时挂载用于标识正在查看的文件hover 预览不挂,
否则鼠标离开后会被 200ms 关闭计时器拖住,看起来像 hover 消失有延迟 */
.edit-summary-card__row:hover,
.edit-summary-card__row.is-active {
/* 用全局 hover token深色下是白色微量 tint提亮避免 surface-soft 的近黑衬底 */
background: var(--hover-bg);
}
.edit-summary-card__path {
flex: 1;
min-width: 0;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-summary-card__delta {
display: flex;
align-items: center;
gap: 6px;
flex: none;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* ===== 居中 diff 弹窗 ===== */
.es-modal {
position: fixed;
inset: 0;
z-index: 1200;
pointer-events: none;
}
.es-modal__overlay {
position: absolute;
inset: 0;
pointer-events: auto;
}
.es-modal__panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(720px, calc(100vw - 32px));
max-height: min(70vh, 640px);
display: flex;
flex-direction: column;
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-strong);
overflow: hidden;
pointer-events: auto;
}
.es-modal__header {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 12px;
flex: none;
/* 与版本管理弹窗一致:头部不加衬底,靠分隔线区分(深色下避免近黑色块) */
border-bottom: 1px solid var(--border-default);
}
.es-modal__header-icon {
color: var(--text-secondary);
flex: none;
}
.es-modal__path {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.es-modal__delta {
display: flex;
align-items: center;
gap: 6px;
flex: none;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.es-modal__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: none;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
}
.es-modal__close:hover {
background: var(--surface-muted);
color: var(--text-primary);
}
.es-modal__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 0;
font-size: 12.5px;
line-height: 1.6;
}
/* ===== diff 行(与文件编辑工具结果 / 版本管理弹窗同一视觉体系) ===== */
.es-diff-line {
display: flex;
align-items: flex-start;
min-height: 20px;
padding-right: 12px;
}
.es-diff-line-number {
width: 44px;
flex: none;
padding-right: 8px;
text-align: right;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
user-select: none;
}
.es-diff-marker {
width: 16px;
flex: none;
text-align: center;
color: var(--text-secondary);
user-select: none;
}
.es-diff-content {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-primary);
}
.es-diff-add {
background: var(--diff-add-bg);
}
.es-diff-add .es-diff-marker {
color: var(--state-success);
}
.es-diff-remove {
background: var(--diff-del-bg);
}
.es-diff-remove .es-diff-marker {
color: var(--state-danger);
}
.es-diff-sep {
padding: 2px 0 2px 60px;
color: var(--text-secondary);
user-select: none;
}
.es-diff-empty,
.es-diff-note {
padding: 12px;
font-size: 12.5px;
color: var(--text-secondary);
}
/* 移动端:弹窗更宽、行号列收窄 */
@media (max-width: 768px) {
.es-modal__panel {
width: calc(100vw - 24px);
max-height: 76vh;
}
.es-diff-line-number {
width: 34px;
}
.es-diff-sep {
padding-left: 50px;
}
}
</style>