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
@@ -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",
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import "../../site-styles";
|
||||
import "../../../site-styles";
|
||||
|
||||
export default function ReportLoading() {
|
||||
return (
|
||||
+1
-1
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user