feat(ui): 星盘/星历/报告中心并入 app 外壳,侧栏不再消失
三个页面此前各是脱离外壳的独立全屏路由,各写了一套一样的 *-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:
co-authored by
Claude Opus 5
parent
b3ea8c336e
commit
50ce02c837
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user