桌面加载和打开本命盘不再把整页卸掉再挂上(BUG-1444)。
侧栏和对话列始终留在原来的容器里。分栏到达、窗口跨过手机断点,子组件只挂载一次。 检查:在 frontend 下跑 shell-frame-stable-mount、sidebar-contract、chat-chart-panel-state、class-name-definition-contract。 结果:52 通过,0 失败,退出码 0。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
2e690f654c
commit
38097ca82f
@@ -19626,3 +19626,19 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/full-data-parity-3-20261022`。
|
||||
|
||||
## BUG-1444 | 桌面加载后整页被卸掉再挂上
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-26
|
||||
- 最近更新:2026-10-26
|
||||
- 影响面:桌面外壳和对话页旁边的本命盘。手机抽屉的开关方式没改。
|
||||
- 用户现象:桌面打开网站,或把窗口从手机宽度拉到桌面,整页会再闪一次,正在生成的回答可能丢掉。第一次打开旁边的本命盘时,对话列也会重新挂上,输入到一半的内容和滚动位置会丢。
|
||||
- 触发条件:可拖分栏的代码还没到时,外壳是普通容器;代码到达后改成另一套面板组件,把整页包进去。本命盘同理,对话列被挪进分栏组件里。窗口跨过手机断点时再来一次。
|
||||
- 根因:两种状态下,页面内容的祖先元素类型变了。React 会卸载再重挂,启动效果跑两次。
|
||||
- 修复:侧栏和主区始终留在各自的容器里。拖宽用侧栏容器里的把手,夹在 200 到 360 之间,拖到比 200 更窄就收成 64。不再另载一套分栏库包住整页。本命盘作为对话列的兄弟,第一次打开后加在旁边,不挪走对话列。不用 `key` 掩盖重挂。
|
||||
- 验证:`frontend/tests/shell-frame-stable-mount.test.tsx` 在分栏到达前后、以及视口 500 与 1440 之间,侧栏和对话列都是挂载 1 次、卸载 0 次。同一批还跑了 `sidebar-contract.test.ts`、`chat-chart-panel-state.test.ts`、`class-name-definition-contract.test.ts`,合计 52 通过、0 失败、退出码 0。
|
||||
- 防复发:上面的挂载计数测试。再把页面包进后到达的面板里,计数会从 1 变成 2。
|
||||
- 相关记录:issue #111 验收 U1。#105、#106。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 进度:组件库与启程事后修复(2026-10-26)
|
||||
|
||||
分支 `codex/ui-kit-onboarding-fix-20261026`。只推这个分支。验收通过前不合进测试环境。承载 issue #111。BUG 从 1444 起。
|
||||
|
||||
## U1 · 桌面加载不再把整页卸掉再挂上(BUG-1444)
|
||||
|
||||
侧栏和主区始终在各自的容器里。拖宽用侧栏里的把手,宽 200–360,默认 288,拖到比 200 更窄收成 64。本命盘加在对话列旁边,不把对话列挪进后到达的分栏里。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 挂载与相关合同 | `node node_modules/tsx/dist/cli.mjs --test tests/shell-frame-stable-mount.test.tsx tests/sidebar-contract.test.ts tests/chat-chart-panel-state.test.ts tests/class-name-definition-contract.test.ts` | 52 通过,0 失败,退出码 0 |
|
||||
|
||||
断言三栏(`sidebar-contract.test.ts`「selects desktop and tablet shell widths」):原值是网格列分出 288 / 240 / 64,并检查 `minSize={SIDEBAR_WIDTH_MIN}`。新值检查横向 flex、`shell-sidebar-slot` / `shell-main-slot`,以及宽度上下限常量。原因是可拖面板不再包住整页,页面在分栏到达或跨断点时不再卸载。
|
||||
|
||||
全量 `npm test`、`npm run build`、`npm run test:db`、Python 快速门留到本单全部改完再跑。
|
||||
@@ -40,7 +40,7 @@
|
||||
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
|
||||
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
|
||||
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | — | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 待执行(#111 承载) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` |
|
||||
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
|
||||
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
|
||||
|
||||
+2
-2
@@ -1267,13 +1267,13 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
|
||||
## 外壳(2026-10-24)
|
||||
|
||||
桌面侧栏在可拖分栏里,宽 200–360px,默认 288。拖到最左收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动、拖宽和拖动排序用的库在页面出来后再载入,不进首屏。
|
||||
桌面侧栏宽 200–360px,默认 288,拖右侧把手调整。拖到比 200 更窄就收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。侧栏和主区始终在各自的容器里:视口就绪、跨过手机断点,都不会把整页卸掉再挂上。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动和拖动排序用的库在页面出来后再载入,不进首屏。拖宽不再另载一套分栏库。
|
||||
|
||||
## 对话页(2026-10-25)
|
||||
|
||||
输入框仍是一个框。框底左边是模型菜单,只显示模型名;价格「N 点」只在菜单里。菜单用外壳已有的延后载入,不进首屏;库还没到时按钮上仍是模型名。右边是圆形发送,和框的底边对齐。回答下面赞、踩、复制、重新生成是 26px 的图标按钮,各有一句提示。不满意原因和兑换窗用组件库对话框,句子不改。追问是描边小按钮,仍按启程进场依次浮出(260ms,间隔 64ms),不另写一套时长。
|
||||
|
||||
桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560px,默认 360。拖到最窄可以收起。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。手机(视口还没定,或小于 768)不显示这个按钮,也不因为藏起来就把「开着」写成关掉。面板里是当前人物的本命盘,复用星盘页的绘制,不写骨架、不写「正在加载」。可拖分栏和星盘绘制都在第一次打开后面才载入,不进首屏。
|
||||
桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板宽 280–560px,默认 360。拖到最窄可以收起。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。手机(视口还没定,或小于 768)不显示这个按钮,也不因为藏起来就把「开着」写成关掉。面板里是当前人物的本命盘,复用星盘页的绘制,不写骨架、不写「正在加载」。对话列始终留在原处。本命盘在第一次打开后载入,加在对话列旁边,不把对话列卸掉再挂上。星盘绘制不进首屏。
|
||||
|
||||
## 新用户启程(2026-10-25)
|
||||
|
||||
|
||||
@@ -823,11 +823,13 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
.group\/sidebar-provider[data-viewport] { height: 100vh; min-height: 0; overflow: hidden; }
|
||||
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: clip; display: flex; flex-direction: column; background: var(--color-canvas); }
|
||||
.shell-frame { display: flex; flex: 1 1 auto; flex-direction: row; min-width: 0; min-height: 0; }
|
||||
.shell-frame[data-shell-resize="pending"] > :first-child { flex: 0 0 var(--shell-sidebar-width, 288px); width: var(--shell-sidebar-width, 288px); min-width: 0; }
|
||||
.shell-sidebar-slot { position: relative; flex: 0 0 var(--shell-sidebar-width, 288px); width: var(--shell-sidebar-width, 288px); min-width: 0; min-height: 0; }
|
||||
.shell-frame[data-shell-resize="mobile"] > .shell-sidebar-slot { flex: 0 0 auto; width: auto; }
|
||||
.shell-main-slot { display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.shell-inset, .shell-page { min-width: 0; min-height: 0; }
|
||||
.shell-page { height: 100%; }
|
||||
.shell-page-presence { position: relative; height: 100%; min-width: 0; min-height: 0; }
|
||||
.shell-resize-handle { flex: 0 0 6px; }
|
||||
.shell-resize-handle { position: absolute; top: 0; right: -3px; z-index: 2; width: 6px; height: 100%; padding: 0; border: 0; background: transparent; cursor: col-resize; }
|
||||
.sidebar-nav-current { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-md); background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); pointer-events: none; }
|
||||
.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); }
|
||||
[data-sidebar="header"] { flex: 0 0 auto; padding-bottom: var(--space-3); }
|
||||
@@ -1016,8 +1018,9 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
.chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); }
|
||||
/* Home spans the outer rows with one frame, then repeats the same templates
|
||||
inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */
|
||||
.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; }
|
||||
.chat-chart-split { height: 100%; min-width: 0; min-height: 0; }
|
||||
.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: row; }
|
||||
.chat-chart-frame > .chat-column { flex: 1 1 auto; }
|
||||
.chat-chart-split { height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; }
|
||||
.chat-column { height: 100%; min-width: 0; min-height: 0; display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification .chat-column { grid-template-rows: 46px minmax(0, 1fr); }
|
||||
.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: 46px minmax(0, 1fr) auto minmax(0, 1fr); }
|
||||
|
||||
@@ -15,7 +15,6 @@ type SplitComponent = (props: {
|
||||
subjectId: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
children: ReactNode;
|
||||
}) => ReactNode;
|
||||
|
||||
export function ChatChartToggle() {
|
||||
@@ -78,14 +77,12 @@ export function ChatChartFrame({ children }: { children: ReactNode }) {
|
||||
}, [Split, desktop, hydrated, open]);
|
||||
|
||||
const column = <div className="chat-column">{children}</div>;
|
||||
if (!Split || !desktop) {
|
||||
return <div className="chat-chart-frame">{column}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="chat-chart-frame">
|
||||
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth}>
|
||||
{column}
|
||||
</Split>
|
||||
{column}
|
||||
{Split && desktop ? (
|
||||
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ChatNatalChart } from "@/components/chat-natal-chart";
|
||||
@@ -13,14 +13,12 @@ export function ChatChartSplit({
|
||||
subjectId,
|
||||
onOpenChange,
|
||||
onWidth,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
width: number;
|
||||
subjectId: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const panelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
|
||||
@@ -44,11 +42,9 @@ export function ChatChartSplit({
|
||||
if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels));
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="chat-chart-split" onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel id="chat-column" defaultSize="100%">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizablePanelGroup orientation="horizontal" className="chat-chart-split" style={{ width }} onLayoutChanged={onLayoutChanged}>
|
||||
<ResizableHandle withHandle className="chat-chart-resize-handle" />
|
||||
<ResizablePanel
|
||||
id="chat-natal"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
@@ -13,124 +12,67 @@ import {
|
||||
} from "@/lib/sidebar-state";
|
||||
import { useUiStore } from "@/stores/ui-store";
|
||||
|
||||
type ResizableModule = typeof import("@/components/ui/resizable");
|
||||
|
||||
let panels: ResizableModule | null = null;
|
||||
let started = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!started) {
|
||||
started = true;
|
||||
void import("@/components/ui/resizable").then((mod) => {
|
||||
panels = mod;
|
||||
notify();
|
||||
}).catch(() => {
|
||||
started = false;
|
||||
});
|
||||
}
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function useResizablePanels(enabled: boolean): ResizableModule | null {
|
||||
return useSyncExternalStore(
|
||||
(listener) => (enabled ? subscribe(listener) : () => {}),
|
||||
() => (enabled ? panels : null),
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
export function ShellFrame({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
|
||||
const { isMobile, open, setOpen, viewport, ready } = useSidebar();
|
||||
const width = useUiStore((state) => state.sidebarWidth);
|
||||
const hydrated = useUiStore((state) => state.sidebarWidthHydrated);
|
||||
const hydrate = useUiStore((state) => state.hydrateSidebarWidth);
|
||||
const setSidebarWidth = useUiStore((state) => state.setSidebarWidth);
|
||||
const dragStop = useRef<(() => void) | null>(null);
|
||||
const desktop = ready && !isMobile;
|
||||
const Resizable = useResizablePanels(desktop && hydrated);
|
||||
const panelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel || !Resizable) return;
|
||||
if (open && panel.isCollapsed()) panel.expand();
|
||||
if (!open && !panel.isCollapsed()) panel.collapse();
|
||||
}, [open, Resizable]);
|
||||
|
||||
if (!desktop) {
|
||||
return (
|
||||
<div className="shell-frame" data-shell-resize="mobile">
|
||||
{sidebar}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const expandedWidth = hydrated
|
||||
? width
|
||||
: viewport === "tablet" ? SIDEBAR_WIDTH_TABLET : SIDEBAR_WIDTH_DEFAULT;
|
||||
const pixelWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED;
|
||||
|
||||
if (!Resizable) {
|
||||
return (
|
||||
<div
|
||||
className="shell-frame"
|
||||
data-shell-resize="pending"
|
||||
style={{ ["--shell-sidebar-width" as string]: `${pixelWidth}px` }}
|
||||
>
|
||||
{sidebar}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
function onLayoutChanged(_layout: Record<string, number>, meta: { isUserInteraction: boolean }) {
|
||||
if (!meta.isUserInteraction) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const pixels = panel.getSize().inPixels;
|
||||
if (panel.isCollapsed() || pixels < 120) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
setSidebarWidth(Math.round(pixels));
|
||||
useEffect(() => () => dragStop.current?.(), []);
|
||||
|
||||
function onResizeStart(event: ReactPointerEvent<HTMLButtonElement>) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED;
|
||||
const move = (ev: PointerEvent) => {
|
||||
const next = startWidth + ev.clientX - startX;
|
||||
if (next < SIDEBAR_WIDTH_MIN) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
setSidebarWidth(Math.min(SIDEBAR_WIDTH_MAX, Math.round(next)));
|
||||
};
|
||||
const stop = () => {
|
||||
dragStop.current = null;
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
};
|
||||
dragStop.current = stop;
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", stop);
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable.ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
<div
|
||||
className="shell-frame"
|
||||
data-shell-resize="live"
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
data-shell-resize={desktop ? "live" : "mobile"}
|
||||
style={desktop ? { ["--shell-sidebar-width" as string]: `${pixelWidth}px` } : undefined}
|
||||
>
|
||||
<Resizable.ResizablePanel
|
||||
id="app-sidebar"
|
||||
className="shell-sidebar-panel"
|
||||
panelRef={panelRef}
|
||||
collapsible
|
||||
collapsedSize={SIDEBAR_WIDTH_COLLAPSED}
|
||||
collapsedThreshold={SIDEBAR_WIDTH_MIN - SIDEBAR_WIDTH_COLLAPSED}
|
||||
minSize={SIDEBAR_WIDTH_MIN}
|
||||
maxSize={SIDEBAR_WIDTH_MAX}
|
||||
defaultSize={pixelWidth}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
>
|
||||
<div className="shell-sidebar-slot">
|
||||
{sidebar}
|
||||
</Resizable.ResizablePanel>
|
||||
<Resizable.ResizableHandle className="shell-resize-handle" />
|
||||
<Resizable.ResizablePanel id="app-main" defaultSize="100%">
|
||||
{children}
|
||||
</Resizable.ResizablePanel>
|
||||
</Resizable.ResizablePanelGroup>
|
||||
{desktop ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shell-resize-handle"
|
||||
aria-label="调整侧栏宽度"
|
||||
onPointerDown={onResizeStart}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shell-main-slot">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ const knownUnstyled = new Set([
|
||||
// 折叠内容、分栏、侧栏导航和手机侧栏上,类名只是钩子。
|
||||
"chart-page-dasha-panel",
|
||||
"chat-chart-resize-handle",
|
||||
"shell-sidebar-panel",
|
||||
"sidebar-nav-item",
|
||||
"sidebar-sheet",
|
||||
"is-live",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { ChatChartFrame } from "../src/components/chat-chart-frame.tsx";
|
||||
import { ShellFrame } from "../src/components/shell-frame.tsx";
|
||||
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import { useUiStore } from "../src/stores/ui-store.ts";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
function Probe({ record }: { record: { mount: number; unmount: number } }) {
|
||||
useEffect(() => {
|
||||
record.mount += 1;
|
||||
return () => {
|
||||
record.unmount += 1;
|
||||
};
|
||||
}, [record]);
|
||||
return <span>probe</span>;
|
||||
}
|
||||
|
||||
function listenForResize(win: Window) {
|
||||
const buckets = new Map<string, Set<EventListener>>();
|
||||
win.addEventListener = ((type: string, listener: EventListener) => {
|
||||
const set = buckets.get(type) ?? new Set<EventListener>();
|
||||
set.add(listener);
|
||||
buckets.set(type, set);
|
||||
}) as Window["addEventListener"];
|
||||
win.removeEventListener = ((type: string, listener: EventListener) => {
|
||||
buckets.get(type)?.delete(listener);
|
||||
}) as Window["removeEventListener"];
|
||||
win.dispatchEvent = ((event: Event) => {
|
||||
for (const listener of buckets.get(event.type) ?? []) listener(event);
|
||||
return true;
|
||||
}) as Window["dispatchEvent"];
|
||||
}
|
||||
|
||||
test("the shell and the chat column stay mounted when the desktop split arrives", async () => {
|
||||
const sidebar = { mount: 0, unmount: 0 };
|
||||
const column = { mount: 0, unmount: 0 };
|
||||
const harness = createClientLifecycleHarness();
|
||||
listenForResize(globalThis.window);
|
||||
const win = globalThis.window as Window & { innerWidth: number };
|
||||
try {
|
||||
await harness.render(
|
||||
<SidebarProvider>
|
||||
<ShellFrame sidebar={<Probe record={sidebar} />}>
|
||||
<ChatChartFrame>
|
||||
<Probe record={column} />
|
||||
</ChatChartFrame>
|
||||
</ShellFrame>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await harness.idle();
|
||||
assert.equal(sidebar.mount, 1, "sidebar mounted once before the split");
|
||||
assert.equal(sidebar.unmount, 0, "sidebar unmounted before the split");
|
||||
assert.equal(column.mount, 1, "chat column mounted once before the split");
|
||||
assert.equal(column.unmount, 0, "chat column unmounted before the split");
|
||||
|
||||
await harness.update(() => {
|
||||
win.innerWidth = 500;
|
||||
win.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
await harness.idle();
|
||||
await harness.update(() => {
|
||||
win.innerWidth = 1440;
|
||||
win.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
await harness.idle();
|
||||
await harness.update(() => {
|
||||
useUiStore.getState().setChatChartOpen(true);
|
||||
});
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) await harness.idle();
|
||||
|
||||
assert.equal(sidebar.mount, 1, "sidebar mounted once across the breakpoint");
|
||||
assert.equal(sidebar.unmount, 0, "sidebar unmounted across the breakpoint");
|
||||
assert.equal(column.mount, 1, "chat column mounted once when the chart split arrived");
|
||||
assert.equal(column.unmount, 0, "chat column unmounted when the chart split arrived");
|
||||
assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n"));
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
@@ -446,12 +446,14 @@ test("maps the sidebar semantic aliases and responsive dimensions", () => {
|
||||
test("selects desktop and tablet shell widths from provider data", () => {
|
||||
assert.match(globalStyles, /\.group\\\/sidebar-provider\[data-viewport\][^{]*\{[^}]*height:\s*100dvh/);
|
||||
// 原值:`.chat-app` 用网格列分出 288 / 240 / 64。
|
||||
// 新值:`.chat-app` 是纵向 flex,`.shell-frame` 是横向 flex;宽度由可拖面板记住,收起是 64。
|
||||
// 原因:K1 桌面侧栏放进可拖宽面板,不再用网格列写死三档宽度。
|
||||
// 新值:`.chat-app` 是纵向 flex,`.shell-frame` 是横向 flex;侧栏宽度夹在最小与最大之间,收起是 64。
|
||||
// 原因:可拖面板不再包住整页。侧栏和主区始终是原来的容器,分栏到达或跨过断点都不会把页面卸掉再挂上。
|
||||
assert.match(globalStyles, /\.chat-app\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*column/);
|
||||
assert.match(globalStyles, /\.shell-frame\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*row/);
|
||||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /minSize=\{SIDEBAR_WIDTH_MIN\}/);
|
||||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /maxSize=\{SIDEBAR_WIDTH_MAX\}/);
|
||||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /SIDEBAR_WIDTH_MIN/);
|
||||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /SIDEBAR_WIDTH_MAX/);
|
||||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /shell-sidebar-slot/);
|
||||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /shell-main-slot/);
|
||||
assert.match(readProjectFile("src/lib/sidebar-state.ts"), /SIDEBAR_WIDTH_COLLAPSED = 64/);
|
||||
assert.doesNotMatch(globalStyles, /transition:[^;}]*\b(?:width|grid-template-columns)\b/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user