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
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { ChartPageRoute } from "@/components/chart-page/chart-page-view";
import "../site-styles";
export const metadata: Metadata = {
title: "星盘 · Jyotisha",
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { EphemerisPage } from "@/components/ephemeris/ephemeris-page";
import "../site-styles";
export const metadata: Metadata = {
title: "星历 · Jyotisha",
+45
View File
@@ -0,0 +1,45 @@
"use client";
import type { ReactNode } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { useSidebarData } from "@/hooks/use-sidebar-data";
import "../site-styles";
/**
* One shell for `/chart`, `/ephemeris`, `/reports` and `/reports/[reportId]`.
*
* A route group changes no URL: the four routes keep their paths and their own
* `metadata` and `dynamic` declarations. What it changes is who owns the shell.
* Each page used to render `SecondaryShell`, so each carried a provider and a
* sidebar of its own; stepping between two of them tore the nav down and built
* it again, list request included. Hoisting it here means the sidebar is mounted
* once per visit to this section, and `useSidebarData` runs once with it.
*
* The sidebar is read-only on purpose — no renaming, archiving, deleting or
* account menu. Those are backed by `Home()`'s optimistic-update and rollback
* layer, and none of these four pages offers session management
* (TASK-cend-surfaces-claude-alignment-20260916 D9, carried forward by
* TASK-sidebar-unify-20260916 D1).
*/
export default function SecondaryLayout({ children }: { children: ReactNode }) {
const { sessions, account, settled, signedOut } = useSidebarData();
return (
<SidebarProvider>
<main className="chat-app">
<AppSidebar
sessions={sessions}
activeSessionId={null}
account={account}
settled={settled}
signedOut={signedOut}
/>
<SidebarInset className="chat-panel secondary-panel">
{children}
</SidebarInset>
</main>
</SidebarProvider>
);
}
@@ -5,7 +5,7 @@ import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import "../../site-styles";
import "../../../site-styles";
export default function ReportError({
error,
@@ -1,5 +1,5 @@
import { InlineSpinner } from "@/components/inline-spinner";
import "../../site-styles";
import "../../../site-styles";
export default function ReportLoading() {
return (
@@ -3,7 +3,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import "../../site-styles";
import "../../../site-styles";
export default function ReportNotFound() {
return (
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
import "../../site-styles";
export const dynamic = "force-dynamic";
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { PersonalReportCenter } from "@/components/personal-report/personal-report-center";
import "../site-styles";
export const dynamic = "force-dynamic";
+8 -34
View File
@@ -707,8 +707,8 @@ button:disabled { cursor: default; opacity: .45; }
.brand-row strong { font-weight: 400; }
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--ring-hairline); }
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.report-nav-button[data-active="true"] {
position: relative;
@@ -769,9 +769,13 @@ button:disabled { cursor: default; opacity: .45; }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-lg); color: var(--sidebar-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; }
.session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); }
.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; }
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); }
/* Read-only rows (the secondary pages) render no menu trigger, so the 44px
column the interactive row reserves for one would be dead space at the end
of every title. Everything else about the row is the same markup. */
.session-row[data-readonly="true"] { grid-template-columns: minmax(0, 1fr); }
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; }
.session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; }
/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */
@@ -813,7 +817,7 @@ button:disabled { cursor: default; opacity: .45; }
.session-actions .session-action-danger, .session-actions .session-action-danger > svg { color: var(--color-danger); }
.session-actions .session-action-danger[data-highlighted] { background: var(--color-danger-muted); }
.sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); }
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
.user-avatar { display: inline-grid; flex: 0 0 auto; overflow: hidden; place-items: center; border-radius: 50%; background: var(--color-action-soft); }
.user-avatar > svg { width: 100%; height: 100%; display: block; }
.profile-avatar { width: 32px; height: 32px; }
@@ -2023,36 +2027,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-weight: 500;
}
/* The rail is the chat sidebar minus every write action. It reuses the sidebar
surface and row styles; only the identity block differs, because there is no
account menu to open from here. */
.nav-rail-row { width: 100%; }
.nav-rail-identity {
min-height: 44px;
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--sidebar-foreground);
font-size: var(--type-body-sm);
text-align: left;
text-decoration: none;
}
.nav-rail-identity > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-rail-identity > small {
margin-left: auto;
flex: 0 0 auto;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-variant-numeric: tabular-nums;
}
button.nav-rail-identity { cursor: pointer; }
button.nav-rail-identity:hover { background: var(--sidebar-accent); }
@media (max-width: 767px) {
/* Replaces the three `*-shell { padding-inline }` rules the standalone pages
each carried. Same global 767px cut, one rule instead of three. */
+34 -36
View File
@@ -3,7 +3,6 @@
import "@/app/site-styles";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
@@ -239,7 +238,6 @@ const BillingPanel = dynamic(
);
export default function Home() {
const router = useRouter();
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
@@ -739,7 +737,6 @@ export default function Home() {
pendingConsultation,
pendingSessionId,
profile,
router,
sessions,
setAccount,
setActiveSessionId,
@@ -1552,41 +1549,42 @@ export default function Home() {
openErrorSessionId={rectificationErrorSessionId}
openErrorMessage={rectificationErrorSessionId ? rectificationErrorMessage : ""}
account={sidebarAccount}
accountMenuOpen={accountMenuOpen}
accountTriggerRef={accountTrigger}
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
sessionControls={{
archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions,
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onToggleArchived: toggleArchivedSession,
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) setPendingSessionDeletion(session);
controls={{
newChatDisabled: !hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending,
creatingSession,
accountMenuOpen,
accountTriggerRef: accountTrigger,
sessionControls: {
archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions,
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onToggleArchived: toggleArchivedSession,
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) setPendingSessionDeletion(session);
},
},
onAccountMenuOpenChange: setAccountMenuOpen,
onNewChat: () => void startNewChat(),
onSelectSession: selectSession,
onOpenProfile: () => openAccountDialog("profile"),
onOpenChartLibrary: () => openAccountDialog("chart-library"),
onOpenGeneral: () => openAccountDialog("general"),
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
onOpenLogout: () => openAccountDialog("logout"),
}}
onAccountMenuOpenChange={setAccountMenuOpen}
onNewChat={() => void startNewChat()}
onOpenReports={() => router.push("/reports")}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenChartLibrary={() => openAccountDialog("chart-library")}
onOpenGeneral={() => openAccountDialog("general")}
onOpenBilling={() => openAccountDialog("billing", { source: "account-menu" })}
onOpenLogout={() => openAccountDialog("logout")}
/>
{pendingSessionDeletion ? (
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>