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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user