diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 53b842c3..90693baf 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4950,11 +4950,27 @@ - 根因:窄屏用 `showModal()` 对话框加不透明 backdrop,并把聊天设为 `inert`;高度约 86dvh / 100dvh,聊天被遮死。 - 修复:窄屏改为工作区分栏下半屏 sheet(约 46% 高),聊天留在上半屏可继续阅读;关闭改为 44px 图标按钮,Escape 仍可关闭。 - 验证:`frontend/tests/rectification-agentic-entry.test.ts`。 -- 防复发:窄屏盘面不得再用 modal dialog / backdrop 盖住聊天;关闭控件必须是独立 44px 目标并带 `aria-label="关闭盘面"`。 -- 相关记录:BUG-322、BUG-326 +- 防复发:关闭控件必须是独立 44px 目标并带 `aria-label="关闭盘面"`。窄屏分层策略以 BUG-330 为准:必须用高于输入框的覆盖层,不得再与输入框做同层网格分栏。 +- 相关记录:BUG-322、BUG-326、BUG-330 - 复发自:BUG-322(分栏后窄屏改成对话框,聊天被挡住) - 修复版本:994f0583 +## BUG-330 | 移动端盘面与输入框同层叠字,无法作为弹出层使用 + +- 状态:resolved +- 首次发现:2026-08-20 +- 最近更新:2026-08-20 +- 影响面:生时纠正窄屏盘面、`rectification-board`、输入框 +- 用户现象:点开“当前盘面”后,盘面标题与输入区“当前盘面”文案叠在一起;宫位表像嵌在输入框同一层,而不是从下往上盖住聊天列表和输入框。 +- 触发条件:窄于分栏阈值的视口打开宫位表。 +- 根因:BUG-328 把窄屏盘面改成工作区第二行(约 46% 高)且未设叠层;输入框 `.composer-wrap` 仍是 `position: relative; z-index: 2`。盘面与输入框在同一工作区网格里重叠时,输入框画在盘面标题之上。 +- 修复:仅在视口宽度小于 768px 的移动端,把盘面改成工作区内 `z-index: 20` 的底部 sheet 覆盖层;桌面网页仍是右侧分栏,不出现底部弹出。遮罩点击、Escape、44px 关闭按钮可关;聊天列在打开时 `inert`。 +- 验证:`frontend/tests/rectification-agentic-entry.test.ts`。 +- 防复发:底部 sheet 只允许 `matchMedia(max-width: 767px)` 触发,不得用聊天区 `clientWidth` 把桌面侧栏挤窄误判成移动端;桌面必须保持 `grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem)`。窄屏盘面必须是覆盖层(absolute、高于输入框的 z-index、不透明背景)。 +- 相关记录:BUG-322、BUG-326、BUG-328 +- 复发自:BUG-328(当时为了露出聊天改成下半屏分栏,叠层低于输入框) +- 修复版本:待发布 + ## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止 - 状态:resolved diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4b37a095..9dca3ade 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -322,6 +322,7 @@ button:disabled { cursor: default; opacity: .45; } @keyframes onboarding-caret { 50% { opacity: 0; } } @keyframes account-overlay-enter { from { opacity: 0; } } @keyframes account-dialog-enter { from { opacity: 0; transform: translateY(var(--space-1)) scale(.985); } } +@keyframes rectification-sheet-enter { from { opacity: 0; transform: translateY(18%); } } @media (hover: hover) { .session-list button:not(:disabled):hover, .profile-trigger:not(:disabled):hover { background: var(--color-light-hover); color: var(--color-ink); } @@ -1874,6 +1875,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .birth-time-clock-menu .select-item { justify-content: flex-start; } .rectification-workspace { + position: relative; min-width: 0; min-height: 0; height: 100%; @@ -1883,9 +1885,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .rectification-workspace.is-compact { grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); } -.rectification-workspace.is-compact.is-board-open { - grid-template-rows: minmax(8rem, 1fr) minmax(13rem, 46%); +.rectification-workspace.is-compact .rectification-workspace__chat, +.rectification-workspace.is-compact .rectification-board-overlay { + grid-area: 1 / 1; } .rectification-workspace__chat { min-width: 0; @@ -1913,13 +1917,42 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class background: var(--color-canvas-soft); color: var(--color-ink); } +.rectification-workspace.is-compact .rectification-board-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + align-items: end; + isolation: isolate; +} +.rectification-board-scrim { + position: absolute; + inset: 0; + border: 0; + background: var(--color-scrim); + cursor: pointer; + animation: account-overlay-enter 180ms ease-out both; +} .rectification-board.is-sheet { + position: relative; + z-index: 1; + width: 100%; + height: 92%; + max-height: 100%; + min-height: 0; + overflow: hidden; border-inline-start: 0; border-block-start: 1px solid var(--color-border); border-start-start-radius: var(--radius-lg); border-start-end-radius: var(--radius-lg); - box-shadow: 0 -8px 28px color-mix(in srgb, var(--color-ink) 10%, transparent); + background: var(--color-canvas); + box-shadow: 0 -12px 32px color-mix(in srgb, var(--color-ink) 12%, transparent); padding-bottom: env(safe-area-inset-bottom); + animation: rectification-sheet-enter 280ms var(--ease-out) both; + grid-template-rows: auto auto minmax(0, 1fr); +} +.rectification-board.is-sheet .rectification-board__header { + background: var(--color-canvas); } .rectification-board__handle { width: 36px; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 1660fb7f..021d1241 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -266,17 +266,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const boardTitleId = useId(); useLayoutEffect(() => { - const node = workspace.current; - if (!node) return; + const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`); const update = () => { - const nextCompact = node.clientWidth < RECTIFICATION_BOARD_SPLIT_MIN_PX; + const nextCompact = query.matches; setCompactBoard(nextCompact); if (!nextCompact) setBoardOpen(false); }; update(); - const observer = new ResizeObserver(update); - observer.observe(node); - return () => observer.disconnect(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); }, []); useEffect(() => { @@ -689,7 +687,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ref={workspace} className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`} > -
+
{pendingConsultationQuestion?.trim() && ( diff --git a/frontend/src/components/rectification-board.tsx b/frontend/src/components/rectification-board.tsx index 66499a04..f0248b34 100644 --- a/frontend/src/components/rectification-board.tsx +++ b/frontend/src/components/rectification-board.tsx @@ -1,7 +1,7 @@ "use client"; import { X } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { groupWindowTransitions, rectificationBoardPeekCopy, @@ -210,8 +210,11 @@ export function RectificationBoard({ titleId: string; onClose: () => void; }>) { + const sheetRef = useRef(null); + useEffect(() => { if (!compact || !open) return; + sheetRef.current?.focus(); const handleKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; @@ -221,11 +224,15 @@ export function RectificationBoard({ if (compact && !open) return null; - return ( + const sheet = ( ); + + if (!compact) return sheet; + + return ( +
+ + ); } export function RectificationBoardPeek({ diff --git a/frontend/src/lib/rectification-board-model.ts b/frontend/src/lib/rectification-board-model.ts index b870e89a..13493b5e 100644 --- a/frontend/src/lib/rectification-board-model.ts +++ b/frontend/src/lib/rectification-board-model.ts @@ -11,7 +11,7 @@ import { type RectificationHouseTable, } from "./rectification-candidate-result"; -export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 832; +export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 768; export type GroupedWindowMinute = Readonly<{ at: string; diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index ee009a3a..caa4ff5e 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -28,6 +28,10 @@ const board = readFileSync( new URL("../src/components/rectification-board.tsx", import.meta.url), "utf8", ); +const boardModel = readFileSync( + new URL("../src/lib/rectification-board-model.ts", import.meta.url), + "utf8", +); const houseTable = readFileSync( new URL("../src/components/rectification-house-table.tsx", import.meta.url), "utf8", @@ -331,6 +335,11 @@ test("candidate state renders from the snapshot API and never from sentinels", ( assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/); assert.match(styles, /\.rectification-workspace \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(18rem, 22\.5rem\)/); assert.match(styles, /\.rectification-workspace\.is-compact \{[\s\S]*grid-template-columns: minmax\(0, 1fr\)/); + assert.match(chat, /matchMedia\(`\(max-width: \$\{RECTIFICATION_BOARD_SPLIT_MIN_PX - 1\}px\)`\)/); + assert.match(chat, /nextCompact = query\.matches/); + assert.doesNotMatch(chat, /clientWidth < RECTIFICATION_BOARD_SPLIT_MIN_PX/); + assert.match(boardModel, /RECTIFICATION_BOARD_SPLIT_MIN_PX = 768/); + assert.match(board, /if \(!compact\) return sheet;/); assert.doesNotMatch(styles, /\.rectification-snapshot \{[^}]*--assistant-content-inset/); assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*padding-inline-start:\s*var\(--assistant-content-inset\)/); assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*--assistant-content-inset/); @@ -369,17 +378,28 @@ test("the natal house table is a live board beside the chat, not a message", () assert.match(board, /event\.key === "Escape"/); assert.match(board, /aria-label="关闭盘面"/); assert.match(board, /className="rectification-board__handle"/); + assert.match(board, /className="rectification-board-overlay"/); + assert.match(board, /className="rectification-board-scrim"/); + assert.match(board, /role=\{compact \? "dialog" : undefined\}/); + assert.match(board, /aria-modal=\{compact \? true : undefined\}/); assert.match(chat, /RectificationBoardPeek/); assert.match(chat, /onOpen=\{toggleBoard\}/); - assert.doesNotMatch(chat, /inert=\{compactBoard && boardOpen/); + assert.match(chat, /inert=\{compactBoard && boardOpen \? true : undefined\}/); assert.match(houseTable, /当前本命宫位/); assert.match(houseTable, /补充经历后会按新线索重算/); assert.doesNotMatch(houseTable, / { - assert.match(styles, /\.rectification-workspace\.is-compact\.is-board-open \{[\s\S]*grid-template-rows: minmax\(8rem, 1fr\) minmax\(13rem, 46%\)/); +test("compact board overlays chat as a bottom sheet above the composer", () => { + assert.doesNotMatch(styles, /\.rectification-workspace\.is-compact\.is-board-open \{[\s\S]*grid-template-rows: minmax\(8rem, 1fr\) minmax\(13rem, 46%\)/); + assert.match(styles, /\.rectification-workspace\.is-compact \.rectification-board-overlay \{[\s\S]*position: absolute/); + assert.match(styles, /\.rectification-workspace\.is-compact \.rectification-board-overlay \{[\s\S]*inset: 0/); + assert.match(styles, /\.rectification-workspace\.is-compact \.rectification-board-overlay \{[\s\S]*z-index: 20/); + assert.match(styles, /\.rectification-workspace__chat \.composer-wrap \{[\s\S]*z-index: 2/); + assert.match(styles, /\.rectification-board\.is-sheet \{[\s\S]*height: 92%/); + assert.match(styles, /\.rectification-board\.is-sheet \{[\s\S]*background: var\(--color-canvas\)/); assert.match(styles, /\.rectification-board\.is-sheet \{[\s\S]*border-block-start: 1px solid var\(--color-border\)/); + assert.match(styles, /@keyframes rectification-sheet-enter \{[\s\S]*translateY\(18%\)/); assert.doesNotMatch(styles, /dialog\.rectification-board/); assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/); assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);