diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6fbb43..e623a384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 印度占星 Skill 更新日志 +## 2026-10-24 — 侧栏可以拖宽,会话可以拖着排 + +- 桌面侧栏可以在 200 到 360 像素之间拖宽。拖到最左边会收成一条窄轨,再打开时回到上次的宽度。这个宽度记在这台浏览器里。 +- 手机上侧栏从左边滑出来。当前页的高亮会滑到新的一项上。 +- 会话行的更多菜单是收藏、重命名、转发、删除。删除前要再确认一次。同一组里可以拖动排序,键盘也可以排。换一台登录的设备,顺序还在。收藏的会话仍然排在最前。星盘档案这次不排序。 +- 左下角账户按钮只显示头像和名字。点数在顶栏,是硬币图标加数字,「点」只读给读屏。设置仍是个人资料、账户与点数、通用设置。退出登录要再确认一次。 +- 右上角的启程清单还在。 +- Skill 版本不变(BUG-1433)。 + ## 2026-10-24 — 右上角出现启程清单,用过的功能会打勾 - 建完档之后,首页右上角是「启程 n/7」。七项按真实使用打勾。合盘和个人报告写出计价表上的价格;对应开关关着时,这一行不出现。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ea1b0244..0e0ba983 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19414,3 +19414,19 @@ - 复发自:无 - 修复版本:分支 `codex/onboarding-3-checklist-20261022`。 +## BUG-1433 | 外壳换成组件库,会话可以拖动排序 + +- 状态:enhancement +- 首次发现:2026-10-24 +- 最近更新:2026-10-24 +- 影响面:侧栏、会话列表、账户菜单、设置、顶栏点数、页面切换。星盘档案的顺序不在这次里。 +- 用户现象:桌面侧栏可以拖宽,拖到最左会收起,宽度记在这台浏览器里。手机上侧栏从左边滑出。会话可以拖动排序,换设备还在;收藏的仍然排在最前。账户按钮只显示头像和名字。点数按钮是硬币图标加数字。 +- 触发条件:打开已登录的页面。会话排序在拖动或键盘排序之后写入。 +- 根因:不是缺陷。组件库迁移的外壳这一张(任务书 K1,决策 D5–D9)。 +- 修复:侧栏、菜单、设置、反馈和注销确认改用已有的组件库零件。会话增加可空的 `sidebar_order`,只加列,不改已有查询的时间顺序;收藏仍先列出,组内再按这个顺序。只改了顺序时不改会话的更新时间。启程清单仍挂在顶栏。`Home()` 的状态个数没有增加。 +- 验证:见 `docs/tasks/PROGRESS-ui-kit-1-shell-20261023.md`。没有浏览器登录态,不把拖侧栏写成已经在浏览器里拖过。 +- 防复发:侧栏、会话行、账户和设置的合同测试锁住组件库零件、拖宽范围、收藏仍在最前,以及排序列只加在 `supabase/migrations`。白名单测试锁住名单只减不增。 +- 相关记录:issue #105。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K1。前置 #104 的组件库底座、#97 的启程清单。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-1-shell-20261023`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-1-shell-20261023.md b/docs/tasks/PROGRESS-ui-kit-1-shell-20261023.md new file mode 100644 index 00000000..d49e6776 --- /dev/null +++ b/docs/tasks/PROGRESS-ui-kit-1-shell-20261023.md @@ -0,0 +1,87 @@ +# PROGRESS · 组件库 K1 外壳(2026-10-25) + +工单 #105。分支 `codex/ui-kit-1-shell-20261023`。基线 `origin/staging` `8f7c0f7db42f8cca6122ae39498435f59d63a483`(#97 已在这个提交里)。只做任务书 **K1 外壳**。K0 已完成,K2 及之后没做。 + +## 结论 + +桌面侧栏可以在 200 到 360 像素之间拖宽。拖到最左边收成窄轨,再拉开回到刚才的宽度,这个宽度记在这台浏览器里。手机上侧栏从左边滑出。会话可以在同一组里拖动排序,键盘也可以排;换一台登录的设备,顺序还在。收藏的会话仍然整组排在最前。星盘档案这次不排序。账户菜单和设置换成组件库。顶栏点数只显示硬币图标和数字。右上角启程清单还在。 + +## 做了什么 + +- 桌面侧栏放进可拖分栏。宽度 200–360,默认 288,记在本机 `sidebar_width`。拖到最左收成 64 像素窄轨;64 不写进记住的宽度。手机用组件库 Sheet,从左边滑出,宽 `min(86vw, 320px)`。 +- 当前页高亮在动效库载入之后用已有的 `layoutId` 滑动。在那之前是一块静止的底色,不另写时长。 +- 翻页在动效库载入之后用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序:新对话、星盘、星历、报告、合盘、人物。路由不等待动效。连点五项时停在最后一项。动效库还没到时,页面就是普通容器,首页不会因为库到了再挂载一次。库到达之后的第一次切换可能看不到离开的那一页叠在上面,之后的切换按规范重叠。 +- 会话行的更多菜单是收藏或取消收藏、重命名、转发、删除。删除前用确认框。拖动排序用 dnd-kit,只在收藏组内或最近的一组内移动。顺序写入新列 `sidebar_order`。没保存过的空会话只改本机顺序,不发请求。保存失败的几条退回原位,并提示「排序没有保存」。只改顺序时不改会话的更新时间。列表查询仍按更新时间取,收藏仍先列出,组内再按这个顺序。 +- 左下角账户按钮只显示头像和名字。主题在菜单里用单选组。点数不在这个按钮上重复。 +- 设置是对话框加三个页签,名字仍是个人资料、账户与点数、通用设置。主题用分段页签。退出登录要再确认一次。反馈与投诉仍是对话框。注销账号的确认还在。 +- 顶栏点数是正常大小的描边按钮,硬币图标加数字,44px。「点」只在读屏名称「余额 N 点,会员与充值」里。数字变少往下滚,变多往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。 +- 菜单、可拖分栏、拖动排序和翻页用的库在页面出来后再载入,不进首屏同步包。Motion 仍走已有的按需 `domAnimation`,没有把整页包进动效提供者。手机抽屉的 Sheet 留在侧栏零件里,打开抽屉的测试要当场看见它。 +- `Home()` 的 `useState` / `useRef` 没有增加。增长合同通过。白名单去掉 8 个已经不再违规的文件,没有加长,也没有改测试里写死的基线名单。`page.tsx` 仍在白名单里。 +- 删掉迁移后不再使用的手写类:`.session-row`、`.session-actions`、`.profile-trigger`、`.new-chat`、`.report-nav-button`、`.sidebar-scrim`,以及手机抽屉的位移。对应合同测试同一轮改了,见下表。 +- 新出现的一句是「排序没有保存」。对照文案口径:短句,不另加解释。`frontend/docs/VOICE.md` 没有改,它不是这句的清单。 +- 没有改 `.gitea/**`,没有改生产部署配置,没有给 `scripts/jyotish_api_server.py` 加方法。 + +## 数据库 + +只加列:`chat_sessions.sidebar_order integer null default null`,并授权已登录用户更新这一列。迁移文件 `frontend/supabase/migrations/20261025010000_chat_session_sidebar_order.sql`。没有放进 `frontend/db/migrations`。没有删列,没有改类型,没有收紧 `NOT NULL`。 + +`npm run test:db`(frontend,Docker 29.8.0)退出码 1。92 项,通过 79,失败 13。失败的 13 个名字与 `tools/agent-loop/windows-known-failures.txt` 逐条一致(备份目录 7 项、环境文件权限 3 项、个人报告任务权限 2 项)。含排序列的那一项通过:`ok 37 - local PostgreSQL applies the reviewed business schema and serves authenticated business calls`。它核对迁移已应用,列是可空 integer,并且没有复制到 `frontend/db/migrations`。不把整套写成通过,也不记入 `BLOCKED.md`。 + +## 断言改动(原值 / 新值 / 原因) + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 账户按钮 | `profile-trigger` | `sidebar-account-trigger` | 去掉手写类。仍是按钮,不是回首页的链接 | +| 账户菜单 | `Menu.Item` / `Menu.Root` | `DropdownMenu` / `DropdownMenuItem` | 改用组件库。站外页仍链到 `/?account=` | +| 设置弹窗 | `renderToString` 看到分区名和表单类 | 这些字符串锁在源码里 | 组件库对话框在这个测试环境里不输出 HTML。三个分区名不变 | +| 注销确认 | `role="dialog"` | 组件库根是 `alert-dialog` | 确认框改用 AlertDialog。页面仍被 inert 盖住 | +| 侧栏导航类 | `.new-chat`、`.report-nav-button` | `.sidebar-nav-link`,新建对话再加 `data-nav="new"` | 导航合成一套菜单行。左对齐、行动色和图标尺寸不变 | +| 手机抽屉 | 手写遮罩、`translateX`、禁止出现 Sheet | `Sheet`,`side="left"`,桌面直接渲染同一棵侧栏 | 手机从左边滑出。内容仍是同一个侧栏变量 | +| 会话行 | `.session-row` | `.sidebar-session`;收藏用 `renderSortableGroup` | 行改名,组内可拖。收藏整组仍在最前 | +| 顶栏点数 | `.credit-button`,与校正窥视同为 40px | `.credit-balance` 高 44px;窥视仍 40px | 点数是正常大小的描边按钮 | +| 外壳孩子 | `SidebarInset` 直接包 `{children}` | 中间是 `PageSwitch`,再原样放 `{children}` | 翻页包在外壳上。首页内容没有被复制 | +| 侧栏宽度 | `.chat-app` 网格列 288 / 240 / 64 | 纵向 flex,宽度由可拖面板记住 | 桌面可以拖宽,不再写死三档 | +| 列表列 | 到 `pinned,archived_at` | 末尾加 `sidebar_order`,仍不含 `created_at` 与 `messages` | 只加排序列。查询仍按更新时间 | +| 人物切换 | 直接写 base-ui `Popover.Root` | shadcn `Popover` | 改用组件库。关合和 Escape 不变 | +| 反馈表单 | 渲染整个 `FeedbackDialog`,类型是 radio | 渲染 `FeedbackDialogBody`,类型是 tab | 对话框要等浏览器挂上。提交字段不变 | +| 手机测试找侧栏 | 只在页面容器里找 | 容器和 `document.body` 都找 | Sheet 打开后挂在 body 上 | +| 测试替身 `self` | `self: window` | `self: globalThis` | 残缺的 window 让 `cn` 解析失败。真实全局才有 `setTimeout` | +| 白名单 | 含设置、反馈、账户、主题等 8 个文件 | 这 8 个移出 | 它们已经改用组件库。名单只许变短 | +| 加号入口 | 字符「+ 添加一个人」 | Lucide `Plus` 加「添加一个人」 | 去掉当图标用的字符。入口和人数上限不变 | +| 头像说明 | 必须出现「刷新和换设备后保持一致」 | 这句不再出现 | 去掉重复解释布局的小字。配色和尺寸仍在 | + +白名单移出的 8 个文件:`account-deletion-gate.tsx`、`account-deletion-section.tsx`、`account-dialog-overlay.tsx`、`billing-panel.tsx`、`feedback-dialog.tsx`、`profile-panel.tsx`、`subject-switcher.tsx`、`theme-preference-menu.tsx`。 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | 仓库根 `frontend\node_modules\.bin\tsc.cmd --noEmit -p frontend` | 退出码 0 | +| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。0 error,121 warning。与 #96、#97 记下的 121 条相同。没有去修无关警告 | +| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 下面列出的文件 | 205 项,pass 205,fail 0,cancelled 0,skipped 0,todo 0,退出码 0 | +| 数据库 | 在 `frontend` 下 `npm run test:db` | 退出码 1。92 项,通过 79,失败 13,名字与 Windows 已知清单一致。排序列在通过的 ok 37 里 | +| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。`/` 在 `.next/prerender-manifest.json` 里 `compute` 为 `static`。`skill-package-registry.ts` 的 Turbopack 追踪警告是改前就有的,没有改 | +| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `8f7c0f7d`:32 个文件,缺失 0,621226 字节。这一张:36 个文件,缺失 0,630350 字节。+9124 字节,+1.47%。621226 × 1.02 = 633650.52,留约 3300 字节 | + +定向测试文件:`account-dialog-inert-20260918`、`account-dialog-overlay`、`account-menu-every-page-20260928`、`beam-avatar`、`chart-birth-editor-and-report-table`、`chat-navigation-a11y-contract`、`chat-profile-picker`、`chat-session-authority`、`consultation-session-capacity`、`feedback-complaints-20260930`、`home-sidebar-rerender`、`home-shell-growth-contract`、`mobile-viewport-scroll-lock-20260917`、`motion-presets`、`new-chat-from-people-lifecycle`、`personal-report-entry`、`session-list-lifecycle`、`session-list-provider`、`sidebar-contract`、`starter-questions`、`synastry-report-surface`、`ui-kit-whitelist`。 + +首屏里没有 `ProjectionNode`、`DndContext`、`dropdown-menu`、`resizable`(这四项在首屏脚本中都是 0,在其余产物里分别是 3、1、2、2)。动效、拖动排序、菜单和可拖分栏都在页面出来之后再载入。 + +没有跑前端全量 `npm test`,没有跑 Python 快速门。Python 入口和 `scripts/jyotish_api_server.py` 没有改。 + +## 偏离 + +- 为了停在 ±2% 以内,翻页、高亮、拖宽、拖动排序和账户菜单都改成页面出来后再载入。没有为了体积去改启程清单,也没有删无关页面。 +- 菜单库还没到时,账户按钮和会话「更多」先是普通按钮,点不开菜单。库到了之后才是真正的菜单。账户按钮的焦点引用也在这时接上。 +- 手机 Sheet 没有再拆出首屏。拆掉会让「打开抽屉」的测试和源码锁对不上,而体积已经在限额内。 + +## 环境缺口 + +- 没有登录态。没有在浏览器里拖过侧栏,没有点过菜单,没有用键盘排过会话。不能写成已经在真机上看过。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md`。 +- 排序跨设备保留,靠上面的数据库测试核对列已经加上、可空、类型是 integer。没有第二台登录设备可看。 + +## 记录 + +- BUG-1433,状态 `enhancement`。不是缺陷。编号接在 BUG-1432 之后。 +- `CHANGELOG.md`(条目日期 2026-10-24)、`frontend/DESIGN.md`、`docs/testing/ui-kit-migration-20261023.md`。 +- Skill 版本不变。 diff --git a/docs/testing/ui-kit-migration-20261023.md b/docs/testing/ui-kit-migration-20261023.md new file mode 100644 index 00000000..40091656 --- /dev/null +++ b/docs/testing/ui-kit-migration-20261023.md @@ -0,0 +1,23 @@ +# 组件库外壳 · 真机清单(2026-10-24) + +用自己的账户看。不要把出生资料、邮箱或会话原文写进记录。这一轮没有登录态,下面各项都还没在浏览器里拖过,也没有点过菜单。高亮滑动和拖宽用的部件在页面出来后再载入。 + +## 侧栏 + +1. 桌面上,按住侧栏右缘拖动。宽度停在大约 200 到 360 像素之间。拖到最左边,侧栏收成一条窄轨。再拉开,宽度回到刚才记下的宽度。刷新页面,这个宽度还在。 +2. 把窗口改成大约 375 像素宽。点左上角打开侧栏,它从左边滑出。点一项之后抽屉关上。页面没有横向滚动条。 +3. 当前页的高亮会滑到新点的那一项。连点侧栏五项,最后停在最后点的那一页。 + +## 会话 + +1. 收藏的会话仍在最上面。在收藏里拖一条到另一条上面,顺序变了,刷新后还在。在最近里拖一条,只改变这一组,不会插进收藏。 +2. 用键盘把焦点放在拖动手柄上,也可以上下移动。 +3. 换一台登录同一账户的设备,顺序还在。星盘档案的名单顺序没有变化。 +4. 点会话行的更多,菜单是收藏或取消收藏、重命名、转发、删除。点删除会先问一次。 + +## 账户和点数 + +1. 左下角只有头像和名字,没有点数。 +2. 顶栏点数是硬币图标加数字。读屏能听到「点」,眼睛看不到这个字。 +3. 设置里仍是个人资料、账户与点数、通用设置。退出登录要再确认一次。反馈与投诉仍能打开。 +4. 建完档的账户,右上角启程清单还在,点数按钮的左边。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ab8ab532..457cc796 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1,5 +1,9 @@ # Jyotisha Web Design System +## 外壳(2026-10-24) + +桌面侧栏在可拖分栏里,宽 200–360px,默认 288。拖到最左收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动、拖宽和拖动排序用的库在页面出来后再载入,不进首屏。 + ## 启程清单与一次性提示(2026-10-24) 首页顶栏右侧、点数按钮左边,是一枚胶囊「启程 n/N」(`.onboarding-checklist-trigger`)。它沿用顶栏按钮的 32px 高度,圆角 999px,字和图标间距 6px。左边是 18×18 的进度环,描边按已完成的项画满。视口还没分出手机或桌面时,这枚胶囊不可打开。手机(侧栏视口小于 768)从底部抽屉拉出,带现有的下拉把手。平板和桌面用现有弹出层,靠右对齐,宽 `min(22rem, calc(100vw - 2rem))`,行是 `1.25rem minmax(0, 1fr) auto`,句子 `overflow-wrap: anywhere`。已完成的行用对勾,没有「去做」。底部是「先不看了」。 @@ -499,7 +503,7 @@ The birth-time rectification session is the consultation transcript plus a house - Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line. - The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). A click on the dialog's padding, or releasing a text selection there, does not close the dialog or drop unsaved edits. Escape still closes it, and so does a click on the backdrop outside the dialog box. Below 768px that row wraps onto a second line and the chart header grows to fit it. - With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px. -- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring. +- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit control (`.credit-balance`) is a normal outline button, 44px, with a coins icon and the number; 点 stays in the accessible name. The rectification 「当前盘面」 chip stays 40px. The shared `::before` hit area stays inset -2px. The onboarding checklist trigger stays 32px. - Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen. ### Navigation item @@ -508,16 +512,16 @@ The birth-time rectification session is the consultation transcript plus a house - **States:** default, hover, current, focus, disabled. - **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity, including rectification turns, choice, adopt, and stop. Opening, refresh, and metadata PATCH do not bump it. A `?c=` that is not on the loaded page is looked up with `GET /api/sessions/{id}` before anyone may say it was deleted. Locked by `frontend/tests/session-open-preserves-identity.test.ts` and `frontend/tests/session-lookup-unlisted.test.ts`. - **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles. -- **Surface:** translucent warm-gray sidebar. Current and hover fill the whole `.session-row` — title and the ⋯ share one chip. The menu trigger has no canvas of its own. +- **Surface:** translucent warm-gray sidebar. Current and hover fill the whole `.sidebar-session` — title and the ⋯ share one chip. The menu trigger has no canvas of its own. A drag handle sits on the row only when that list can be reordered. ### Sidebar shell - **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset. `/`、`/chart`、`/ephemeris`、`/reports` share one `(app)` layout: one `SessionListProvider` and one `SidebarProvider`. The session list is fetched once per tab; walking between those four pages does not remount the sidebar or refetch `/api/sessions`. - **Home registers shell controls** (new chat, select session, rename, pin, share, delete, account menu). The other three pages leave `controls` unset, so rows are links to `/?c=`. - **Trigger placement:** the single visible collapse/expand trigger sits beside the active session title in the chat header. The sidebar brand row has no duplicate trigger. -- **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail. -- **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded. -- **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim or Escape. +- **Desktop:** a resizable panel, default 288px at 1024px and above, drag range 200–360px. Dragging to the left edge collapses to the 64px rail. The open width is remembered per browser under `sidebar_width`. The 64px rail is not stored as that width. +- **Tablet:** 64px collapsed by default from 768px through 1023px. Expanding uses the same panel; with nothing stored yet the width starts at 240px, then follows `sidebar_width`. +- **Mobile:** no icon rail. A left sheet uses `min(86vw, 320px)` and closes through the sheet, a navigation choice, or Escape. - **Collapsed content:** logo, new-chat action, the page actions, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons. - **Expanded content:** the header actions (new chat, 星盘, 星历, 我的报告), then **one flat list** under a single 「最近」 heading: pinned sessions as the first labelled group, then history grouped by recency. It used to be three stacked sections — a 星盘列表 chip grid and two `
` wrappers for 收藏对话 / 历史对话 — so the nav carried two collapse affordances before the first session row, and neither had anything worth collapsing. The chip grid went entirely: every chip, and the add button beside it, opened the same chart-library dialog that the account menu already offers, so a list of names provided no navigation. Chart data is managed from one place now (account menu → 星盘资料). @@ -816,7 +820,7 @@ page. Three parts now, in reading order: ### Account popover -- **Structure:** identity header, then 个人资料 / 通用设置 / 账户与点数 (with the current balance), theme preference, divider, and logout. 星盘档案不在这个菜单里,它是侧栏导航页 `/people`。 +- **Structure:** the trigger is the avatar and the name only. The menu is 个人资料 / 通用设置 / 账户与点数, then theme as a radio group, then 反馈与投诉, then logout. The balance is not repeated on the trigger. 星盘档案不在这个菜单里,它是侧栏导航页 `/people`。 - **Surface:** 280px elevated canvas popover anchored above the sidebar account trigger; warm hairline, existing elevated shadow, no nested cards. - **States:** closed, open, hover, focus-visible, and logout confirmation. - **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, and focus return. @@ -844,7 +848,7 @@ The 👍 / 👎 under an answer (回复评价) is saved per conversation and mes - **Placement:** the overlay and the onboarding paywall portal to `document.body`. `SidebarInset` stays `inert` while a dialog is open (the BUG-744~746 focus contract); the dialog itself must not sit inside that subtree (BUG-968). Closing still uses the existing overlay click, the header button, and the window-level Escape listener. - **Redeem paywall timing (2026-09-28, TASK-birth-sky-polish-20260928 P4):** the 「解锁完整咨询」 paywall no longer opens when the starter home appears. The first send on the page refused for lack of credits (the pre-flight balance check or a 402) opens it, and the typed question stays in the composer; every later refusal opens the billing dialog as before. Rectification's own 402 path is unchanged. -- **Structure:** one fixed chrome for three panes — 个人资料, 账户与点数, 通用设置. The chart archive is no longer a pane; `?settings=chart` leaves for `/people`. Left nav is 200px, has a hairline boundary, and does not scroll; the title bar stays put; only the right-hand content pane scrolls. The pane menu uses icon + label rows without a trailing chevron because these controls switch content inside the same dialog. Logout stays a separate 400px confirmation. +- **Structure:** one fixed chrome for three panes — 个人资料, 账户与点数, 通用设置. The names are unchanged. The pane switch is Tabs. The chart archive is no longer a pane; `?settings=chart` leaves for `/people`. Left nav is 200px, has a hairline boundary, and does not scroll; the title bar stays put; only the right-hand content pane scrolls. The pane menu uses icon + label rows without a trailing chevron because these controls switch content inside the same dialog. Theme on the general pane is the same segmented control. Logout is a separate AlertDialog, still capped at 400px. Feedback is a Dialog. - **Width / height:** desktop `width: min(100vw - 32px, 880px)`, with `height: min(84vh, 640px)` as the base and `min(84dvh, 640px)` applied inside `@supports (height: 1dvh)`. The right content pane uses 32px horizontal and bottom inset on desktop. All four panes share one class (`.settings-modal`), so switching panes cannot change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top and 16px content inset. - **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, no trailing navigation chevron, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited. - **Content width:** the 880px frame now gives the content pane a deliberate inset instead of leaving controls against the divider. Form panes (个人资料, 通用设置) cap their children at 560px and stay left aligned (`.settings-dialog-content--form > *`); list panes (星盘资料, 账户与点数) stay full-bleed within the inset so tables and card grids keep their columns. The cap sits on the children of the scroll container only — never on `.settings-modal` or `.account-settings-shell` — so it cannot make the frame resize between panes (BUG-554 / BUG-698). diff --git a/frontend/eslint/ui-primitive-whitelist.json b/frontend/eslint/ui-primitive-whitelist.json index b43d8287..d78a1dce 100644 --- a/frontend/eslint/ui-primitive-whitelist.json +++ b/frontend/eslint/ui-primitive-whitelist.json @@ -2,11 +2,7 @@ "src/app/(app)/page.tsx", "src/app/error.tsx", "src/app/global-error.tsx", - "src/components/account-deletion-gate.tsx", - "src/components/account-deletion-section.tsx", - "src/components/account-dialog-overlay.tsx", "src/components/ayanamsa-preference-field.tsx", - "src/components/billing-panel.tsx", "src/components/birth-place-picker.tsx", "src/components/birth-sky/birth-sky-converge.tsx", "src/components/birth-sky/birth-sky-replay.tsx", @@ -29,7 +25,6 @@ "src/components/conversation-follow-ups.tsx", "src/components/email-otp-login.tsx", "src/components/ephemeris/ephemeris-page.tsx", - "src/components/feedback-dialog.tsx", "src/components/home-bootstrap-error.tsx", "src/components/home-onboarding-shell.tsx", "src/components/jump-to-latest-button.tsx", @@ -47,7 +42,6 @@ "src/components/personal-report/report-export-drawer.tsx", "src/components/personal-report/report-row-delete.tsx", "src/components/profile-gender-field.tsx", - "src/components/profile-panel.tsx", "src/components/rectification-agentic-chat.tsx", "src/components/rectification-board.tsx", "src/components/rectification-choice-card.tsx", @@ -56,9 +50,7 @@ "src/components/rectification-segment-delivery.tsx", "src/components/reply-down-reason.tsx", "src/components/starter-home.tsx", - "src/components/subject-switcher.tsx", "src/components/synastry/synastry-add-dialog.tsx", "src/components/synastry/synastry-picker.tsx", - "src/components/synastry/synastry-report-view.tsx", - "src/components/theme-preference-menu.tsx" + "src/components/synastry/synastry-report-view.tsx" ] diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index ec36ff4a..60bdcab8 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -6,6 +6,8 @@ import { AccountDeletionGate } from "@/components/account-deletion-gate"; import { AppSidebar } from "@/components/app-sidebar"; import { OnboardingHintLayer } from "@/components/onboarding-hint-layer"; import { LegalConsentGate } from "@/components/legal/legal-consent-gate"; +import { PageSwitch } from "@/components/shell-motion"; +import { ShellFrame } from "@/components/shell-frame"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { OnboardingJourneyProvider } from "@/lib/onboarding-journey"; import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list"; @@ -20,26 +22,30 @@ function AppShell({ children }: { children: ReactNode }) { return ( -
- - - {children} - - -
- +
+ + )} + > + + + {children} + + + + +
+
diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index b0dc5a36..a924b013 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -2,10 +2,17 @@ import dynamic from "next/dynamic"; import { loadBillingPanel } from "@/lib/billing-panel-loader"; -import { Coins } from "lucide-react"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; +import { CreditBalanceButton } from "@/components/credit-balance-button"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; import { ProfilePanel } from "@/components/profile-panel"; import { AccountDeletionSection } from "@/components/account-deletion-section"; import { ThemePreferencePanel } from "@/components/theme-preference-menu"; @@ -415,6 +422,7 @@ export default function Home() { renameSession, deleteSession, togglePinnedSession, + reorderSessions, shareSession, startNewChat, bindEmptyChatSubject, @@ -1117,6 +1125,7 @@ export default function Home() { shareSession, startNewChat, togglePinnedSession, + reorderSessions, visibleSessions, }); @@ -1172,8 +1181,8 @@ export default function Home() { <>

退出后,需要重新登录才能继续查看对话。

- - + +
); @@ -1203,20 +1212,20 @@ export default function Home() { {replyAnnouncement} {pendingSessionDeletion ? ( -
setPendingSessionDeletion(null)}> -
event.stopPropagation()}> -

删除聊天记录?

-

“{pendingSessionDeletion.title}”将被永久删除,无法恢复。

+ { if (!next) setPendingSessionDeletion(null); }}> + + 删除聊天记录? + “{pendingSessionDeletion.title}”将被永久删除,无法恢复。
- - + + }}>确认删除
-
-
+ + ) : null}
@@ -1242,10 +1251,11 @@ export default function Home() {
- + openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })} + />
diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index 9757b3f6..d9a04527 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -23,7 +23,7 @@ import { CHART_SUBJECT_SELECT } from "@/lib/subject-birth"; type RouteContext = { params: Promise<{ id: string }> }; -const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,created_at,updated_at,pinned,archived_at"; +const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,created_at,updated_at,pinned,archived_at,sidebar_order"; const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function payloadHasMessages(payload: unknown): payload is { messages: unknown } { diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index bf2e00ad..e153db02 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -26,7 +26,7 @@ import { import { excludeEmptyConsultations } from "@/lib/session-list-filter"; import { authoritativeSynastrySession, SynastrySessionError } from "@/lib/synastry-report-session"; -const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,updated_at,pinned,archived_at"; +const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,updated_at,pinned,archived_at,sidebar_order"; const SELF_SESSION_FILTER = "chart_profile_id.is.null,chart_profile_id.eq.self,synastry_partner_subject_id.eq.self"; const SUBJECT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5215026a..663350fd 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -487,8 +487,9 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 .session-nav:last-of-type { min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; } .chevron { width: 18px; height: 18px; color: var(--color-ink-tertiary); } -.credit-button span { display: inline-flex; align-items: center; margin: 0; color: inherit; font-size: inherit; line-height: 1; } -.credit-button .credit-icon { width: 16px; height: 16px; flex: 0 0 auto; } +.credit-balance .credit-roll { overflow: hidden; display: inline-grid; height: 1.2em; } +.credit-balance .credit-roll-value { display: inline-flex; align-items: center; margin: 0; color: inherit; font-size: inherit; line-height: 1; } +.credit-balance .credit-icon { width: 16px; height: 16px; flex: 0 0 auto; } .welcome-mark { margin-bottom: 20px; } .welcome h1, .auth-panel h1 { margin: 0; font-size: clamp(30px, 4vw, 42px); font-weight: 600; letter-spacing: -.035em; line-height: 1.1; } .welcome-copy { max-width: 520px; margin: var(--space-3) 0 28px; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.65; } @@ -730,7 +731,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .status-已过期, .status-已兑换 { color: var(--color-ink-secondary); } .empty-cell { color: var(--color-ink-secondary); text-align: center !important; } -.new-chat:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active{ transform: scale(.96); } +.sidebar-nav-link[data-nav="new"]:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .sidebar-account-trigger:not(:disabled):active, .credit-balance:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active{ transform: scale(.96); } @keyframes app-loading-orbit { to { transform: rotate(360deg); } } @keyframes inline-spin { to { transform: rotate(360deg); } } @@ -741,8 +742,8 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 @keyframes rectification-sheet-enter { from { opacity: 0; transform: translateY(18%); } } @media (hover: hover) { - .session-list button:not(:disabled):hover, .profile-trigger:not(:disabled):hover { background: var(--color-light-hover); color: var(--color-ink); } - .credit-button:not(:disabled):hover, .dialog-close:not(:disabled):hover { background: color-mix(in srgb, var(--color-border) 54%, transparent); } + .session-list button:not(:disabled):hover, .sidebar-account-trigger:not(:disabled):hover { background: var(--color-light-hover); color: var(--color-ink); } + .credit-balance:not(:disabled):hover, .dialog-close:not(:disabled):hover { background: color-mix(in srgb, var(--color-border) 54%, transparent); } .account-menu-item:hover { background: var(--color-canvas-muted); } } @@ -828,7 +829,14 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .app-loading-error .app-loading-content { padding: 28px; border: 1px solid var(--color-border); background: var(--color-canvas); border-color: var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-elevated); } .group\/sidebar-provider[data-viewport] { height: 100vh; min-height: 0; overflow: hidden; } -.chat-app { width: 100%; height: 100%; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); } +.chat-app { width: 100%; height: 100%; min-height: 0; overflow: clip; display: flex; flex-direction: column; background: var(--color-canvas); } +.shell-frame { display: flex; flex: 1 1 auto; flex-direction: row; min-width: 0; min-height: 0; } +.shell-frame[data-shell-resize="pending"] > :first-child { flex: 0 0 var(--shell-sidebar-width, 288px); width: var(--shell-sidebar-width, 288px); min-width: 0; } +.shell-inset, .shell-page { min-width: 0; min-height: 0; } +.shell-page { height: 100%; } +.shell-page-presence { position: relative; height: 100%; min-width: 0; min-height: 0; } +.shell-resize-handle { flex: 0 0 6px; } +.sidebar-nav-current { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-md); background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); pointer-events: none; } .sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); } [data-sidebar="header"] { flex: 0 0 auto; padding-bottom: var(--space-3); } [data-sidebar="content"] { min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: var(--space-3); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; } @@ -880,16 +888,16 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .brand-row strong { font-weight: 400; } .brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); } .auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--ring-hairline); } -.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; } -.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; } -.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } -.report-nav-button[data-active="true"] { +.sidebar-nav-link[data-nav="new"] { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; } +.sidebar-nav-link { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; } +.sidebar-nav-link:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.sidebar-nav-link[data-active="true"] { position: relative; - background: var(--sidebar-accent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); + background: transparent; color: var(--sidebar-accent-foreground); } -.report-nav-button[data-active="true"]::before { +.sidebar-nav-link[data-active="true"] > :not(.sidebar-nav-current) { position: relative; z-index: 1; } +.sidebar-nav-link[data-active="true"]::before { position: absolute; content: ""; top: var(--space-3); @@ -902,9 +910,9 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 /* 新建对话 is the sidebar's single high-signal action. DESIGN.md keeps the action color scarce, so it stays tinted text on a transparent row rather than a filled block — no page-scale color surface. */ -.new-chat:hover { background: var(--sidebar-accent); color: var(--color-action-hover); } -.new-chat svg, -.report-nav-button svg { +.sidebar-nav-link[data-nav="new"]:hover { background: var(--sidebar-accent); color: var(--color-action-hover); } +.sidebar-nav-link[data-nav="new"] svg, +.sidebar-nav-link svg { width: 18px; height: 18px; flex: 0 0 auto; @@ -939,16 +947,18 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .sidebar-empty { margin: 0; padding: 0 0 var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.35; } /* Visual chip stays 32px; wrap gap is 8px, so a 44px overlay would overlap. */ .session-list { min-height: 0; display: flex; flex-direction: column; gap: var(--space-1); } -.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: stretch; overflow: hidden; border-radius: var(--radius-lg); color: var(--sidebar-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; } -.session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } -.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); } +.sidebar-session { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: stretch; overflow: hidden; border-radius: var(--radius-lg); color: var(--sidebar-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; } +.sidebar-session[data-sortable="true"] { grid-template-columns: 44px minmax(0, 1fr) 44px; } +.session-drag-handle { width: 44px; min-height: 44px; padding: 0; color: inherit; } +.sidebar-session:hover, .sidebar-session:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.sidebar-session:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); } .session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; text-decoration: none; transition: color 120ms ease-out, transform 120ms ease-out; } .session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; } .session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); } /* Read-only rows (the secondary pages) render no menu trigger, so the 44px column the interactive row reserves for one would be dead space at the end of every title. Everything else about the row is the same markup. */ -.session-row[data-readonly="true"] { grid-template-columns: minmax(0, 1fr); } +.sidebar-session[data-readonly="true"] { grid-template-columns: minmax(0, 1fr); } .session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; } .session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; } /* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */ @@ -965,25 +975,25 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .session-list-sentinel { height: 1px; } .session-menu-trigger { width: 44px; min-height: 44px; align-self: stretch; display: grid; place-items: center; justify-self: stretch; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: color 120ms ease-out, opacity 120ms ease-out; } .session-menu-trigger > svg { width: 18px; height: 18px; } -.session-row:hover .session-menu-trigger, .session-row:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; } +.sidebar-session:hover .session-menu-trigger, .sidebar-session:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; } .session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: transparent; color: inherit; } .session-actions-positioner { z-index: 30; max-width: calc(100vw - var(--space-6)); max-height: calc(100dvh - var(--space-6)); outline: 0; } -.session-actions { min-width: 176px; max-height: var(--available-height); overflow-y: auto; display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } -.session-actions[data-starting-style], .session-actions[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)) scale(.98); } +.sidebar-session-menu { min-width: 176px; max-height: var(--available-height); overflow-y: auto; display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } +.sidebar-session-menu[data-starting-style], .sidebar-session-menu[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)) scale(.98); } .session-action-item { width: 100%; min-height: 44px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); outline: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; user-select: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; font-size: var(--type-body-sm); } .session-action-item > svg { width: 16px; height: 16px; color: var(--color-ink-tertiary); transition: color 120ms ease-out; } .session-action-item:hover, .session-action-item[data-highlighted] { background: var(--color-canvas-muted); color: var(--color-ink); } .session-action-item:hover > svg, .session-action-item[data-highlighted] > svg { color: var(--color-ink); } .session-actions-separator { height: 1px; margin: var(--space-1) var(--space-3); background: var(--color-border); } -.session-actions .session-action-danger, .session-actions .session-action-danger > svg { color: var(--color-danger); } -.session-actions .session-action-danger[data-highlighted] { background: var(--color-danger-muted); } +.sidebar-session-menu .session-action-danger, .sidebar-session-menu .session-action-danger > svg { color: var(--color-danger); } +.sidebar-session-menu .session-action-danger[data-highlighted] { background: var(--color-danger-muted); } .sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); } -.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); } +.sidebar-account-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr); align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); } .user-avatar { display: inline-grid; flex: 0 0 auto; overflow: hidden; place-items: center; border-radius: 50%; background: var(--color-action-soft); } .user-avatar > svg { width: 100%; height: 100%; display: block; } .profile-avatar { width: 32px; height: 32px; } .profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action-strong); color: var(--color-on-dark); font-weight: 500; } -.profile-trigger b { display: block; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; } +.sidebar-account-trigger b { display: block; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; } [data-sidebar="trigger"] { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-foreground); cursor: pointer; } [data-sidebar="trigger"] svg { width: 18px; height: 18px; } [data-sidebar="trigger"]:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } @@ -992,9 +1002,6 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 [data-sidebar="rail"]:focus-visible { outline: 3px solid color-mix(in srgb, var(--sidebar-ring) 56%, transparent); outline-offset: -2px; } @media (min-width: 768px) { - [data-viewport="desktop"][data-state="expanded"] .chat-app { grid-template-columns: var(--sidebar-width-desktop) minmax(0, 1fr); } - [data-viewport="tablet"][data-state="expanded"] .chat-app { grid-template-columns: var(--sidebar-width-tablet) minmax(0, 1fr); } - [data-viewport="desktop"][data-state="collapsed"] .chat-app, [data-viewport="tablet"][data-state="collapsed"] .chat-app { grid-template-columns: var(--sidebar-width-icon) minmax(0, 1fr); } [data-state="collapsed"] .sidebar { padding-inline: var(--space-2); } [data-state="collapsed"] .brand-row { justify-content: center; gap: 0; padding: 0; } [data-state="collapsed"] [data-sidebar="menu"] { justify-items: center; } @@ -1002,10 +1009,10 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 /* Centering belongs to the icon rail, which only exists at this breakpoint. data-state tracks the desktop open state, so it must not decide layout on the mobile drawer — that drawer is always expanded. */ - [data-state="collapsed"] .new-chat, - [data-state="collapsed"] .report-nav-button { justify-content: center; } - [data-state="collapsed"] .new-chat { margin-block: var(--space-2); } - [data-state="collapsed"] .profile-trigger { width: 44px; height: 44px; min-height: 44px; margin-inline: auto; padding: 6px; display: grid; grid-template-columns: 32px; place-items: center; } + [data-state="collapsed"] .sidebar-nav-link[data-nav="new"], + [data-state="collapsed"] .sidebar-nav-link { justify-content: center; } + [data-state="collapsed"] .sidebar-nav-link[data-nav="new"] { margin-block: var(--space-2); } + [data-state="collapsed"] .sidebar-account-trigger { width: 44px; height: 44px; min-height: 44px; margin-inline: auto; padding: 6px; display: grid; grid-template-columns: 32px; place-items: center; } [data-state="collapsed"] .sidebar-footer { padding-top: var(--space-3); } } @@ -1124,17 +1131,29 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 right: 4px; } .chart-page-tab, -[data-sidebar="menu-button"].report-nav-button { position: relative; } +[data-sidebar="menu-button"].sidebar-nav-link { position: relative; } .onboarding-hint { width: min(18rem, calc(100vw - 2rem)); max-width: 100%; overflow-wrap: anywhere; } .onboarding-hint p { margin: 0; } -/* Header chips share one size. `.chat-header-actions button` is 32px and - more specific than `.credit-button`, which is why the credit pill looked - shorter than 「当前盘面」(BUG-921). */ -.chat-header-actions .credit-button, +/* The checklist trigger stays 32px. The credit control is a normal outline + button (44px) so the number is readable; 「点」 lives only in its name. */ +.chat-header-actions button.credit-balance { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + height: 44px; + min-height: 44px; + padding: 0 var(--space-3); + gap: var(--space-2); + border-radius: var(--radius-md); + font-size: var(--type-body-sm); + font-weight: 500; + font-variant-numeric: tabular-nums; +} .chat-header-rectification .rectification-board-peek { position: relative; display: inline-flex; @@ -1154,13 +1173,13 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; } -.chat-header-actions .credit-button::before, +.chat-header-actions button.credit-balance::before, .chat-header-rectification .rectification-board-peek::before { position: absolute; inset: -2px; content: ""; } -.credit-button { +.credit-balance { display: inline-flex; align-items: center; justify-content: center; @@ -2011,8 +2030,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .model-selector-trigger { max-width: 100%; grid-column: 1; grid-row: 1; } } -.profile-trigger .chevron { transition: transform 120ms var(--ease-out); } -.profile-trigger .chevron.is-open { transform: rotate(-90deg); } + :has(> .account-menu-popup) { z-index: 30; max-width: calc(100vw - var(--space-6)); max-height: calc(100dvh - var(--space-6)); outline: 0; } .account-menu-popup { width: min(280px, calc(100vw - var(--space-6))); max-height: var(--available-height); overflow-y: auto; padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-xl); background: var(--color-canvas); color: var(--sidebar-foreground); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); } @@ -2056,7 +2074,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .account-deletion-input { display: grid; gap: var(--space-1); } .account-deletion-input input { min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font: inherit; } /* 账号注销中: covers the whole app for an account with a pending deletion. */ -.account-deletion-gate { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: var(--space-6); background: var(--color-canvas-soft); } +[data-slot="alert-dialog-content"].account-deletion-gate { position: fixed; inset: 0; top: 0; left: 0; z-index: 90; display: grid; place-items: center; width: 100%; max-width: none; height: 100%; transform: none; border-radius: 0; padding: var(--space-6); background: var(--color-canvas-soft); } .account-deletion-gate section { width: min(440px, 100%); display: grid; gap: var(--space-4); padding: var(--space-8) var(--space-6); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); text-align: center; } .account-deletion-gate h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 500; } .account-deletion-gate p { margin: 0; color: var(--color-ink-secondary); line-height: 1.6; } @@ -2285,7 +2303,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); } @media (hover: hover) { - .new-chat:not(:disabled):hover { background: var(--sidebar-accent); color: var(--color-action-hover); } + .sidebar-nav-link[data-nav="new"]:not(:disabled):hover { background: var(--sidebar-accent); color: var(--color-action-hover); } .composer button:not(:disabled):hover { background: var(--color-action-hover); } .button-primary:not(:disabled):hover, .composer .composer-row-end > button:not(:disabled):hover { background: var(--color-action-fill-hover); } .model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); } @@ -2306,14 +2324,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } @media (max-width: 767px) { - .chat-app { grid-template-columns: 1fr; } - /* The glass surface assumes a light backdrop. On mobile the drawer floats - above the scrim instead, which darkens and flattens it, so the drawer - takes the solid token — same treatment as prefers-reduced-transparency. */ - [data-sidebar="sidebar"] { background: var(--sidebar-solid); backdrop-filter: none; position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--sidebar-width-mobile); height: 100vh; padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); border-right: 1px solid var(--sidebar-border); } - [data-sidebar="sidebar"][data-mobile-open="false"] { visibility: hidden; transform: translateX(-100%); } - [data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); } - .sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; } + /* The glass surface assumes a light backdrop. On a phone the sheet covers + the page, so the drawer takes the solid token. */ + [data-sidebar="sidebar"] { background: var(--sidebar-solid); backdrop-filter: none; width: 100%; height: 100%; } [data-sidebar="rail"] { display: none; } .chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; } .chat-panel:has(.chart-birth-line) { grid-template-rows: auto minmax(0, 1fr) auto; } diff --git a/frontend/src/components/account-deletion-gate.tsx b/frontend/src/components/account-deletion-gate.tsx index 18d845f9..badc7280 100644 --- a/frontend/src/components/account-deletion-gate.tsx +++ b/frontend/src/components/account-deletion-gate.tsx @@ -2,6 +2,13 @@ import { useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; import { formatAccountDeletionDate, parseAccountDeletionStatus, @@ -64,17 +71,17 @@ export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean } } return ( -
-
-

账号注销中

-

+ { /* Pending deletion stays until 撤销注销 or 退出登录. */ }}> + + 账号注销中 + 这个账号将在 {formatAccountDeletionDate(status.scheduledFor)} 永久删除。在那之前,你可以撤销注销,恢复全部内容。 -

+
- - + +
-
-
+ + ); } diff --git a/frontend/src/components/account-deletion-section.tsx b/frontend/src/components/account-deletion-section.tsx index d48ee47d..82847ffa 100644 --- a/frontend/src/components/account-deletion-section.tsx +++ b/frontend/src/components/account-deletion-section.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { ACCOUNT_DELETION_CONFIRM_WORD, ACCOUNT_DELETION_DELETED_ITEMS, @@ -55,9 +57,9 @@ export function AccountDeletionSection() { {ACCOUNT_DELETION_GRACE_DAYS} 天内可以撤销,之后永久删除 {!open ? ( - + ) : (

提交后你会立刻退出登录,账号暂停使用。{ACCOUNT_DELETION_GRACE_DAYS} 天内重新登录可以撤销;{ACCOUNT_DELETION_GRACE_DAYS} 天后以下内容会被永久删除,无法恢复:

@@ -68,22 +70,22 @@ export function AccountDeletionSection() {

有疑问可以先联系我们:{LEGAL_ENTITY.contactEmail}

- - + +
)} diff --git a/frontend/src/components/account-dialog-overlay.tsx b/frontend/src/components/account-dialog-overlay.tsx index ea845ffe..f979289e 100644 --- a/frontend/src/components/account-dialog-overlay.tsx +++ b/frontend/src/components/account-dialog-overlay.tsx @@ -2,7 +2,19 @@ import { Settings, UserRound, WalletCards, X } from "lucide-react"; import { memo, type MutableRefObject, type ReactNode } from "react"; -import { createPortal } from "react-dom"; + +import { + AlertDialog, + AlertDialogContent, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general"; @@ -33,10 +45,7 @@ const settingsItems: ReadonlyArray<{ /** * Panes whose body is a single column of form fields. They get a reading-width cap - * inside the content box; the list panes (星盘资料 / 账户与点数) stay full-bleed. - * The cap is purely inside `.settings-dialog-content`: every pane still renders under - * the same `.settings-modal` box, so the dialog cannot change size between panes - * (BUG-554 / BUG-698). + * inside the content box; the list pane (账户与点数) stays full-bleed. */ const formPanes: ReadonlyArray = ["profile", "general"]; @@ -51,70 +60,76 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({ }>) { if (!open || dialog === null || model === null) return null; - const isSettingsDialog = dialog !== "logout"; - const isFormPane = dialog !== "logout" && formPanes.includes(dialog); + if (dialog === "logout") { + return ( + { if (!next && !model.signingOut) model.close(); }}> + { model.overlayRef.current = node; }} + aria-labelledby="account-dialog-title" + > +
+ {model.title} +
+ {model.renderLogout()} +
+
+ ); + } + + const isFormPane = formPanes.includes(dialog); const renderSettingsContent = () => { if (dialog === "profile") return model.renderProfile(); - if (dialog === "chart-library") return null; + if (dialog === "chart-library") return model.renderChartLibrary(); if (dialog === "billing") return model.renderBilling(); return model.renderGeneral(); }; - const overlay = ( -
-
{ if (!next) model.close(); }}> + { model.overlayRef.current = node; }} aria-labelledby="account-dialog-title" - onMouseDown={(event) => event.stopPropagation()} >
- {isSettingsDialog ? 设置 : null} -

{model.title}

+ 设置 + {model.title}
- +
- {isSettingsDialog ? ( -
- -
- {renderSettingsContent()} -
+ model.navigate(next as AccountSettingsDialog)}> + + {settingsItems.map(({ dialog: itemDialog, label, icon: Icon }) => ( + + + ))} + +
+ {renderSettingsContent()}
- ) : model.renderLogout()} -
-
+ + + ); - // BUG-968: SidebarInset is inert while the dialog is open. Portal to body so - // the dialog is not inside that subtree. SSR keeps the in-tree markup so - // renderToString contracts still see the dialog. - if (typeof document === "undefined") return overlay; - return createPortal(overlay, document.body); }); diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 230da2d2..33c098d3 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -1,7 +1,15 @@ "use client"; import { FeedbackDialog } from "@/components/feedback-dialog"; -import { Menu } from "@base-ui/react/menu"; +import { SidebarCurrentHighlight } from "@/components/shell-motion"; +import { useSortableSessions } from "@/components/sidebar-sortable-loader"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/dropdown-menu-lazy"; import { CalendarDays, ChevronRight, @@ -161,6 +169,7 @@ export function AppSidebar({ const [feedbackOpen, setFeedbackOpen] = useState(false); const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen; const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen; + const sortableSessions = useSortableSessions(); const hasMoreSessions = sessionControls?.hasMore ?? false; const onLoadMoreSessions = sessionControls?.onLoadMore; const favoriteSessions = sessions.filter((session) => session.pinned); @@ -248,6 +257,39 @@ export function AppSidebar({ ); } + function renderSortableGroup(groupSessions: readonly SidebarSession[], indexOffset: number) { + const onReorder = sessionControls?.onReorder; + if (!onReorder || !sortableSessions || !sessionControls || !onSelectSession) { + return groupSessions.map((session, index) => renderSession(session, indexOffset + index)); + } + const Group = sortableSessions.SortableSessionGroup; + return ( + ({ + session, + active: session.id === activeSessionId, + opening: session.id === openingSessionId, + disabled: sessionControls.disabled, + menuOpen: sessionControls.menuSessionId === session.id, + onMenuOpenChange: (open: boolean) => sessionControls.onMenuSessionChange(open ? session.id : null), + onSelect: () => { + onSelectSession(session.id); + closeDrawer(); + }, + onTogglePinned: () => sessionControls.onTogglePinned(session.id), + onRename: () => sessionControls.onRename(session.id), + onShare: () => sessionControls.onShare(session.id), + onDelete: () => sessionControls.onDelete(session.id), + rowRef: indexOffset + groupSessions.findIndex((item) => item.id === session.id) === 0 + ? (element: HTMLButtonElement | null) => { firstSessionRef.current = element; } + : undefined, + })} + /> + ); + } + /* One entry of the account menu: on `/` the dialog opens in place; on another page the entry is a link to `/?account=` (S2). */ function accountEntry( @@ -258,16 +300,16 @@ export function AppSidebar({ pointer: { onPointerEnter?: () => void; onPointerDown?: () => void } = {}, ) { return onOpen ? ( - {content} + {content} ) : ( - } {...pointer} > {content} - + ); } @@ -282,28 +324,34 @@ export function AppSidebar({ {controls ? ( + {pathname === "/" ? : null} ) : ( - + + {pathname === "/" ? : null} )} - {NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => ( - + {NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => { + const active = pathname === href || pathname.startsWith(`${href}/`); + return ( + prefetchSecondaryPage(href)} onPointerDown={() => prefetchSecondaryPage(href)} @@ -311,12 +359,14 @@ export function AppSidebar({ {...(href === "/chart" ? { "data-onboarding-anchor": "sidebar-chart" } : {})} {...(href === "/ephemeris" ? { "data-onboarding-anchor": "ephemeris" } : {})} > + {active ? : null} - ))} + ); + })} @@ -345,18 +395,20 @@ export function AppSidebar({