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
+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 };
}