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
+29 -2
View File
@@ -373,12 +373,39 @@ Every assistant reply moves through the same states on both chat surfaces, and e
Text release is paced, not animated: the frame buffer commits at most once per animation frame and reveals `max(2, ⌈backlog ÷ 12⌉)` characters per frame, so a burst catches up in about twelve frames and a slow model never reads as stalled. The reader's own toggle on the timeline always wins over the state default. Under `prefers-reduced-motion: reduce` the collapse and the label fade are instant; the pacing stays, because it is content arrival rather than decoration.
### Secondary page shell
`/chart`, `/ephemeris` and `/reports` share the chat shell through
`SecondaryShell`. Each used to own a full-screen layout — a `*-shell` root, a
`*-topbar` with one 「返回对话」 link, and a `*-hero` with a page-sized h1 —
three copies of one skeleton, none of them carrying the sidebar.
- **Composition:** `SidebarProvider` → `AppNavRail` → `SidebarInset.chat-panel.secondary-panel`.
The header is the same 46px row the chat page uses: sidebar trigger, page name,
an optional quiet note chip, and up to two right-aligned actions. The body is
the only thing that scrolls, and it owns the page floor (`--color-canvas-soft`).
- **The rail is read-only.** It carries the brand, 新建对话, the three page
actions, and one flat 「最近」 list whose rows navigate to `/?c=<uuid>`.
Renaming, pinning, archiving, deleting and the account menu stay on `/`: they
are backed by `Home()`'s optimistic-update and rollback layer, and lifting that
so four routes can share it is far more than these pages need. The footer shows
identity and balance as text, not as a menu trigger.
- **Loading:** the rail renders its nav immediately and fills the list when
`/api/sessions` returns. While that is in flight the list area carries static
copy — never a skeleton, per the unified-loading ruling.
- **Signed out:** the rail degrades to brand, nav, a 「登录后可以看到你的对话」
line, and a 去登录 footer. It never renders an error state of its own.
- **Page name:** lives in the header, so none of these pages carries an `h1` any
more. A page's own reassurance copy (the chart page's cost-and-speed eyebrow,
for one) stays in the body; it is content, not chrome.
### Personal report centre
- **Structure:** full-page archive on the page floor. A compact back control, serif page title, supporting copy, one generate action, then a list of report cards.
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits in the 46px header with 「生成完整报告」 as the header's one action; the body is supporting copy, the overview line, then the list of report cards.
It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link.
- **Surface:** `--color-canvas-soft` floor; cards use `--color-canvas`, a warm hairline, and `--radius-lg`. No drop shadow. Status is a caption badge, not a colored block.
- **Typography:** page title uses `--type-display-lg` / `--font-display` at weight 400 with `text-wrap: balance`. Card titles are `--type-title-md` at weight 500. Body stays `--type-body-md` or `--type-body-sm`.
- **Width:** 900px centered, matching long-form chat reading. Cards stack below 720px.
- **Width:** 900px centered, matching long-form chat reading. Cards stack at the global 767px cut.
- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and “导出报告(.md)” beside it. The export downloads the same longform Markdown the detail page renders. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
- **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating.
+110 -186
View File
@@ -1973,6 +1973,104 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; }
}
/* ── Secondary pages inside the app shell ─────────────────────────────────
/chart, /ephemeris and /reports used to be standalone full-screen routes,
each with its own `*-shell` root, a `*-topbar` holding one 「返回对话」 link,
and a `*-hero` with a page-sized h1. Three copies of one skeleton, none of
them carrying the sidebar. They share the chat panel now; the page name lives
in the 46px header and the body is the only thing that scrolls. */
.secondary-panel { background: var(--color-canvas-soft); }
.secondary-panel > :not(.chat-header) {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.chart-page-eyebrow {
width: min(1100px, 100%);
margin: var(--space-4) auto 0;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.chart-page-body,
.ephemeris-body,
.report-center-body,
.chart-page-message,
.ephemeris-message,
.report-center-message {
padding: 0 var(--space-6) var(--space-16);
}
.chart-page-message,
.ephemeris-message,
.report-center-message {
display: grid;
justify-items: center;
align-content: center;
gap: var(--space-3);
min-height: 60%;
text-align: center;
}
.chart-page-message p,
.ephemeris-message p,
.report-center-message p {
max-width: 44ch;
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.6;
}
.chart-page-message h2,
.ephemeris-message h2,
.report-center-message h2 {
margin: 0;
font-family: var(--font-display);
font-size: var(--type-title-lg);
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. */
.chart-page-body,
.ephemeris-body,
.report-center-body,
.chart-page-message,
.ephemeris-message,
.report-center-message,
.chart-page-eyebrow { padding-inline: var(--space-4); }
}
/* Starter home: greeting, composer, entry pills */
.welcome {
width: min(var(--session-column-width), 100%);
@@ -3278,60 +3376,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); }
.payment-qr-badge svg { display: block; width: 100%; height: 100%; }
/* Global report centre — quiet archive, independent from chat sessions. */
.report-center-shell {
width: 100%;
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 0 var(--space-6) var(--space-16);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
.report-center-topbar,
.report-center-hero,
.report-center-section {
/* Global report centre — quiet archive, independent from chat sessions. */.report-center-hero,
.report-center-section{
width: min(900px, 100%);
margin-inline: auto;
}
.report-center-topbar {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
}
.report-center-back {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-weight: 500;
text-decoration: none;
}
.report-center-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); }
.report-center-back:focus-visible,
.report-center-section-heading button:focus-visible,
}.report-center-section-heading button:focus-visible,
.personal-report-back:focus-visible,
.personal-report-disclosure:focus-visible {
.personal-report-disclosure:focus-visible{
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.report-center-back svg,
.report-center-section-heading button svg,
}.report-center-section-heading button svg,
.personal-report-back svg,
.personal-report-action-end svg { width: 16px; height: 16px; }
.personal-report-action-end svg{ width: 16px; height: 16px; }
.report-center-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -3340,16 +3396,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
padding: var(--space-10) 0 var(--space-8);
}
.report-center-hero > div { max-width: 760px; }
.report-center-hero h1 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-lg);
font-weight: 500;
letter-spacing: -1px;
line-height: 1.1;
text-wrap: balance;
}
.report-center-hero > div > p:not(.report-center-meta) {
max-width: 660px;
margin: var(--space-4) 0 0;
@@ -3511,10 +3557,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.report-center-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
@media (max-width: 767px) {
.report-center-shell { padding-inline: var(--space-4); }
.report-center-hero { grid-template-columns: 1fr; align-items: start; gap: var(--space-6); padding-block: var(--space-8) var(--space-6); }
.report-center-hero h1 { font-size: var(--type-display-md); letter-spacing: -.5px; }
.report-center-hero > button { width: 100%; }
.report-center-card { grid-template-columns: 1fr; gap: var(--space-4); }
.report-center-card-actions,
.report-center-card > a,
@@ -4314,84 +4357,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-message-wrap { display: grid; gap: var(--space-1); }
/* Read-only natal chart page. Independent route; does not grow page.tsx. */
.chart-page-shell {
width: 100%;
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 0 var(--space-6) var(--space-16);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
.chart-page-topbar,
.chart-page-hero,
.chart-page-tabs,
.chart-page-panel {
/* Read-only natal chart page. Independent route; does not grow page.tsx. */.chart-page-tabs,
.chart-page-panel{
width: min(1100px, 100%);
margin-inline: auto;
}
.chart-page-topbar {
min-height: 64px;
display: flex;
align-items: center;
}
.chart-page-back {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-weight: 500;
text-decoration: none;
}
.chart-page-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); }
.chart-page-back:focus-visible,
.chart-page-tab:focus-visible,
.chart-page-chip:focus-visible {
}.chart-page-tab:focus-visible,
.chart-page-chip:focus-visible{
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.chart-page-back svg { width: 16px; height: 16px; }
.chart-page-hero { padding: var(--space-8) 0 var(--space-6); }
.chart-page-eyebrow {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
font-weight: 500;
letter-spacing: 1.5px;
}
.chart-page-hero h1 {
margin: var(--space-3) 0 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-lg);
font-weight: 500;
letter-spacing: -1px;
line-height: 1.1;
text-wrap: balance;
}
.chart-page-hero > p:not(.chart-page-eyebrow):not(.chart-page-birthline) {
max-width: 40em;
margin: var(--space-4) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-md);
line-height: 1.65;
text-wrap: pretty;
}
.chart-page-birthline {
margin: var(--space-3) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.chart-page-tabs {
display: flex;
flex-wrap: wrap;
@@ -4603,7 +4577,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-page-unavailable > p { margin: 0 0 var(--space-3); font-size: var(--type-body-md); }
@media (max-width: 767px) {
.chart-page-hero h1 { font-size: var(--type-display-md); }
.chart-page-center-compact { display: block; }
.chart-page-center-extra { display: none; }
.chart-page-params-below { display: block; }
@@ -4640,64 +4613,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
/* ============================================================
Ephemeris page — facts only, own scroll (html/body overflow hidden)
============================================================ */
.ephemeris-shell {
width: 100%;
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 0 var(--space-6) var(--space-16);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
.ephemeris-topbar,
.ephemeris-hero,
============================================================ */.ephemeris-hero,
.ephemeris-section,
.ephemeris-footer {
.ephemeris-footer{
width: min(900px, 100%);
margin-inline: auto;
}
.ephemeris-topbar {
min-height: 64px;
display: flex;
align-items: center;
}
.ephemeris-back {
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-weight: 500;
text-decoration: none;
}
.ephemeris-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); }
.ephemeris-back:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.ephemeris-hero {
display: grid;
gap: var(--space-4);
padding: var(--space-6) 0 var(--space-8);
}
.ephemeris-hero h1 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-lg);
font-weight: 500;
letter-spacing: -1px;
line-height: 1.1;
text-wrap: balance;
}
.ephemeris-date-label {
margin: 0;
color: var(--color-ink-secondary);
@@ -4855,8 +4781,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.ephemeris-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
@media (max-width: 767px) {
.ephemeris-shell { padding-inline: var(--space-4); }
.ephemeris-hero h1 { font-size: var(--type-display-md); letter-spacing: -.5px; }
.ephemeris-date-bar { display: grid; grid-template-columns: 1fr 1fr 1fr; }
.ephemeris-date-button { width: 100%; }
.ephemeris-panchanga {
+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>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
/**
* 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;
};
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 };
} | 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,
});
}
} 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 };
}
+18 -13
View File
@@ -127,21 +127,21 @@ test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的
});
test("mobile width keeps two centre lines and moves the full parameter card below the chart", () => {
assert.match(
globalStyles,
/@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-center-compact \{ display: block; \}/,
);
assert.match(
globalStyles,
/@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-center-extra \{ display: none; \}/,
);
assert.match(
globalStyles,
/@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-params-below \{ display: block; \}/,
// 原值:三条断言都以 `@media (max-width: 767px) { .chart-page-hero h1` 起锚,
// 再加一条 `.chart-page-back` 的 44px 触摸目标。
// 新值:直接断那个断点块里的三个开关;`.chart-page-back` 一条删除。
// 原因:hero 与「返回对话」按钮随页面并入 app 外壳而删除(T4.2)。被断言的三个
// 开关本身一条没动,只是不再有 hero 那一行可以用来起锚。返回路径现在是
// 常驻侧栏,它的触摸目标由 sidebar-contract 管。
const mobile = globalStyles.slice(
globalStyles.indexOf("@media (max-width: 767px)", globalStyles.indexOf(".chart-page-center-compact")
- 2000),
);
assert.match(mobile, /\.chart-page-center-compact \{ display: block; \}/);
assert.match(mobile, /\.chart-page-center-extra \{ display: none; \}/);
assert.match(mobile, /\.chart-page-params-below \{ display: block; \}/);
assert.match(cssDeclarations(".chart-page-tab", globalStyles), /min-height:\s*44px/);
assert.match(cssDeclarations(".chart-page-chip", globalStyles), /min-height:\s*44px/);
assert.match(cssDeclarations(".chart-page-back", globalStyles), /min-height:\s*44px/);
assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/);
});
@@ -157,7 +157,12 @@ test("an incomplete profile page has no loading animation", () => {
test("the chart page shell is visible before the natal chart arrives", () => {
const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view: null }));
assert.match(markup, />星盘</);
assert.match(markup, /返回对话/);
// 原值:断言页面上有「返回对话」链接
// 新值:断言常驻侧栏在场(「新建对话」是它的第一项)
// 原因:星盘页并入 app 外壳后侧栏常驻,回对话不再依赖一个一次性链接;
// 这比原断言更强——它要求的是整条导航都在,而不只是一个返回按钮。
assert.match(markup, /新建对话/);
assert.doesNotMatch(markup, /返回对话/);
assert.match(markup, /这一张盘还没拿到/);
assert.doesNotMatch(markup, /正在加载|骨架|spinner|打开即有/i);
assert.doesNotMatch(markup, /chart-page-eyebrow/);
+7 -2
View File
@@ -196,11 +196,16 @@ test("global report copy uses the canonical profile, allows an unrectified minut
});
test("report centre and ready reader scroll inside the chat shell lock", () => {
const centre = cssDeclarations(".report-center-shell", globalStyles);
// 原值:`.report-center-shell`(报告中心自己的全屏滚动容器)
// 新值:`.secondary-panel > :not(.chat-header)`——报告中心并入 app 外壳后,
// 滚动所有权归外壳的内容区,三个次级页共用一条规则。
// 原因:T4.2。「页面在外壳的高度锁内部滚动、不自己撑高」这条要求没变。
const centre = cssDeclarations(".secondary-panel > :not(.chat-header)", globalStyles);
const reader = cssDeclarations(".personal-report-reader", globalStyles);
assert.match(globalStyles, /html, body \{ width: 100%; height: 100%; overflow: hidden; \}/);
assert.match(centre, /height:\s*100%/);
assert.match(centre, /overflow-y:\s*auto/);
// 高度由 .chat-panel 的 grid 行给定,内容区自己不再声明 height。
assert.match(cssDeclarations(".chat-panel", globalStyles), /grid-template-rows:\s*46px minmax\(0, 1fr\) auto/);
assert.match(centre, /-webkit-overflow-scrolling:\s*touch/);
assert.doesNotMatch(centre, /min-height:\s*100dvh/);
assert.match(reader, /height:\s*100%/);
@@ -77,10 +77,14 @@ test("print pins the palette back to light so paper is always paper", () => {
// 原因:BUG-737——那个"衬线栈"里的 Tiempos Headline 从未加载,中文标题实际落到宋体。
// --font-display 现在是无衬线,400 在大字号上发虚,display 层级改由字重承担。
test("report titles use the display stack at the display weight", () => {
const centreTitle = cssDeclarations(".report-center-hero h1");
// 原值:三处标题——报告中心页标题 `.report-center-hero h1`、封面、章节
// 新值:两处——中心页标题已随 hero 移进 app 外壳的 46px 顶栏(T4.2),
// 它现在走 `.chat-header strong`,由 chat-header 的规则统一管。
// 原因:报告中心不再是有自己 hero 的独立页。封面与章节标题是报告纸面自己的
// 排版,不受影响,仍然逐条断。
const coverTitle = cssDeclarations(".personal-report-cover h1");
const sectionTitle = cssDeclarations(".personal-report-section-heading h2");
for (const [name, body] of [["centre", centreTitle], ["cover", coverTitle], ["section", sectionTitle]] as const) {
for (const [name, body] of [["cover", coverTitle], ["section", sectionTitle]] as const) {
assert.match(body, /font-family:\s*var\(--font-display\)/, `${name} title must use --font-display`);
assert.match(body, /font-weight:\s*500/, `${name} title must stay weight 500`);
}
@@ -89,5 +93,8 @@ test("report titles use the display stack at the display weight", () => {
test("report chrome and appendix disclosure keep 44px targets", () => {
assert.match(cssDeclarations(".personal-report-back"), /min-height:\s*44px/);
assert.match(cssDeclarations(".personal-report-disclosure"), /min-height:\s*44px/);
assert.match(cssDeclarations(".report-center-back"), /min-height:\s*44px/);
// 原值:报告中心「返回对话」按钮的 44px 触摸目标
// 新值:删除该断言
// 原因:报告中心并入 app 外壳,返回路径是常驻侧栏(T4.2),
// 它的触摸目标由 sidebar-contract 与 touch-target-contract 管。
});
@@ -30,7 +30,10 @@ test("tablet CSS cuts at 1023 so it matches sidebarViewportForWidth", () => {
});
test("report centre and reader share the global mobile cut", () => {
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.report-center-shell/);
// 原值 `.report-center-shell` / 新值 `.report-center-body`
// 原因:报告中心并入 app 外壳(T4.2),页面根不再是自己的全屏滚动容器;
// 「用全局 767 断点、不自立断点」这条要求没变,下面两条 doesNotMatch 仍在守。
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.report-center-body/);
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.personal-report-reader-body/);
assert.doesNotMatch(css, /@media \(max-width: 720px\)/);
assert.doesNotMatch(css, /@media \(max-width: 760px\)/);
@@ -42,6 +45,7 @@ test("report TOC keeps a content-width exception at 860px", () => {
});
test("ephemeris page stacks at the global mobile cut", () => {
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-shell/);
// 原值 `.ephemeris-shell` / 新值 `.ephemeris-body` / 原因同上。
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-body/);
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-panchanga \{[\s\S]*grid-template-columns:\s*1fr/);
});