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:
co-authored by
Claude Fable 5.1
parent
302ff08504
commit
d9d347236f
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user