refactor(ui): 侧栏统一成一个组件,次级页共享外壳,跳转不再整页刷新

BUG-744 / BUG-745 / BUG-746,任务书 TASK-sidebar-unify-20260916。

T1 侧栏只剩一个组件。`AppSidebar` 收编次级页那份另写的侧栏:会话操作与
账户菜单收进可选的 `controls`,不传就渲染只读模式。只读行仍是同一个
`SidebarSessionRow`、同一套 `.session-row > .session-main` 标记,只是
`.session-main` 是 `<Link>`、不渲染菜单按钮,并用 `data-readonly="true"`
去掉那一列从不使用的 44px 空位;页脚是同一个 56px `.profile-trigger`,
渲染成去 `/` 的链接。只读模式只少菜单按钮、chevron、账户菜单三样。
`app-nav-rail.tsx`、`use-nav-rail.ts` 与 `.nav-rail-*` 两段 CSS 删除。

T2 四个次级路由移进 `app/(secondary)/` 路由组,`layout.tsx` 承载
`SidebarProvider + AppSidebar(只读) + SidebarInset`。`SecondaryShell` 拆剩
46px 顶栏并改名 `SecondaryHeader`,14 处调用同步。路由组不进 URL,四个
地址与四个渲染标记均未变。

T3 `sidebar-data-cache.ts`:模块级、按账户 id 键、60 秒的内存缓存,同步读
再后台刷新,不落 localStorage。`use-session-management.ts` 的新建 / 重命名 /
删除 / 归档 / 收藏成功后失效,401 清空。

T4 三个页面项改 `<SidebarMenuLink href=…>` 客户端跳转,`persistLoginSessionReturn()`
保留在 `onClick` 里,`/login` 仍是硬跳转。顺带删掉从未被调用的死 prop
`onOpenReports`;它删掉后 `page.tsx` 的 `router` 再无消费者,`useConsultationRun`
里同样解构成 `_router` 的死参数一并删。

T5 折叠状态存 localStorage 的 `sidebar_state`(不用 cookie:`/`、`/chart`、
`/ephemeris` 都是 Static,服务端读 cookie 会让三条路由掉出静态渲染)。移动端
抽屉不记。整页加载首帧仍可能闪一下,属让步顺序第 1 条,写在 BUG-746 与真机清单。

`Home()` 的 useState 36 / useRef 37 均未增长,`page.tsx` 净删 1 行。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 11:23:11 +00:00
co-authored by Claude Fable 5.1
parent 302ff08504
commit d9d347236f
27 changed files with 835 additions and 676 deletions
+58
View File
@@ -0,0 +1,58 @@
import type { SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
/**
* The session list and account the read-only sidebar shows, kept in memory for
* one tab.
*
* `/chart`, `/ephemeris` and `/reports` used to re-issue `GET /api/sessions` and
* `GET /api/account` on every arrival, because each page mounted its own shell.
* The shared `(secondary)` layout removes the per-page remount; this removes the
* repeat when the reader leaves for `/` and comes back. Nothing durable is
* written: a stale session list surviving a browser restart is worse than one
* fetch, and it would outlive a sign-out.
*
* Keyed by account id so a second account in the same tab never reads the
* first one's rows. The pointer is what makes a synchronous read possible: the
* account id only arrives with the payload, so the reader cannot name its own
* key before the first fetch has happened.
*/
export const SIDEBAR_CACHE_TTL_MS = 60_000;
export type SidebarCacheEntry = {
readonly accountId: string;
readonly sessions: readonly SidebarSession[];
readonly account: SidebarAccount | null;
readonly fetchedAt: number;
};
const entries = new Map<string, SidebarCacheEntry>();
let currentAccountId: string | null = null;
export function readSidebarCache(): SidebarCacheEntry | null {
if (currentAccountId === null) return null;
return entries.get(currentAccountId) ?? null;
}
export function writeSidebarCache(entry: SidebarCacheEntry): void {
entries.set(entry.accountId, entry);
currentAccountId = entry.accountId;
}
/** True when the entry may be shown without going back to the network. */
export function sidebarCacheIsFresh(entry: SidebarCacheEntry | null, now: number): boolean {
if (entry === null) return false;
const age = now - entry.fetchedAt;
return age >= 0 && age < SIDEBAR_CACHE_TTL_MS;
}
/**
* Called from `/` after every session write — create, rename, delete, archive,
* pin — and on any 401. Renaming a session and walking to `/chart` has to show
* the new title, and a signed-out tab must not keep a list on screen.
*/
export function invalidateSidebarCache(): void {
entries.clear();
currentAccountId = null;
}
+53
View File
@@ -37,3 +37,56 @@ export function shouldHandleSidebarShortcut(event: SidebarShortcutEvent): boolea
&& !event.altKey
&& !event.shiftKey;
}
/**
* Where the desktop/tablet collapse state is remembered across pages.
*
* localStorage, not the shadcn `sidebar_state` cookie: `/` is a `○ Static`
* route and `/chart` and `/ephemeris` are static too, so reading a cookie on
* the server would opt all three out of static rendering. A cookie that only
* the client may read buys nothing a localStorage key does not, so this is the
* cheaper half of the D5 choice. The mobile drawer is deliberately excluded:
* reopening a phone drawer on every navigation is not a preference anyone set.
*/
export const SIDEBAR_STATE_STORAGE_KEY = "sidebar_state";
type SidebarStateStorage = Pick<Storage, "getItem" | "setItem">;
function sidebarStateStorage(): SidebarStateStorage | null {
try {
return globalThis.localStorage ?? null;
} catch {
/* Private mode and blocked site data both throw on access. */
return null;
}
}
/** `null` when nothing was stored, or when storage is unavailable. */
export function readStoredSidebarOpen(
viewport: SidebarViewport,
storage: SidebarStateStorage | null = sidebarStateStorage(),
): boolean | null {
if (viewport === "mobile" || storage === null) return null;
try {
const raw = storage.getItem(SIDEBAR_STATE_STORAGE_KEY);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
} catch {
return null;
}
}
export function writeStoredSidebarOpen(
viewport: SidebarViewport,
open: boolean,
storage: SidebarStateStorage | null = sidebarStateStorage(),
): void {
if (viewport === "mobile" || storage === null) return;
try {
storage.setItem(SIDEBAR_STATE_STORAGE_KEY, open ? "true" : "false");
} catch {
/* Quota and private mode: the sidebar still works, it just forgets. */
}
}