From bf136304d7f5d457f688fcf4942160ef01c6ff6f Mon Sep 17 00:00:00 2001 From: jerry Date: Tue, 1 Sep 2026 17:47:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=9B=BE=E7=89=87=2050%=20?= =?UTF-8?q?=E5=AE=BD=E5=B8=83=E5=B1=80=20+=20=E8=BF=9E=E7=BB=AD=E5=90=8C?= =?UTF-8?q?=E6=AF=94=E4=BE=8B=E5=8F=8C=E5=9B=BE=E5=B9=B6=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- tests/frontend/unit/b2/figureLayout.test.js | 159 ++++++++++++++++++++ ui/src/components/chat/AssistantMessage.vue | 60 +++++++- ui/src/components/chat/figureLayout.js | 140 +++++++++++++++++ 3 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 tests/frontend/unit/b2/figureLayout.test.js create mode 100644 ui/src/components/chat/figureLayout.js diff --git a/tests/frontend/unit/b2/figureLayout.test.js b/tests/frontend/unit/b2/figureLayout.test.js new file mode 100644 index 0000000..16b6f09 --- /dev/null +++ b/tests/frontend/unit/b2/figureLayout.test.js @@ -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('同一

内仅空白相隔 → 相邻', () => { + const p = document.createElement('p'); + const a = fig(); + const b = fig(); + p.append(a, document.createTextNode(' '), b); + expect(isInlineAdjacent(a, b)).toBe(true); + }); + it('相邻兄弟

→ 相邻(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('

'); + 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( + '

' + ); + 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( + '

' + ); + 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('

'); + 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( + '

' + ); + 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( + '

分隔文字

' + ); + 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); + }); +}); diff --git a/ui/src/components/chat/AssistantMessage.vue b/ui/src/components/chat/AssistantMessage.vue index 227f2c1..24fb85b 100644 --- a/ui/src/components/chat/AssistantMessage.vue +++ b/ui/src/components/chat/AssistantMessage.vue @@ -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 {{ T.assistantName }} {{ message.timeLabel }} -
+