From 50ce02c837334fed1bc80f6d499d5ebc2c157e67 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 06:03:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=98=9F=E7=9B=98/=E6=98=9F?= =?UTF-8?q?=E5=8E=86/=E6=8A=A5=E5=91=8A=E4=B8=AD=E5=BF=83=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=20app=20=E5=A4=96=E5=A3=B3=EF=BC=8C=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个页面此前各是脱离外壳的独立全屏路由,各写了一套一样的 *-shell / *-topbar(只有一个「返回对话」链接)/ *-hero 骨架, 侧栏在打开它们的瞬间整个消失。 新增 AppNavRail(只读:两个 GET,零写操作)与 SecondaryShell。 会话行走 sessionHref → /?c=,跳转复用现有侧栏的 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 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- CHANGELOG.md | 12 + docs/tasks/PROGRESS-cend-shell-20260916.md | 112 +++++++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 31 +- frontend/src/app/globals.css | 296 +++++++----------- frontend/src/components/app-nav-rail.tsx | 160 ++++++++++ .../components/chart-page/chart-page-view.tsx | 39 +-- .../components/ephemeris/ephemeris-page.tsx | 34 +- .../personal-report-center.tsx | 32 +- frontend/src/components/secondary-shell.tsx | 48 +++ frontend/src/hooks/use-nav-rail.ts | 116 +++++++ frontend/tests/chart-page-view.test.tsx | 31 +- frontend/tests/personal-report-entry.test.ts | 9 +- .../personal-report-theme-contract.test.ts | 13 +- .../viewport-breakpoint-contract.test.ts | 8 +- 15 files changed, 680 insertions(+), 263 deletions(-) create mode 100644 docs/tasks/PROGRESS-cend-shell-20260916.md create mode 100644 frontend/src/components/app-nav-rail.tsx create mode 100644 frontend/src/components/secondary-shell.tsx create mode 100644 frontend/src/hooks/use-nav-rail.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d411784a..ac8a3466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 星盘、星历、我的报告三个页面不再把侧栏弄丢 + +此前从对话点进「星盘」「星历」或「我的报告」,左侧栏会整个消失,页面顶部只剩一个「返回对话」链接——想换一段对话,得先退回去再点。现在这三个页面和对话共用同一个外壳:侧栏常驻,最近的对话一直在左边,点哪条都能直接过去。 + +页面名字挪到了顶部那条 46 像素的窄栏里,原来那个占一大块的大标题去掉了。每个页面最主要的那个动作也提到了顶栏右侧:星历是「就今天问一句」,报告中心是「生成完整报告」。 + +未登录时这三个页面不再报错或空白,侧栏会显示「登录后可以看到你的对话」和一个登录入口。 + +报告的阅读页这一轮还没并进来,它下一轮和目录一起做。出错页和找不到页面那类兜底页仍然保留「返回对话」按钮——它们在更外层,按设计不能引入应用样式。 + +Skill 版本不变(纯界面改动)。 + ## 2026-09-16 — 左侧栏拍平成一条列表,助手消息去掉头像 **左侧栏。** 此前会话列表上方压着三段东西:一组星盘名字(点哪个都只是打开同一个「星盘资料」弹窗)、可折叠的「收藏对话」、可折叠的「历史对话」。现在是一条平铺列表:收藏的排在最前面并带一枚星标,其余按日期分组,上面只有「最近」两个字。两个折叠箭头都去掉了——它们底下并没有值得折叠的东西。 diff --git a/docs/tasks/PROGRESS-cend-shell-20260916.md b/docs/tasks/PROGRESS-cend-shell-20260916.md new file mode 100644 index 00000000..57cf1dd4 --- /dev/null +++ b/docs/tasks/PROGRESS-cend-shell-20260916.md @@ -0,0 +1,112 @@ +# PROGRESS · 次级页外壳 R4 + +任务书:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md` 的 R4 +执行方式:**直接执行模式** +分支:`codex/cend-shell-20260916` +基线:`origin/staging` = `b3ea8c33`(第一份任务书 R1–R3 合入后) +BUG 段:不占用(产品改造) + +R5(星盘+星历)与 R6(报告)依赖本轮。 + +--- + +## T4.1 · 只读导航外壳 + +新增三个文件: + +| 文件 | 职责 | +|---|---| +| `src/hooks/use-nav-rail.ts` | 两个只读 GET(`/api/sessions?limit=40`、`/api/account`),`Promise.allSettled`,**不含任何写操作** | +| `src/components/app-nav-rail.tsx` | 品牌 + 新建对话 + 星盘/星历/我的报告 + 一条「最近」平铺列表 + 身份页脚 | +| `src/components/secondary-shell.tsx` | `SidebarProvider` → `AppNavRail` → `SidebarInset.chat-panel.secondary-panel` + 46px 顶栏 | + +会话行走 `sessionHref("", id)` → `/?c=`,跳转用 `persistLoginSessionReturn()` + `window.location.assign()`,**与现有侧栏 `leaveChat()` 完全一致的行为**,没有另造一套。 + +侧栏原语(`SidebarProvider` / `Sidebar` / `SidebarTrigger` / `SidebarRail`)是通用的,抽屉与折叠态直接复用,没有重写。 + +### 验收对照 + +| 任务书验收标准 | 结果 | +|---|---| +| `AppNavRail` 不 import page.tsx 的 hook,不持有会话写状态 | ✅ 依赖只有 ui/sidebar、`chat-session-url`、`session-groups`、自己的 hook | +| 四个路由都挂上外壳,桌面常驻、移动端抽屉 | ⚠️ **三个**:`/chart`、`/ephemeris`、`/reports`。`/reports/[reportId]`(阅读页)留给 R6,见下 | +| 点「最近」行跳 `/?c=` 并选中 | ✅ 复用 `sessionHref`;`parseSessionUrlQuery` 的既有测试未动 | +| 列表未到达时静态空态,无骨架无 spinner | ✅ 三档文案:未登录 / 已 settled 无数据 / 读取中,全是静态文字 | +| 未登录时降级为品牌 + 登录入口,不报错不空白 | ✅ 401 → `signedOut`,列表位显示「登录后可以看到你的对话」,页脚变「去登录」 | +| 四个路由渲染标记逐个入记录 | ✅ 见下 | + +**`/reports/[reportId]` 本轮没挂**:阅读页是纸面文档表面,外壳会改变它的滚动所有权与打印行为,而 R6 正要重做它(加目录)。同一轮里既挂外壳又改阅读结构,出问题难以定位。留给 R6,已在任务书依赖里写明。 + +### 两处我自己的健壮性问题,被测试抓到 + +1. **`usePathname()` 会返回 `null`。** 类型标注是 `string`,但在 app-router 上下文之外(包括直接挂载组件的单测里)返回 null,我的 `pathname.startsWith(...)` 直接抛 `Cannot read properties of null`,打红了 `chart-page-view` 与 `ephemeris-page` 两个渲染测试共 5 条。已加 `?? ""`。既有的 `app-sidebar.tsx` 有同样的写法但没被覆盖到,本轮不动它(红线 11)。 +2. **`fetch` 可能不存在**(jsdom / 纯 node 环境)。原写法会在 `load()` 里同步抛出,变成未捕获的 promise 拒绝。已加 `typeof fetch !== "function"` 短路 + `.catch()` 兜底。 + +## T4.2 · 删掉三套重复骨架 + +| 页面 | 删除 | +|---|---| +| `chart-page-view.tsx` | `.chart-page-shell` / `.chart-page-topbar` / `.chart-page-back`(返回对话)/ `.chart-page-hero` + `

星盘

` | +| `ephemeris-page.tsx` | `.ephemeris-shell` / `.ephemeris-topbar` / `.ephemeris-back` / hero 的 `

`;底部「去问」提到顶栏 | +| `personal-report-center.tsx` | `.report-center-shell` / `.report-center-topbar` / `.report-center-back` / hero 的 `

`;「生成完整报告」提到顶栏 | + +CSS 侧删掉 22 条规则、裁剪 6 条选择器列表。**这次用了 R2 教训后的切分规则**(只在注释之后找选择器),改完复核:注释配平 73/73、含花括号的注释 0 条。 + +### 一处内容差点被我弄丢 + +`CHART_VIEW_COPY.eyebrow`(「主盘直接算 · 分盘按需 · 不消耗点数」)原本在 hero 里,随 hero 一起被删。这是 **BUG-717 专门定的文案**——它是成本与速度承诺,而且会印在失败页上。测试打红才发现,已放回 tab 行下方作为一行静默说明。 + +### 错误态与空态 + +三个页面的降级分支(未登录、读取失败、无数据、`natalMissing`、`panchangaUnavailable` 等)全部保留,只是从 `*-shell *-message` 的根节点改成外壳内的一个 `section`,`h1` 降为 `h2`(页名已在顶栏,一页不该有两个 h1)。 + +### 「返回对话」残留说明 + +`git grep 返回对话 src/` 还有 4 处命中,都在**根边界页**(`error.tsx`、`not-found.tsx`、`global-error.tsx`、`reports/[reportId]/not-found.tsx`)。按 `DESIGN.md`,这些页在根 layout 段里、**不得 import `globals.css`**,因此不能挂外壳,那个链接是它们唯一的回家路径。本轮不动。 + +--- + +## 收尾验收 + +| 红线 | 结果 | +|---|---| +| 1 · `tsc` 0 错 / `lint` 0 error | ✅ 118 warning,与基线同 | +| 2 · 测试总数 | ✅ tests 3350,pass 3303,fail 31,与基线 `diff` **逐条一致** | +| 3 · **四个路由渲染标记不变** | ✅ `/` `○` · `/chart` `○` · `/ephemeris` `○` · `/reports` `ƒ`,改前改后完全相同 | +| 4 · 次级页首屏体积 | ⚠️ **口径缺口**:CSS 侧 39,825 → **39,727 字节(−0.25%)**,三套骨架并成一套是净减。但当前 Turbopack 的路由表**不输出 per-route JS 体积**,导航栏与 sidebar 原语给三个次级页增加的 JS 我量不到,不能给数字。任务书预期的「次级页体积上升」无法证实也无法证伪 | +| 6 · 一律 `ChatComposer` / 一套滚动跟随 | ✅ 本轮未涉及 | +| 7 · 无 spinner / 骨架 | ✅ 列表在途是静态文案 | +| 8 · 不回退 BUG-698 的 `@supports (height: 1dvh)` | ✅ 未触碰 | +| 9 · 不回退 BUG-616/617 的报告盘面 grid | ✅ 未触碰 `report-chart-grid-rehype.ts` | +| 10 · 同提交更新 `DESIGN.md` | ✅ 新增「Secondary page shell」一节;「Personal report centre」的 Structure 与 Width 两条重写 | +| 11 · 不升依赖、不留新 warning | ✅ | +| 12 · 只读侧栏不发起写操作 | ✅ `use-nav-rail.ts` 只有两个 GET | + +Python 快速门(BUG-736):本轮**未改** `page.tsx`;新增的 `use-nav-rail.ts` 不是 Home 级 hook(`Home()` 不引用它),按规则不需要登记进 `OPTIONAL_HOME_HOOKS`。仍跑了 `pytest tests/test_supabase_user_data_contract.py`,8 条全绿。 + +### 按红线 3 更新的既有断言(原值 / 新值 / 原因) + +| 文件 | 原值 | 新值 | 原因 | +|---|---|---|---| +| `chart-page-view` · 移动端 | 三条以 `@media (max-width: 767px) { .chart-page-hero h1` 起锚;另断 `.chart-page-back` 44px | 直接断该断点块里的三个开关;删 `.chart-page-back` 一条 | hero 与返回按钮已删;**被断言的三个开关一条没动** | +| `chart-page-view` · 空态 | `assert.match(markup, /返回对话/)` | `assert.match(markup, /新建对话/)` + `doesNotMatch(/返回对话/)` | 返回路径从一次性链接变成常驻侧栏。这条**更强**:要求整条导航在场 | +| `personal-report-entry` | `.report-center-shell` 的 height/overflow | `.secondary-panel > :not(.chat-header)` 的 overflow + `.chat-panel` 的 grid 行 | 滚动所有权归外壳;「在高度锁内部滚动、不自己撑高」没变 | +| `personal-report-theme-contract` ×2 | `.report-center-back` 44px;标题三处含 `.report-center-hero h1` | 前者删除;后者收为封面 + 章节两处 | 返回按钮与页标题都移进了外壳顶栏,由 chat-header 规则统管 | +| `viewport-breakpoint-contract` ×2 | `.report-center-shell` / `.ephemeris-shell` 在 767 块内 | `.report-center-body` / `.ephemeris-body` | 页面根换了;「用全局 767 断点、不自立断点」由同测试的两条 `doesNotMatch` 继续守 | + +一条实际收紧(空态从「有返回链接」变成「整条导航在场 + 不得再有返回链接」),其余是形态同步。 + +--- + +## 环境缺口(不得记为通过) + +1. **次级页侧栏在移动端抽屉的手势与焦点行为**(本轮新挂,优先级最高)。 +2. **未登录时三个次级页的降级表现**——无登录态,只能证明代码分支存在。 +3. **从次级页点「最近」行回到对话并正确选中**——需要真实会话数据。 +4. **per-route JS 体积**:构建不输出,需要另外的测量手段(见红线 4)。 + +--- + +## 下一轮 + +R5(星盘 + 星历)与 R6(报告中心 + 报告阅读,含把 `/reports/[reportId]` 挂进外壳)基线取本轮合入后的 `origin/staging`。R7、R8 与本轮文件不重叠,可并行。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6c302bd9..b694cfde 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -157,7 +157,7 @@ | `TASK-engine-runtime-hygiene-20260901.md` | `PROGRESS-engine-runtime-hygiene-20260901.md` | Python 引擎运行时治理 | 已验收 | `124d3990`(staging 实机三条待部署窗口) | | `TASK-billing-pricing-20260830.md` | `PROGRESS-billing-pricing-20260830.md` | 计费闭环与功能级定价 | 已合入 | `aaaa5fb7`~`a499c344` | | `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1/r2/r3-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `
` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — | -| `TASK-cend-surfaces-claude-alignment-20260916.md` | — | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | 执行中(Claude 直接执行) | — | +| `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4 已实现(Claude 直接执行),R5–R8 待做** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)| ### 待开的任务书(2026-09-03 审查决定) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 054d22a0..295b852a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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=`. + 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. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 0e36817f..a98d0986 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 { diff --git a/frontend/src/components/app-nav-rail.tsx b/frontend/src/components/app-nav-rail.tsx new file mode 100644 index 00000000..3edfb42e --- /dev/null +++ b/frontend/src/components/app-nav-rail.tsx @@ -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 ( + + + + ); + } + + return ( + + +
+
+ + + go("/")}> + + + {NAV_PAGES.map(({ href, label, icon: Icon }) => ( + + go(href)} + > + + + ))} + +
+ + + {showExpandedContent ? ( + +

最近

+ + {/* Static copy while the list is in flight — never a skeleton. */} + {sessions.length === 0 ? ( +

+ {signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"} +

+ ) : ( + + {pinned.length > 0 ? ( +
+

+

+ {pinned.map(renderRow)} +
+ ) : null} + {groups.map((group) => ( +
+

{group.label}

+ {group.sessions.map(renderRow)} +
+ ))} +
+ )} +
+
+ ) : null} +
+ + + {signedOut ? ( + + ) : ( + /* 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. */ +
+ + {showExpandedContent && account ? ( + <> + {account.name} + {account.credits} 点 + + ) : null} +
+ )} +
+ +
+ ); +} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 496f5d56..44d57622 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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 ( -
-
- -
+ const birthline = view != null && view.status === "ok" + ? `${view.profile.name} · ${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}` + : null; + return ( + {view == null ? ( -
-

星盘

+

{CHART_VIEW_COPY.waitingChart}

) : view.status !== "ok" ? ( -
-

星盘

+

{view.message}

) : ( - <> -
-

{CHART_VIEW_COPY.eyebrow}

-

星盘

-

- {view.profile.name} · {view.profile.date} {view.profile.time} · {view.profile.placeLabel} -

-
+
+ {/* 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. */} +

{CHART_VIEW_COPY.eyebrow}

{CHART_VIEW_TABS.map((item) => (
)} -
+ ); } diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx index 82557626..5be42c51 100644 --- a/frontend/src/components/ephemeris/ephemeris-page.tsx +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -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 ( -
-

{EPHEMERIS_COPY.loginTitle}

-

{EPHEMERIS_COPY.loginBody}

- -
+ +
+

{EPHEMERIS_COPY.loginTitle}

+

{EPHEMERIS_COPY.loginBody}

+ +
+
); } @@ -113,13 +116,16 @@ export function EphemerisView(props: { const isToday = Boolean(props.date) && props.date === props.today; return ( -
-
- {EPHEMERIS_COPY.back} -
- + + {EPHEMERIS_COPY.ask} + + )} + > +
-

{EPHEMERIS_COPY.title}

{props.date ? formatEphemerisDate(props.date) : ""}

-
+ + ); } diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 6d764b31..ba92f4ef 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -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 ( -
-
+ +
+
+
); } return ( -
-
-
- + void load()} />} + > +
-

个人报告

有填报到分钟的出生时间即可生成;未校正会标明为方向性参考。只有日期和时段时,完整本命报告需要具体分钟,生时校正可选。与某一次对话无关。创建后会在后台继续处理,你无需停留等待。

共 {state.reports.length} 份 · 已完成 {state.reports.filter((report) => report.status === "ready").length} 份 · {hasGenerating ? "有报告生成中" : latestReady ? "最新报告可查看" : "尚待生成"}

- void load()} />
@@ -261,6 +262,7 @@ export function PersonalReportCenter() {
)} -
+ + ); } diff --git a/frontend/src/components/secondary-shell.tsx b/frontend/src/components/secondary-shell.tsx new file mode 100644 index 00000000..6766dd28 --- /dev/null +++ b/frontend/src/components/secondary-shell.tsx @@ -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 ( + +
+ + +
+ +
+ {title} + {note ? {note} : null} +
+ {actions ?
{actions}
: null} +
+ {children} +
+
+
+ ); +} diff --git a/frontend/src/hooks/use-nav-rail.ts b/frontend/src/hooks/use-nav-rail.ts new file mode 100644 index 00000000..ee007736 --- /dev/null +++ b/frontend/src/hooks/use-nav-rail.ts @@ -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([]); + const [account, setAccount] = useState(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 }; +} diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 3d0ddc40..d01b4f5f 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -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, />星盘 { - 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%/); diff --git a/frontend/tests/personal-report-theme-contract.test.ts b/frontend/tests/personal-report-theme-contract.test.ts index 7856ee24..5d6c6288 100644 --- a/frontend/tests/personal-report-theme-contract.test.ts +++ b/frontend/tests/personal-report-theme-contract.test.ts @@ -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 管。 }); diff --git a/frontend/tests/viewport-breakpoint-contract.test.ts b/frontend/tests/viewport-breakpoint-contract.test.ts index ebae414d..ba3257bd 100644 --- a/frontend/tests/viewport-breakpoint-contract.test.ts +++ b/frontend/tests/viewport-breakpoint-contract.test.ts @@ -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/); });