feat(ui): 星盘/星历/报告中心并入 app 外壳,侧栏不再消失
Independent Staging Quality Gate / validate (push) Failing after 9m57s
Independent Staging Quality Gate / publish (push) Skipped

三个页面此前各是脱离外壳的独立全屏路由,各写了一套一样的
*-shell / *-topbar(只有一个「返回对话」链接)/ *-hero 骨架,
侧栏在打开它们的瞬间整个消失。

新增 AppNavRail(只读:两个 GET,零写操作)与 SecondaryShell。
会话行走 sessionHref → /?c=<uuid>,跳转复用现有侧栏的
persistLoginSessionReturn + location.assign,行为完全一致。

刻意不带重命名/收藏/归档/删除与账户菜单:那套连着 Home() 的乐观更新与
回滚层,为四个路由把它整体上提远超需要,且会撞 useState 增长门禁。

四个路由渲染标记完全不变:/ ○、/chart ○、/ephemeris ○、/reports ƒ。
CSS gzip 39,825→39,727(−0.25%);per-route JS 体积构建不输出,
已作为口径缺口记录。

两处自身问题被测试抓到并修复:usePathname() 在 app-router 上下文外
返回 null(类型说是 string)、fetch 在 jsdom 里可能不存在。
另差点随 hero 一起丢掉 BUG-717 的 eyebrow 文案(成本与速度承诺,
会印在失败页上),已放回 tab 行下方。

/reports/[reportId] 留给 R6 与目录一起做;根边界页保留「返回对话」,
它们不得 import globals.css,挂不了外壳。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 06:03:30 +00:00
co-authored by Claude Opus 5
parent b3ea8c336e
commit 50ce02c837
15 changed files with 680 additions and 263 deletions
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { usePathname } from "next/navigation";
import { CalendarDays, FileText, Orbit, SquarePen, Star } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import { groupSessionsByRecency } from "@/lib/session-groups";
import { useNavRail, type NavRailSession } from "@/hooks/use-nav-rail";
/**
* The nav that /chart, /ephemeris and /reports carry.
*
* Those three pages used to be standalone full-screen routes whose only way back
* was a 「返回对话」 link: the sidebar disappeared entirely the moment you opened
* a chart. This rail keeps it on screen, at the cost of being read-only —
* renaming, pinning, archiving and deleting stay on `/`, where the state that
* backs them lives. See TASK-cend-surfaces-claude-alignment-20260916 D9.
*/
const NAV_PAGES = [
{ href: "/chart", label: "星盘", icon: Orbit },
{ href: "/ephemeris", label: "星历", icon: CalendarDays },
{ href: "/reports", label: "我的报告", icon: FileText },
] as const;
export function AppNavRail() {
const { sessions, account, settled, signedOut } = useNavRail();
const { isMobile, setOpenMobile, state } = useSidebar();
/* Null outside an app-router context (and in unit tests that mount this
component directly), even though the type says string. */
const pathname = usePathname() ?? "";
const showExpandedContent = !(state === "collapsed" && !isMobile);
const pinned = sessions.filter((session) => session.pinned);
const history = sessions.filter((session) => !session.pinned);
const groups = groupSessionsByRecency(history);
/* Same navigation the chat sidebar performs when it leaves for one of these
pages: a real document load, with the return target stored first. */
function go(href: string) {
persistLoginSessionReturn();
if (isMobile) setOpenMobile(false);
window.location.assign(href);
}
function renderRow(session: NavRailSession) {
return (
<SidebarMenuItem key={session.id}>
<button
className="session-row nav-rail-row"
type="button"
onClick={() => go(sessionHref("", session.id))}
>
<span className="session-title">{session.title}</span>
</button>
</SidebarMenuItem>
);
}
return (
<Sidebar className="sidebar" aria-label="对话导航">
<SidebarHeader className="sidebar-header">
<div className="brand-row">
<span className="brand-mark" aria-hidden="true" />
{showExpandedContent ? <strong>Jyotisha</strong> : null}
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton className="new-chat" type="button" tooltip="新对话" onClick={() => go("/")}>
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span></span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
<SidebarMenuItem key={href}>
<SidebarMenuButton
className="report-nav-button"
type="button"
tooltip={label}
isActive={pathname === href || pathname.startsWith(`${href}/`)}
onClick={() => go(href)}
>
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{label}</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{showExpandedContent ? (
<SidebarGroup className="session-nav" aria-label="最近对话">
<p className="sidebar-section-label"></p>
<SidebarGroupContent className="sidebar-nested">
{/* Static copy while the list is in flight — never a skeleton. */}
{sessions.length === 0 ? (
<p className="sidebar-empty">
{signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"}
</p>
) : (
<SidebarMenu className="session-list">
{pinned.length > 0 ? (
<div>
<p className="sidebar-group-label">
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
</p>
{pinned.map(renderRow)}
</div>
) : null}
{groups.map((group) => (
<div key={group.key}>
<p className="sidebar-group-label">{group.label}</p>
{group.sessions.map(renderRow)}
</div>
))}
</SidebarMenu>
)}
</SidebarGroupContent>
</SidebarGroup>
) : null}
</SidebarContent>
<SidebarFooter className="sidebar-footer">
{signedOut ? (
<button className="nav-rail-identity" type="button" onClick={() => go("/login")}>
<span className="profile-initial" aria-hidden="true">·</span>
{showExpandedContent ? <span><b></b></span> : null}
</button>
) : (
/* Identity only. Account actions live in the chat page's own menu; a
second copy here would need the settings dialog stack to come with it. */
<div className="nav-rail-identity" aria-label={account ? `${account.name},余额 ${account.credits}` : undefined}>
<span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>
{showExpandedContent && account ? (
<>
<span><b>{account.name}</b></span>
<small>{account.credits} </small>
</>
) : null}
</div>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
@@ -1,9 +1,8 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useState } from "react";
import { SecondaryShell } from "@/components/secondary-shell";
import { useChartPage } from "@/hooks/use-chart-page";
import {
CHART_VIEW_TABS,
@@ -59,34 +58,26 @@ export function ChartPageView({
if (id !== "D1") onNeedLayer?.("varga");
}
return (
<main className="chart-page-shell">
<header className="chart-page-topbar">
<Link href="/" className="chart-page-back">
<ArrowLeft aria-hidden="true" />
</Link>
</header>
const birthline = view != null && view.status === "ok"
? `${view.profile.name} · ${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`
: null;
return (
<SecondaryShell title="星盘" note={birthline}>
{view == null ? (
<section className="chart-page-hero">
<h1></h1>
<section className="chart-page-message">
<p>{CHART_VIEW_COPY.waitingChart}</p>
</section>
) : view.status !== "ok" ? (
<section className="chart-page-hero">
<h1></h1>
<section className="chart-page-message">
<p>{view.message}</p>
</section>
) : (
<>
<section className="chart-page-hero">
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
<h1></h1>
<p className="chart-page-birthline">
{view.profile.name} · {view.profile.date} {view.profile.time} · {view.profile.placeLabel}
</p>
</section>
<div className="chart-page-body">
{/* BUG-717 copy: a cost and speed promise that also prints on the
failure page. It used to sit in the hero as an eyebrow; the hero is
gone, the promise is not. */}
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
<div className="chart-page-tabs" role="tablist" aria-label="星盘体系">
{CHART_VIEW_TABS.map((item) => (
<button
@@ -128,8 +119,8 @@ export function ChartPageView({
)
) : null}
</section>
</>
</div>
)}
</main>
</SecondaryShell>
);
}
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { SecondaryShell } from "@/components/secondary-shell";
import { setComposerDraft } from "@/lib/composer-draft";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
import {
@@ -58,11 +59,13 @@ export function EphemerisPage() {
if (state.phase === "unauthorized") {
return (
<main className="ephemeris-shell ephemeris-message">
<h1>{EPHEMERIS_COPY.loginTitle}</h1>
<p>{EPHEMERIS_COPY.loginBody}</p>
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
</main>
<SecondaryShell title={EPHEMERIS_COPY.title}>
<section className="ephemeris-message">
<h2>{EPHEMERIS_COPY.loginTitle}</h2>
<p>{EPHEMERIS_COPY.loginBody}</p>
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
</section>
</SecondaryShell>
);
}
@@ -113,13 +116,16 @@ export function EphemerisView(props: {
const isToday = Boolean(props.date) && props.date === props.today;
return (
<main className="ephemeris-shell">
<header className="ephemeris-topbar">
<Link className="ephemeris-back" href="/">{EPHEMERIS_COPY.back}</Link>
</header>
<SecondaryShell
title={EPHEMERIS_COPY.title}
actions={(
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
{EPHEMERIS_COPY.ask}
</Button>
)}
>
<div className="ephemeris-body">
<section className="ephemeris-hero">
<h1>{EPHEMERIS_COPY.title}</h1>
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
<div className="ephemeris-date-bar" role="group" aria-label="切换日期">
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onPrevious}>
@@ -199,10 +205,8 @@ export function EphemerisView(props: {
<section className="ephemeris-footer">
<p>{EPHEMERIS_COPY.footerNote}</p>
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
{EPHEMERIS_COPY.ask}
</Button>
</section>
</main>
</div>
</SecondaryShell>
);
}
@@ -1,10 +1,11 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Check, Clock3, FileText, RefreshCw, TriangleAlert } from "lucide-react";
import { Check, Clock3, FileText, RefreshCw, TriangleAlert } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SecondaryShell } from "@/components/secondary-shell";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
@@ -166,30 +167,30 @@ export function PersonalReportCenter() {
if (state.phase === "unauthorized") {
return (
<main className="report-center-shell report-center-message">
<FileText aria-hidden="true" className="size-8 text-primary" />
<h1></h1>
<p></p>
<Button render={<Link href="/login" />} nativeButton={false}></Button>
</main>
<SecondaryShell title="我的报告">
<section className="report-center-message">
<FileText aria-hidden="true" className="size-8 text-primary" />
<h2></h2>
<p></p>
<Button render={<Link href="/login" />} nativeButton={false}></Button>
</section>
</SecondaryShell>
);
}
return (
<main className="report-center-shell">
<header className="report-center-topbar">
<Link href="/" className="report-center-back"><ArrowLeft aria-hidden="true" /></Link>
</header>
<SecondaryShell
title="我的报告"
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
>
<div className="report-center-body">
<section className="report-center-hero">
<div>
<h1></h1>
<p></p>
<p className="report-center-meta" aria-label="报告概览">
{state.reports.length} · {state.reports.filter((report) => report.status === "ready").length} · {hasGenerating ? "有报告生成中" : latestReady ? "最新报告可查看" : "尚待生成"}
</p>
</div>
<GeneratePersonalReportButton onCreated={() => void load()} />
</section>
<section className="report-center-section">
@@ -261,6 +262,7 @@ export function PersonalReportCenter() {
</div>
)}
</section>
</main>
</div>
</SecondaryShell>
);
}
@@ -0,0 +1,48 @@
"use client";
import type { ReactNode } from "react";
import { AppNavRail } from "@/components/app-nav-rail";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
/**
* The app shell for /chart, /ephemeris and /reports.
*
* Each of those pages used to own a full-screen layout of its own — a
* `*-shell` root, a `*-topbar` holding one 「返回对话」 link, and a `*-hero` with
* a page-sized h1. Three copies of the same skeleton, and none of them had the
* sidebar, so opening a chart dropped the reader out of the app entirely.
*
* The page name now sits in the 46px header beside the sidebar trigger, which is
* where the chat page keeps its session title.
*/
export type SecondaryShellProps = {
/** Shown in the header. Pass a falsy value for pages that carry their own. */
readonly title: string;
/** Right-aligned header controls: the one primary action, at most two. */
readonly actions?: ReactNode;
/** A quiet chip beside the title — birth line, report date. */
readonly note?: ReactNode;
readonly children: ReactNode;
};
export function SecondaryShell({ title, actions, note, children }: SecondaryShellProps) {
return (
<SidebarProvider>
<main className="chat-app">
<AppNavRail />
<SidebarInset className="chat-panel secondary-panel">
<header className="chat-header has-title">
<SidebarTrigger placement="inset" />
<div className="chat-header-title">
<strong>{title}</strong>
{note ? <span className="chat-header-chart">{note}</span> : null}
</div>
{actions ? <div className="chat-header-actions">{actions}</div> : null}
</header>
{children}
</SidebarInset>
</main>
</SidebarProvider>
);
}