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
@@ -108,7 +108,6 @@ export type ConsultationRunParams = {
pendingConsultation: MutableRefObject<PendingConsultation | null>;
pendingSessionId: string | null;
profile: Profile;
router: { push: (href: string) => void };
sessions: ChatSession[];
setAccount: Dispatch<SetStateAction<Account | null>>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
@@ -166,7 +165,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
pendingConsultation,
pendingSessionId,
profile,
router: _router,
sessions,
setAccount,
setActiveSessionId,
-122
View File
@@ -1,122 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import type { BeamAvatar } from "@/lib/beam-avatar";
/**
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
*
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
* rollback included. Lifting that whole layer so four routes can share it is far
* more than these pages need: none of them offers session management. Two GETs
* are enough, and this hook never writes.
*/
export type NavRailSession = {
readonly id: string;
readonly title: string;
readonly pinned: boolean;
readonly updatedAt: number;
};
export type NavRailAccount = {
readonly name: string;
readonly initial: string;
readonly credits: number;
/** The same beam avatar the chat sidebar draws, so the two footers match. */
readonly avatar: BeamAvatar | null;
};
export type NavRailState = {
readonly sessions: readonly NavRailSession[];
readonly account: NavRailAccount | null;
/** True once both requests have settled, however they settled. */
readonly settled: boolean;
/** The viewer is not signed in; the rail degrades to brand plus a login link. */
readonly signedOut: boolean;
};
type SessionRow = {
id?: unknown;
title?: unknown;
pinned?: unknown;
updated_at?: unknown;
};
function toSession(row: SessionRow): NavRailSession | null {
if (typeof row.id !== "string" || !row.id) return null;
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
return {
id: row.id,
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
pinned: row.pinned === true,
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
};
}
export function useNavRail(): NavRailState {
const [sessions, setSessions] = useState<readonly NavRailSession[]>([]);
const [account, setAccount] = useState<NavRailAccount | null>(null);
const [settled, setSettled] = useState(false);
const [signedOut, setSignedOut] = useState(false);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
async function load() {
if (typeof fetch !== "function") {
setSettled(true);
return;
}
const [sessionResult, accountResult] = await Promise.allSettled([
fetch("/api/sessions?limit=40", { signal: controller.signal }),
fetch("/api/account", { signal: controller.signal }),
]);
if (cancelled) return;
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
if (!cancelled) setSessions(rows.map(toSession).filter((item): item is NavRailSession => item !== null));
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
if (!cancelled) setSignedOut(true);
}
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
const body = await accountResult.value.json().catch(() => null) as {
credits?: unknown;
user?: { email?: unknown };
profile?: { name?: unknown };
avatar?: BeamAvatar | null;
} | null;
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
const email = typeof body?.user?.email === "string" ? body.user.email : "";
if (!cancelled) {
setAccount({
name: name || email || "账户",
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
credits: typeof body?.credits === "number" ? body.credits : 0,
avatar: body?.avatar ?? null,
});
}
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
if (!cancelled) setSignedOut(true);
}
if (!cancelled) setSettled(true);
}
void load().catch(() => {
if (!cancelled) setSettled(true);
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
return { sessions, account, settled, signedOut };
}
+11 -2
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { sortSessions } from "@/lib/session-groups";
import { invalidateSidebarCache } from "@/lib/sidebar-data-cache";
import {
clearBirthTimeConsultationConsent,
type BirthTimeConsultationConsentState,
@@ -216,6 +217,12 @@ export function useSessionManagement(params: SessionManagementParams) {
updateSession(session.id, () => nextSession);
try {
await persistSession(nextSession);
/* The secondary pages read their list from a 60s module cache; without
this, walking to /chart right after a rename still shows the old
title. Invalidate rather than write through: `Home()` owns the richer
ChatSession shape, and one refetch is cheaper than keeping two
representations in step. */
invalidateSidebarCache();
} catch (caught) {
setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败");
}
@@ -236,6 +243,7 @@ export function useSessionManagement(params: SessionManagementParams) {
const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
const payload = await response.json().catch(() => null) as { error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败");
invalidateSidebarCache();
} catch (caught) {
setSessions(previousSessions);
setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败");
@@ -247,7 +255,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!session) return;
const nextPinned = !session.pinned;
updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned }));
void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => {
void writeChatSession(sessionId, { pinned: nextPinned }, "update").then(invalidateSidebarCache).catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned }));
setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败");
});
@@ -266,7 +274,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
}
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").then(invalidateSidebarCache).catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
if (!restoring && previousActiveId === sessionId) {
setActiveSessionId(previousActiveId);
@@ -330,6 +338,7 @@ export function useSessionManagement(params: SessionManagementParams) {
? { continuedFromSessionId: options.continuedFromSessionId }
: undefined,
);
invalidateSidebarCache();
return nextSession;
} catch (caught) {
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useEffect, useState } from "react";
import type { SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
import type { BeamAvatar } from "@/lib/beam-avatar";
import {
invalidateSidebarCache,
readSidebarCache,
sidebarCacheIsFresh,
writeSidebarCache,
} from "@/lib/sidebar-data-cache";
/**
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
*
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
* rollback included. Lifting that whole layer so four routes can share it is far
* more than these pages need: none of them offers session management. Two GETs
* are enough, and this hook never writes.
*
* It is called once, from the `(secondary)` layout, so moving between those
* four routes does not re-run it at all. The module cache behind it covers the
* other trip: leaving for `/` and coming back unmounts the layout, and without
* it that would mean two more requests inside the same minute.
*/
export type SidebarDataState = {
readonly sessions: readonly SidebarSession[];
readonly account: SidebarAccount | null;
/** True once both requests have settled, however they settled. */
readonly settled: boolean;
/** The viewer is not signed in; the sidebar degrades to brand plus a login link. */
readonly signedOut: boolean;
};
type SessionRow = {
id?: unknown;
title?: unknown;
pinned?: unknown;
archived_at?: unknown;
updated_at?: unknown;
};
type AccountBody = {
credits?: unknown;
user?: { id?: unknown; email?: unknown };
profile?: { name?: unknown };
avatar?: BeamAvatar | null;
};
const EMPTY: SidebarDataState = { sessions: [], account: null, settled: false, signedOut: false };
export function toSidebarSession(row: SessionRow): SidebarSession | null {
if (typeof row.id !== "string" || !row.id) return null;
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
return {
id: row.id,
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
pinned: row.pinned === true,
archived: typeof row.archived_at === "string" && row.archived_at !== "",
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
};
}
export function toSidebarAccount(body: AccountBody | null): SidebarAccount {
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
const email = typeof body?.user?.email === "string" ? body.user.email : "";
return {
name: name || email || "账户",
email,
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
credits: typeof body?.credits === "number" ? body.credits : 0,
avatar: body?.avatar ?? null,
};
}
/** Which account the payload belongs to; the cache key. */
export function sidebarAccountKey(body: AccountBody | null): string {
const id = typeof body?.user?.id === "string" ? body.user.id : "";
const email = typeof body?.user?.email === "string" ? body.user.email : "";
return id || email || "anonymous";
}
export function useSidebarData(): SidebarDataState {
const [state, setState] = useState<SidebarDataState>(() => {
/* Read once, at mount: an entry from a previous visit renders in the first
frame rather than after a round trip. Empty on the server, and empty on a
real page load, which is what makes the first render match. */
const cached = readSidebarCache();
if (cached === null) return EMPTY;
return { sessions: cached.sessions, account: cached.account, settled: true, signedOut: false };
});
useEffect(() => {
const cached = readSidebarCache();
if (sidebarCacheIsFresh(cached, Date.now())) return;
const controller = new AbortController();
let cancelled = false;
async function load() {
if (typeof fetch !== "function") {
setState((current) => ({ ...current, settled: true }));
return;
}
const [sessionResult, accountResult] = await Promise.allSettled([
fetch("/api/sessions?limit=40", { signal: controller.signal }),
fetch("/api/account", { signal: controller.signal }),
]);
if (cancelled) return;
let sessions: readonly SidebarSession[] | null = null;
let account: SidebarAccount | null = null;
let accountBody: AccountBody | null = null;
let signedOut = false;
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
sessions = rows.map(toSidebarSession).filter((item): item is SidebarSession => item !== null);
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
signedOut = true;
}
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
accountBody = await accountResult.value.json().catch(() => null) as AccountBody | null;
account = toSidebarAccount(accountBody);
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
signedOut = true;
}
if (cancelled) return;
if (signedOut) {
/* A rejected session must not leave a list on screen, here or on the
next page this tab opens. */
invalidateSidebarCache();
setState({ sessions: [], account: null, settled: true, signedOut: true });
return;
}
if (sessions !== null || account !== null) {
writeSidebarCache({
accountId: sidebarAccountKey(accountBody),
sessions: sessions ?? [],
account,
fetchedAt: Date.now(),
});
}
setState({ sessions: sessions ?? [], account, settled: true, signedOut: false });
}
void load().catch(() => {
if (!cancelled) setState((current) => ({ ...current, settled: true }));
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
return state;
}