feat(chat): 图片 50% 宽布局 + 连续同比例双图并排

- 新增 figureLayout.js:sameAspect(容差2%)/pairFigures(贪心配对)/layoutTextRoot(幂等)
- AssistantMessage:图片 load 后读 naturalWidth/Height 重排,单图 half 类,双图包 figure-row
- 相邻判定兼容 HTML 解析器把 figure 提出 p 后留下的空 p
- CSS 修 :deep()(原 scoped 规则从未命中 v-html),单图 50%、row 内各 calc(50% - 7px) + 14px 间距
- 新测试 16 例,全量前端 174 例全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-09-01 17:47:37 +08:00
co-authored by Claude Fable 5
parent f81971b176
commit bf136304d7
3 changed files with 352 additions and 7 deletions
+159
View File
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
// figureLayout.test.js —— 聊天图片布局纯函数(2026-09-01)
// 需求:单图 50% 宽;连续两张比例相同的图并排一行各 50%,留间距。
import { describe, it, expect } from 'vitest';
import { aspectOf, sameAspect, pairFigures, isInlineAdjacent, layoutTextRoot } from '../../../../ui/src/components/chat/figureLayout.js';
const fig = () => document.createElement('figure');
describe('aspectOf / sameAspect', () => {
it('未加载(0 值)返回 null', () => {
expect(aspectOf(0, 0)).toBe(null);
expect(aspectOf(0, 100)).toBe(null);
expect(aspectOf(100, 0)).toBe(null);
});
it('宽高比计算', () => {
expect(aspectOf(100, 50)).toBe(2);
expect(aspectOf(800, 400)).toBe(2);
});
it('容差 2% 内判同比例', () => {
expect(sameAspect(1, 1)).toBe(true);
expect(sameAspect(1.0, 1.01)).toBe(true);
expect(sameAspect(1.0, 1.03)).toBe(false);
expect(sameAspect(1, null)).toBe(false);
expect(sameAspect(null, null)).toBe(false);
expect(sameAspect(4 / 3, 800 / 600)).toBe(true);
});
});
describe('pairFigures 贪心配对', () => {
const figs = [fig(), fig(), fig(), fig()];
it('相邻同比例 → 配对;不同 → 落单', () => {
const g = pairFigures([figs[0], figs[1]], [1.5, 1.5]);
expect(g).toHaveLength(1);
expect(g[0].row).toBe(true);
expect(g[0].figures).toEqual([figs[0], figs[1]]);
const g2 = pairFigures([figs[0], figs[1]], [1.5, 1.0]);
expect(g2).toHaveLength(2);
expect(g2.every((x) => !x.row)).toBe(true);
});
it('3 张连续同比例 = 1 对 + 1 单', () => {
const g = pairFigures([figs[0], figs[1], figs[2]], [1, 1, 1]);
expect(g).toHaveLength(2);
expect(g[0].row).toBe(true);
expect(g[1].row).toBe(false);
});
it('空列表', () => {
expect(pairFigures([], [])).toEqual([]);
});
it('比例未知(null)不配对', () => {
const g = pairFigures([figs[0], figs[1]], [null, null]);
expect(g).toHaveLength(2);
expect(g.every((x) => !x.row)).toBe(true);
});
});
describe('isInlineAdjacent 连续性', () => {
it('同一 <p> 内仅空白相隔 → 相邻', () => {
const p = document.createElement('p');
const a = fig();
const b = fig();
p.append(a, document.createTextNode(' '), b);
expect(isInlineAdjacent(a, b)).toBe(true);
});
it('相邻兄弟 <p> → 相邻(markdown-it 逐图成段)', () => {
const wrap = document.createElement('div');
const pa = document.createElement('p');
const pb = document.createElement('p');
pa.append(fig());
pb.append(fig());
wrap.append(pa, pb);
expect(isInlineAdjacent(pa.firstChild, pb.firstChild)).toBe(true);
});
it('中间有文字段落 → 不相邻', () => {
const wrap = document.createElement('div');
const pa = document.createElement('p');
const pm = document.createElement('p');
const pb = document.createElement('p');
pa.append(fig());
pm.textContent = '说明文字';
pb.append(fig());
wrap.append(pa, pm, pb);
expect(isInlineAdjacent(pa.firstChild, pb.firstChild)).toBe(false);
});
});
describe('layoutTextRoot 幂等布局', () => {
function makeRoot(html) {
const root = document.createElement('div');
root.className = 'md-text';
root.innerHTML = html;
return root;
}
function readyImg(w, h) {
const img = document.createElement('img');
Object.defineProperty(img, 'naturalWidth', { value: w });
Object.defineProperty(img, 'naturalHeight', { value: h });
return img;
}
it('单图 → half 类,无 row', () => {
const root = makeRoot('<p><figure class="md-figure"><img></figure></p>');
root.querySelector('img').replaceWith(readyImg(800, 400));
const r = layoutTextRoot(root);
expect(r.done).toBe(true);
expect(root.querySelectorAll('.figure-row').length).toBe(0);
expect(root.querySelector('.md-figure').classList.contains('half')).toBe(true);
});
it('连续同比例双图 → 一个 figure-row,无 half', () => {
const root = makeRoot(
'<p><figure class="md-figure"><img></figure></p><p><figure class="md-figure"><img></figure></p>'
);
root.querySelectorAll('img').forEach((img) => img.replaceWith(readyImg(800, 400)));
const r = layoutTextRoot(root);
expect(r.done).toBe(true);
expect(root.querySelectorAll('.figure-row').length).toBe(1);
const row = root.querySelector('.figure-row');
expect(row.children.length).toBe(2);
expect(root.querySelectorAll('.md-figure.half').length).toBe(0);
});
it('比例不同的连续双图 → 各自 half,无 row', () => {
const root = makeRoot(
'<p><figure class="md-figure"><img></figure></p><p><figure class="md-figure"><img></figure></p>'
);
const imgs = root.querySelectorAll('img');
imgs[0].replaceWith(readyImg(800, 400));
imgs[1].replaceWith(readyImg(400, 800));
const r = layoutTextRoot(root);
expect(r.done).toBe(true);
expect(root.querySelectorAll('.figure-row').length).toBe(0);
expect(root.querySelectorAll('.md-figure.half').length).toBe(2);
});
it('图未加载 → 返回 pending,不布局', () => {
const root = makeRoot('<p><figure class="md-figure"><img></figure></p>');
const r = layoutTextRoot(root);
expect(r.done).toBe(false);
expect(r.pending.length).toBe(1);
expect(root.querySelector('.md-figure').classList.contains('half')).toBe(false);
});
it('重复调用幂等(重排前拆旧 row / 清 half)', () => {
const root = makeRoot(
'<p><figure class="md-figure"><img></figure></p><p><figure class="md-figure"><img></figure></p>'
);
root.querySelectorAll('img').forEach((img) => img.replaceWith(readyImg(800, 400)));
layoutTextRoot(root);
expect(root.querySelectorAll('.figure-row').length).toBe(1);
layoutTextRoot(root);
expect(root.querySelectorAll('.figure-row').length).toBe(1);
expect(root.querySelectorAll('.md-figure.half').length).toBe(0);
});
it('中间夹文字段落的双图 → 各自 half,无 row', () => {
const root = makeRoot(
'<p><figure class="md-figure"><img></figure></p><p>分隔文字</p><p><figure class="md-figure"><img></figure></p>'
);
root.querySelectorAll('img').forEach((img) => img.replaceWith(readyImg(800, 400)));
const r = layoutTextRoot(root);
expect(r.done).toBe(true);
expect(root.querySelectorAll('.figure-row').length).toBe(0);
expect(root.querySelectorAll('.md-figure.half').length).toBe(2);
});
});
+53 -7
View File
@@ -3,9 +3,10 @@
// 块类型:text(markdown-it + DOMPurify 消毒后 v-html)/ table(渐变表头 + 非「说明」列 mono)/
// code / stream(光标 + shimmer-line,瞬态不落库)/ pipeline(五相位卡)/
// tool(阶段卡)/ sources(内联引用 chip)/ graph(轻量摘要行,完整图在 bench kg tab)
import { computed, ref } from 'vue';
import { computed, onMounted, onUpdated, onUnmounted, ref } from 'vue';
import { T, BENCH_TABS } from '../../i18n/labels.js';
import { sanitizeHtml, NON_MONO_HEADER } from './markdown.js';
import { layoutTextRoot } from './figureLayout.js';
import PipelineCard from './PipelineCard.vue';
import ToolCard from './ToolCard.vue';
@@ -43,6 +44,36 @@ const onLightboxKeydown = (e) => {
if (e.key === 'Escape') closeLightbox();
};
// ---------- 图文布局(2026-09-01):单图 50% 宽;连续同比例双图并排 ----------
const bodyEl = ref(null);
let layoutTimer = null;
/** 对消息体所有 text 块跑布局;返回仍在等加载的 img 列表(比例未知) */
const runFigureLayout = () => {
if (!bodyEl.value) return;
for (const root of bodyEl.value.querySelectorAll('.md-text')) {
layoutTextRoot(root);
}
};
const scheduleLayout = () => {
if (layoutTimer) clearTimeout(layoutTimer);
layoutTimer = setTimeout(runFigureLayout, 300);
};
// 图片加载完成后立即重排(capture 委托,与 onImageError 同机制)
const onImageLoad = (e) => {
if (e.target instanceof HTMLImageElement && e.target.closest('.md-figure')) {
scheduleLayout();
}
};
onMounted(scheduleLayout);
onUpdated(scheduleLayout);
onUnmounted(() => {
if (layoutTimer) clearTimeout(layoutTimer);
});
// text 块 html 在创建/落库时已渲染为消毒 HTML(markdown.js renderMarkdown);
// 渲染前再消毒一次(纵深防御,FR-CHAT-004:LLM 输出必须消毒)
const renderedText = (html) => sanitizeHtml(html);
@@ -94,10 +125,10 @@ const showRetry = computed(() => !!(props.onRetry && props.message.status === 'e
<span class="who">{{ T.assistantName }}</span>
<span class="when">{{ message.timeLabel }}</span>
</div>
<div class="msg-body">
<div ref="bodyEl" class="msg-body" @load.capture="onImageLoad">
<template v-for="(b, bi) in message.blocks" :key="bi">
<!-- text:markdown-it → DOMPurify → v-html(FR-CHAT-004);
图片事件经 capture 委托绑定(点击放大 / 加载失败隐藏) -->
图片事件经 capture 委托绑定(点击放大 / 加载失败隐藏 / 加载完成重排) -->
<div
v-if="b.type === 'text'"
class="md-text"
@@ -434,23 +465,38 @@ const showRetry = computed(() => !!(props.onRetry && props.message.status === 'e
color: var(--pa-ink-muted);
}
/* ---------- 图文并茂(2026-08-28) ---------- */
.md-figure {
/* ---------- 图文并茂(2026-08-28 图注块;2026-09-01 布局:单图 50% 宽 / 连续同比例双图并排) ---------- */
/* v-html 内容不带 scope 属性,必须 :deep() 才能命中 */
:deep(.md-figure) {
margin: 14px 0;
cursor: zoom-in;
width: 50%; /* 默认半宽(单图);figure-row 内由 flex-basis 接管 */
}
.md-figure img {
:deep(.md-figure img) {
display: block;
width: 100%;
max-width: 100%;
border-radius: var(--pa-r-md);
border: 1px solid var(--pa-hairline);
}
.md-figure figcaption {
:deep(.md-figure figcaption) {
font-size: 11.5px;
color: var(--pa-ink-subtle);
margin-top: 6px;
line-height: 1.5;
}
/* 连续同比例双图并排:gap 14px,两侧 figure 各占 calc(50% - 7px);
一张 onerror 隐藏后另一张仍保持半宽,不会撑满 */
:deep(.figure-row) {
display: flex;
gap: 14px;
align-items: flex-start;
margin: 14px 0;
}
:deep(.figure-row .md-figure) {
flex: 0 0 calc(50% - 7px);
margin: 0;
}
.figure-lightbox {
position: fixed;
inset: 0;
+140
View File
@@ -0,0 +1,140 @@
// chat/figureLayout.js —— 聊天图片布局(2026-09-01)
// 需求:回答中的图片默认占页面宽度 50%;连续两张比例相同的图片并排一行各占 50%,留适当间距。
// 图片比例需读 naturalWidth/naturalHeight(img load 后才可用),因此布局在组件运行时用 JS 完成。
// 纯函数导出便于单测;DOM 操作仅在 layoutTextRoot 内。
/** 宽高比;w/h 无效(未加载)返回 null */
export function aspectOf(w, h) {
return w > 0 && h > 0 ? w / h : null;
}
/** 两个比例是否相同(相对误差 ≤ tol,默认 2%) */
export function sameAspect(a, b, tol = 0.02) {
if (a == null || b == null) return false;
return Math.abs(a - b) / Math.max(a, b) <= tol;
}
/** 空块元素(无子元素且无文本,如 HTML 解析器把 figure 提出 <p> 后留下的空 <p>)视为空白 */
const isEmptyBlock = (node) =>
node.nodeType === 1 && !node.querySelector('*') && node.textContent.trim() === '';
/** x、y 之间的兄弟节点是否只有空白文本/空块元素(无实体内容) */
export function betweenWhitespaceOnly(x, y) {
let node = x.nextSibling;
while (node && node !== y) {
if (node.nodeType === 3) {
if (node.textContent.trim() !== '') return false;
} else if (node.nodeType === 1) {
if (!isEmptyBlock(node)) return false;
} else {
return false;
}
node = node.nextSibling;
}
return node === y;
}
/**
* 两张 figure 是否「连续出现」:
* - 同一 <p> 内(行内图)仅空白相隔;或
* - 各自所在 <p> 是相邻兄弟且仅空白相隔(markdown-it 逐图成段的标准形态)。
*/
export function isInlineAdjacent(a, b) {
const ca = a.closest('p') || a;
const cb = b.closest('p') || b;
if (ca === cb) return betweenWhitespaceOnly(a, b);
if (!ca.parentElement || ca.parentElement !== cb.parentElement) return false;
return betweenWhitespaceOnly(ca, cb);
}
/**
* 从左到右贪心配对:相邻且同比例 → { figures:[a,b], row:true };
* 落单 → { figures:[f], row:false }。3 张连续同比例 = 1 对 + 1 单。
* @param {HTMLElement[]} figures figure.md-figure 元素(DOM 顺序)
* @param {(number|null)[]} aspects 与 figures 等长的宽高比数组
*/
export function pairFigures(figures, aspects) {
const groups = [];
let i = 0;
while (i < figures.length) {
if (i + 1 < figures.length && sameAspect(aspects[i], aspects[i + 1])) {
groups.push({ figures: [figures[i], figures[i + 1]], row: true });
i += 2;
} else {
groups.push({ figures: [figures[i]], row: false });
i += 1;
}
}
return groups;
}
/**
* 对一个 .md-text 根做布局(幂等,可反复调用):
* 1. 拆掉旧的 .figure-row 包裹、清 half 类;
* 2. 图片未加载完成(naturalWidth=0)→ 返回 pending img 列表,等 load 后重排;
* 3. 按「连续出现」切 run,run 内相邻同比例 → 包 .figure-row;落单 → figure 加 .half。
* @returns {{done: boolean, pending: HTMLImageElement[]}}
*/
export function layoutTextRoot(root) {
// 加载失败的图片由组件层隐藏(figure.style.display='none'),不参与布局
const figures = Array.from(root.querySelectorAll('.md-figure')).filter(
(f) => !(f instanceof HTMLElement && f.style.display === 'none')
);
// 幂等:拆旧包裹
for (const row of root.querySelectorAll('.figure-row')) {
const parent = row.parentElement;
while (row.firstChild) parent.insertBefore(row.firstChild, row);
parent.removeChild(row);
}
figures.forEach((f) => f.classList.remove('half'));
const aspects = figures.map((f) => {
const img = f.querySelector('img');
return img ? aspectOf(img.naturalWidth || 0, img.naturalHeight || 0) : null;
});
const pending = [];
figures.forEach((f, i) => {
if (aspects[i] == null) {
const img = f.querySelector('img');
if (img) pending.push(img);
}
});
if (pending.length) return { done: false, pending };
// 按连续性切 run
const runs = [];
let run = [];
for (const f of figures) {
if (run.length && !isInlineAdjacent(run[run.length - 1], f)) {
runs.push(run);
run = [];
}
run.push(f);
}
if (run.length) runs.push(run);
for (const r of runs) {
const groups = pairFigures(
r,
r.map((f) => {
const img = f.querySelector('img');
return aspectOf(img.naturalWidth, img.naturalHeight);
})
);
for (const g of groups) {
if (g.row) {
const row = document.createElement('div');
row.className = 'figure-row';
const parent = g.figures[0].parentElement;
parent.insertBefore(row, g.figures[0]);
row.appendChild(g.figures[0]);
row.appendChild(g.figures[1]);
} else {
g.figures[0].classList.add('half');
}
}
}
return { done: true, pending: [] };
}