报告、星历和登录页换成组件库(#109)。
报告语言改成分段页签,切换后不留下上一种语言的正文。导出从右侧或底部滑出,不再显示文件大小和说明句。星历用箭头按钮按方向翻页,卡片边框不被裁掉。登录的输入、按钮和六位验证码换成组件库。星星汇聚不改。 自测:tsc 退出码 0;lint 0 error、122 个警告;相关测试 185 条里 184 通过,另 1 条是既有的 Windows 管理路径问题;英文版 10 条和删除 5 条重跑通过;build 后首页仍是 Static;首页 JS gzip-9 为 642174,相对 639609 多 0.40%。隐私标记测试通过。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
eee8564b7f
commit
1516ad56ec
@@ -1,5 +1,14 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-25 — 报告、星历和登录页换成组件库
|
||||
|
||||
- 报告的中文和 English 是一排分段。切换之后,页面上不留下上一种语言的正文。
|
||||
- 导出从桌面右侧滑出,窄屏从底部滑出。不再显示文件大小,也不再写那句给 AI 的说明。按钮仍是「导出整篇」或「导出 N 项」。
|
||||
- 星历左右是箭头。点前一天或后一天,下面的卡片按这个方向翻,边框不会被切掉。
|
||||
- 登录页的输入框和按钮换成组件库。六位验证码是分开的六格。恢复码仍是一条输入框。
|
||||
- 报告正文、导出的文件、发验证码和校验、扣点,都没有改。那一刻的天空仍是原来的星星汇聚,只换了关闭和分享两枚按钮。
|
||||
- Skill 版本不变(BUG-1437)。
|
||||
|
||||
## 2026-10-25 — 星盘档案可以拖着排,合盘先确认扣点
|
||||
|
||||
- 星盘档案的人物卡可以拖动排序。刷新、换一台登录的设备,顺序还在。本人始终排在第一张,拖不动。
|
||||
|
||||
+18
-1
@@ -15106,7 +15106,7 @@
|
||||
- 根因:`PersonalReportMarkdownView` 与 `ReportFactTables` 是同级元素,key 都是 `shown`,发生重复;React 清不掉旧节点,旧语言正文留在最上面,新语言正文追加在后面(Claude 在 staging 线上 JS 上复现:正文区块 1→2→3 份,开发版报 `Encountered two children with the same key`)。响应里没有核对表时不复现,所以只测组件的用例(`report-english-edition.test.tsx`)没有拦住。引入提交 `b2ad772e`。
|
||||
- 修复:两处 key 改为 `markdown-${shown}` / `facts-${shown}`,仍随语言重新挂载,不再重复。
|
||||
- 验证:新增 `frontend/tests/report-language-switch-20260930.test.tsx`(整页渲染 + 真实引擎虚构盘 golden 的中英正文与核对表;中→英→中、英→中两条):修复前 2 条均失败(正文区块 2 份),修复后通过;前端全量失败清单与基线逐条一致;tsc / lint / build 见 `docs/tasks/PROGRESS-report-language-switch-stale-20260930.md`。浏览器级走查见 `docs/testing/report-english-edition-20260929.md` 第 10、11 步。
|
||||
- 防复发:同一父级下按语言重新挂载的多个子元素,key 必须带各自前缀;语言切换要有整页测试,数据带齐核对表。
|
||||
- 防复发:同一父级下按语言重新挂载的多个子元素,key 必须带各自前缀;语言切换要有整页测试,数据带齐核对表。2026-10-25 组件库 K5 把中英切换换成了分段页签(`role="tab"`、`aria-selected`)。`frontend/tests/report-language-switch-20260930.test.tsx` 仍断言切换后正文只有一份、上一种语言的节点不残留,这条防复发还在。
|
||||
- 相关记录:BUG-1106~1108(跨语言保留章节)
|
||||
- 修复版本:分支 `codex/report-language-switch-stale-20260930`
|
||||
|
||||
@@ -19478,3 +19478,20 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-4-people-20261023`。
|
||||
|
||||
## BUG-1437 | 报告、星历、登录与其余页面换成组件库
|
||||
|
||||
- 状态:enhancement
|
||||
- 首次发现:2026-10-25
|
||||
- 最近更新:2026-10-25
|
||||
- 影响面:个人报告的中英切换、导出、列表删除和跳到文末;星历翻日期;登录页的输入、按钮和验证码;协议勾选;代码块复制;全站崩溃页的两个按钮;那一刻的天空回放上的关闭和分享。报告正文、导出文件、发码和校验、扣点,都不在这次的改动里。
|
||||
- 用户现象:报告语言是一排分段,点中文或 English 后,页面上不留下上一种语言的正文。导出从桌面右侧或手机底部滑出,不再写文件大小,也不再写那句给 AI 的说明。星历左右箭头翻日期时,下面的卡片跟着翻,边框不会被切掉。登录页的六位验证码是分开的六格。
|
||||
- 触发条件:打开一份个人报告后切换语言或点导出;在星历上点前一天或后一天;打开登录页填写验证码。
|
||||
- 根因:不是缺陷。组件库迁移的报告、星历和登录这一张(任务书 K5,决策 D4、D7、D8、D9)。
|
||||
- 修复:中英切换和导出里的语言、范围改用分段页签。导出改用从右侧或底部滑出的面板,去掉说明句和文件大小。星历日期箭头改用按钮和图标,内容按方向翻页,翻页容器不裁边框。登录的输入、按钮和六位验证码改用组件库;恢复码仍是一条输入框。天空回放只换关闭和分享两枚按钮,星星汇聚不动。验证码格子和登录页按需加载,不进首页同步包。
|
||||
- 验证:见 `docs/tasks/PROGRESS-ui-kit-5-pages-20261023.md`。没有浏览器登录态,不把登录或导出报告写成已经在浏览器里做过。
|
||||
- 防复发:`frontend/tests/report-language-switch-20260930.test.tsx` 锁住切换后正文只有一份。导出合同锁住没有文件大小、没有说明句,以及面板从右侧或底部出现。白名单测试锁住名单只减不增。
|
||||
- 相关记录:issue #109。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K5。BUG-1126 的防复发测试仍在。星星汇聚沿用 #99,本张不改 `birth-sky-converge.tsx`。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-5-pages-20261023`。
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# PROGRESS · 组件库 K5 报告、星历、登录与其余页面(2026-10-25)
|
||||
|
||||
工单 #109。分支 `codex/ui-kit-5-pages-20261023`。基线 `origin/staging` `eee8564b7f43b766c5711a94541d5703fd245d99`(#108 档案与合盘已在这个提交里)。只做任务书 **K5 报告、星历、登录与其余页面**。K6 生时校正和 K7 收尾没做。没有重做档案、合盘、星盘页、对话页、侧栏。
|
||||
|
||||
## 结论
|
||||
|
||||
报告的中文和 English 是一排分段。切换之后,页面上不留下上一种语言的正文。导出从桌面右侧滑出,窄屏从底部滑出,不再显示文件大小,也不再写那句给 AI 的说明。星历左右箭头翻日期时,下面的卡片按方向翻,边框不会被切掉。登录页的输入框、按钮和六位验证码换成了组件库。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 报告语言用分段式 `Tabs`,`aria-label="报告语言"`。导出抽屉里的语言是 `aria-label="导出语言"`,范围是 `aria-label="导出范围"`。不用描边的 `ToggleGroup`。切换语言仍按原来的 key 整段重挂,没有加交叉淡入。
|
||||
- 导出用 `Sheet`。宽屏从右侧滑出,宽度最多 520px。767px 以下从底部滑出,最高 85dvh。页脚是「导出整篇」「导出 N 项」或不能点的「先勾选要导出的部分」。没有说明句,没有文件大小。没有英文版时,打开后仍提示一次,句子和原来一样。下载、打印限制、文件名和勾选规则没有改。
|
||||
- 报告列表的更多改成菜单,删除前仍问「确定删除这份报告?」。跳到文末的按钮换成组件库按钮,箭头换成图标。刷新按钮也换成组件库按钮,读屏名仍是「刷新报告列表」。
|
||||
- 星历的前一天、后一天是按钮加 Lucide 箭头,读屏名不变。日期下面的内容按方向翻页,用已有的 Motion。翻页容器不裁切,四周留 1px,卡片描边留得住。减少动态效果时只淡入。没有转圈。
|
||||
- 登录页的输入框和按钮换成组件库。六位验证码用 `InputOTP`,格子是 `size-11`。恢复码仍是一条 `.otp-input`,高度 `h-11`,字号仍是 20px。验证码登录 / 密码登录,以及二步验证里的动态验证码 / 恢复码,是分段页签。注册账号、忘记密码、返回登录是链接样式的按钮。同意勾选换成组件库复选框。发码和校验没有改。
|
||||
- 代码块的复制、全站崩溃页的重试和返回对话,换成组件库按钮。崩溃页的颜色仍写在本文件里。
|
||||
- 那一刻的天空只换了回放上的关闭和分享。星星汇聚留在 `birth-sky-converge.tsx`,没有重写。
|
||||
- 新增零件 `input-otp@1.5.0`(2026-08-18 发布),用固定的 `shadcn@4.21.4` 添加。没有升级其他依赖。
|
||||
- 白名单去掉 12 个文件,没有加长。测试里写死的基线名单没有改。`personal-report-markdown-view.tsx` 和星星汇聚仍留在名单里。
|
||||
- 没有改 `.gitea/**`,没有改生产部署,没有改数据库,没有给 `scripts/jyotish_api_server.py` 加方法,没有改 `page.tsx`。`Home()` 的 useState / useRef 没有增长。
|
||||
|
||||
## 断言改动(原值 / 新值 / 原因)
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 报告语言选中态 | `BUTTON` 且 `aria-pressed="true"` | `role="tab"` 且 `aria-selected="true"` | K5 改用分段页签。正文仍只能有一份 |
|
||||
| 导出语言未选中 | `BUTTON`,`aria-pressed="false"` | `role="tab"`,`aria-selected="false"` | 同上 |
|
||||
| 导出单选 | `role="radio"` 加 `aria-checked` | `role="tab"`。选中看 `aria-selected` | 导出语言和导出范围改用页签 |
|
||||
| 导出容器 | 原生 `dialog`,窄屏底部、桌面居中 | `Sheet`。桌面 `data-side="right"`,窄屏 `data-side="bottom"` | 桌面从右侧滑出,手机从底部滑出 |
|
||||
| 导出按钮文案 | 「导出整篇 · 约 N KB」 | 「导出整篇」,看不到「约 N KB」 | D8 去掉文件大小 |
|
||||
| 导出说明句 | 英文可用时正文含「建议选 English」 | 没有这句 | D8 删掉说明句。没有英文版时的提示仍在 |
|
||||
| 关闭导出 | 原生 dialog 的 Esc 和点遮罩 | 点「关闭导出」关闭。Esc 和点遮罩由 Sheet 交给 `onOpenChange` | 不再使用原生 dialog |
|
||||
| 下载测试的文档 | 把 `document.body` 换成空对象 | 留下测试文档的 body,只把下载锚点换成假的 | Sheet 要挂到 body 上 |
|
||||
| 自选章节的查找 | 页签文字必须正好等于「自选章节」 | 先精确匹配,再按前缀匹配 | 这一格还带着「只带走需要的部分」 |
|
||||
| 删除入口 | 行内 `details` 里的「删除」,取消后焦点回 `SUMMARY` | 先点「报告更多操作」,再点菜单里的「删除」。确认框里的「删除」是按钮 | 换成菜单和确认框 |
|
||||
| 第二次删除失败 | `buttons(h, "删除")[1]` | 文字为「删除」的最后一枚 | 菜单关上后,菜单项不留在文档里 |
|
||||
| 登录同意勾选 | 原生 `INPUT`,`onChange` 带 `checked: true` | `role="checkbox"`,点它把 `true` 交给 `onChange` | 同意换成组件库复选框 |
|
||||
| 崩溃页引用 | `global-error` 不得出现任何 import | 只允许 `import { Button }` | 两个按钮换成组件库。颜色仍写在本文件里 |
|
||||
| 天空回放按钮 | 两枚原生 `button`,类名 `birth-sky-control` / `birth-sky-share` | 两枚 `Button`,类名、读屏名「关闭」、分享仍调用 `share()` | 只换控件,不改星星汇聚 |
|
||||
| 白名单 | 含这 12 个报告、星历、登录和其余页面文件 | 这 12 个移出 | 它们已经改用组件库。名单只许变短 |
|
||||
|
||||
移出的 12 个文件:`global-error.tsx`、`birth-sky-replay.tsx`、`email-otp-login.tsx`、`ephemeris-page.tsx`、`legal-consent-gate.tsx`、`login-consent.tsx`、`markdown-code-block.tsx`、`personal-report-center.tsx`、`report-actions.tsx`、`report-edge-jump.tsx`、`report-export-drawer.tsx`、`report-row-delete.tsx`。
|
||||
|
||||
BUG-1126 的「正文只有一份」和「切换后不含上一种语言的汉字」没有改弱。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | 仓库根 `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0。第一次因测试里的嵌套函数报 TS2684,改成箭头函数后通过 |
|
||||
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。122 个警告,0 error。警告数和上一张记下的一样,没有为这一张新加 |
|
||||
| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 一批合同文件 | 185 条,通过 184,失败 1。失败的是 `admin-operation-reauth-removal` 第 1 条:Windows 上把文件网址的路径拼成了 `G:\G:\...`,这条在改 K5 之前就会这样。同一文件第 3 条(登录页仍有「邮箱验证码登录」「验证并登录」)通过。BUG-1126 的两条在这批里通过 |
|
||||
| 英文版与删除 | 同上,单独重跑 `report-english-edition.test.tsx`、`report-row-delete.test.tsx` | 英文版 10 条全部通过。删除 5 条全部通过 |
|
||||
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 10.8s,TypeScript 115s。79 个静态页。`/` 仍是 ○ Static。`/login` 是动态。`/ephemeris`、`/reports` 是静态。5 条既有 skill-package-registry 警告,没有改 |
|
||||
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `eee8564b`(上一张记下的数):36 个文件,639609 字节。本张:35 个文件,缺失 0,642174 字节,多 2565(+0.40%)。±2% 大约是 626817 到 652401。`input-otp`、`email-otp-login`、`InputOTP`、`report-export-drawer`、`ephemeris-page`、`birth-sky-replay` 命中都是 0 |
|
||||
|
||||
没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。`tests/test_repo_privacy_markers.py` 退出码 0。`page.tsx` 和 `scripts/jyotish_api_server.py` 没有改。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 全站崩溃页原来不允许任何 import。现在只引入按钮组件,颜色仍写在本文件的内联样式里。合同按三栏改过。没有改崩溃时的两句文案。
|
||||
- 没有英文版时的提示,从抽屉组件自己的挂载挪到了抽屉内容里的一个空子节点。原因是面板要等传送门挂上之后才出现在文档里,挂在外层时提示会早于面板。句子、编号和只提示一次都没变。
|
||||
- 测试环境里,传送门要在模块加载时已经看得到 `document`,否则布局效果会被换成空函数,面板和菜单永远挂不出来。合同测试在引入这些组件之前先放一个占位文档,真正渲染前仍换成原来的测试文档。复选框、页签排序和菜单打开需要的指针事件、文档位置比较和观察器,也补在这个测试文档上。没有改组件库零件本身。
|
||||
- `personal-report-markdown-view.tsx` 不在本张文件清单里,没有改。星星汇聚没有改。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 没有登录态。没有在浏览器里登录,没有收到验证码邮件,也没有导出过报告。不写成通过。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md` 的「报告」「星历」「登录」三节。
|
||||
@@ -60,3 +60,30 @@
|
||||
2. 选好两个人,点开始合盘。先出现「会用掉 N 点,余额 M 点。」,按钮是取消和开始。N 是计价表上的价格,不是写死的数字。点开始之后才生成。这两人已经合过的次数,仍写在按钮下面。
|
||||
3. 余额不够时,这句话改成「需要 N 点,余额只有 M 点。」,主按钮变成去补点数。点了去补点数的那一页。
|
||||
4. 第一次打开一份合盘报告,两个人的头像仍会靠拢,再展开报告。系统开了减少动态效果时,只淡入,头像不飞。报告左上角回到婚恋合盘。语言仍是中文和 English。
|
||||
|
||||
## 报告
|
||||
|
||||
没有登录态,没有在浏览器里打开过报告,也没有导出过文件。
|
||||
|
||||
1. 打开一份有英文版的个人报告。语言是一排分段,读屏名是「报告语言」。点 English,正文和目录换成英文,页面上不再留着刚才的中文。再点中文,英文也不残留。
|
||||
2. 点导出。桌面从右侧滑出。把窗口改成大约 390 像素宽,从底部滑出。抽屉里没有「拿去问 AI」那句说明,按钮上也不写文件大小。整篇时按钮是「导出整篇」。点自选章节后,一块都不勾时按钮是「先勾选要导出的部分」,勾了若干块是「导出 N 项」。
|
||||
3. 打开一份没有英文版的旧报告,再点导出。会提示只能导出中文,或提示重新生成才能有中英两版。这句话不常驻在抽屉里。
|
||||
4. 在报告列表里,每一行右上角是更多。点删除会先问「确定删除这份报告?」。取消后焦点回到这个更多按钮。
|
||||
5. 读一篇较长的报告。右下角是「到底部」。快到文末时变成「回到顶部」。
|
||||
|
||||
## 星历
|
||||
|
||||
没有登录态,没有在浏览器里翻过日期。
|
||||
|
||||
1. 打开星历。日期左右是箭头,读屏名是「前一天」和「后一天」。点前一天,下面的卡片往这个方向翻。卡片四周的边框还在,没有被切掉一圈。
|
||||
2. 翻日期时没有转圈,也没有「正在加载」。看的是今天时,日期下面不出现「今天」。看的不是今天时,日期下面有「今天」。
|
||||
3. 系统开了减少动态效果时,卡片只淡入,不左右移动。
|
||||
|
||||
## 登录
|
||||
|
||||
没有登录态,没有发过验证码邮件,也没有在浏览器里登录。
|
||||
|
||||
1. 打开登录页。验证码登录和密码登录是一排分段。邮箱和按钮是普通的输入框和按钮。
|
||||
2. 走到填写六位验证码时,是六格分开的输入。恢复码仍是一条输入框。
|
||||
3. 注册账号、忘记密码、返回登录仍是这几个字。没勾同意就继续,仍会提示先阅读并勾选。
|
||||
4. 打开那一刻的天空。星星仍会汇聚到定格。左上关闭、右上分享仍在。没有重画星星。
|
||||
|
||||
+10
-10
@@ -688,7 +688,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
|
||||
|
||||
### Login consent and the legal pages (2026-09-30)
|
||||
|
||||
- **Login:** under the form, an unticked checkbox 「我已阅读并同意《用户协议》和《隐私政策》」 (`.auth-consent`, caption size, links underlined, open in a new tab). Sending a code or a password sign-in with the box unticked does nothing but mark the box (a `--color-danger` outline, `.auth-consent.is-attention`) and raise the error toast 「请先阅读并勾选同意,再继续。」; ticking clears the mark. The operators' admin login (password-only, `/admin`) has no box. The login page is a three-row grid (`minmax(56px, 1fr) auto minmax(56px, 1fr)`, no vertical padding; the card's min-height is `min(680px, 100dvh - 128px)`): the card sits in the middle row, exactly centred, and the 56px floors keep the footer off the card on short windows, and the caption footer 用户协议 · 隐私政策 (plus the ICP number to beian.miit.gov.cn once configured) takes the bottom row, so it never pushes the card up (BUG-1122). Below 768px the rows are `1fr auto` and the footer sits under the full-height card.
|
||||
- **Login (2026-10-25, K5):** email, password and the action buttons are component-library Input / Button. A six-digit code is InputOTP (slots `size-11`). 验证码登录 / 密码登录, and 动态验证码 / 恢复码 inside two-factor, are segmented tabs. 注册账号, 忘记密码 and 返回登录 are link-styled buttons. Backup codes stay one `.otp-input` field at 20px. Under the form, an unticked checkbox 「我已阅读并同意《用户协议》和《隐私政策》」 (component-library Checkbox, `.auth-consent`, caption size, links underlined, open in a new tab). Sending a code or a password sign-in with the box unticked does nothing but mark the box (a `--color-danger` outline, `.auth-consent.is-attention`) and raise the error toast 「请先阅读并勾选同意,再继续。」; ticking clears the mark. The operators' admin login (password-only, `/admin`) has no box. The login page is a three-row grid (`minmax(56px, 1fr) auto minmax(56px, 1fr)`, no vertical padding; the card's min-height is `min(680px, 100dvh - 128px)`): the card sits in the middle row, exactly centred, and the 56px floors keep the footer off the card on short windows, and the caption footer 用户协议 · 隐私政策 (plus the ICP number to beian.miit.gov.cn once configured) takes the bottom row, so it never pushes the card up (BUG-1122). Below 768px the rows are `1fr auto` and the footer sits under the full-height card.
|
||||
- **Account menu:** a caption row at the very bottom, 「用户协议 · 隐私政策」, below 退出登录.
|
||||
- **/terms and /privacy:** public, static, one 720px column on `--color-canvas-soft`; a top row with 「返回 Jyotisha」 and a link to the other document; while the text is a draft, a warning-tinted note 「草稿 · 版本 …,待律师审定」 above the title; h1 in the display face, h2 per section, bullet lists for enumerations.
|
||||
- **Re-consent:** when a final version replaces the one a signed-in user accepted, one modal 「协议已更新」 with the same checkbox and 「同意并继续」; it cannot be dismissed without agreeing. Off while the documents are a draft.
|
||||
@@ -709,7 +709,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
|
||||
- **Status chip:** a caption pill with a 5px `currentColor` dot and a text label. Ready uses `--color-success`, generating `--color-warning`, failed `--color-danger`; each background mixes the same token at 12%. No spinner. Ready rows use `--report-paper`; all other rows use `--color-canvas`.
|
||||
- **Meta honesty:** the row says only what `GET /api/reports` returns. It carries no section or chart count, so the row does not claim one; inventing 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). If the endpoint ever projects per-section progress, it belongs on the generating row's note line.
|
||||
- **Surface:** `--color-canvas-soft` floor; completed rows are paper, other rows canvas, with a warm hairline and `--radius-lg` on the list as a whole. No drop shadow.
|
||||
- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right `⋯` menu containing only 「删除」. It opens the row-local 「确定删除这份报告?」 with 删除 / 取消, never `window.confirm`. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row.
|
||||
- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right menu (component-library DropdownMenu, trigger class `report-center-menu`, Lucide ellipsis, accessible name 「报告更多操作」) containing only 「删除」. It opens 「确定删除这份报告?」 (AlertDialog) with 删除 / 取消, never `window.confirm`. While the delete is in flight the confirm button reads 「删除中…」. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row.
|
||||
- **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut.
|
||||
- **Actions:** “生成报告” in the generate card is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the card above already has one. 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. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures raise an error toast and leave the drawer open; only success closes it and emits the 「已导出」 toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
|
||||
@@ -718,19 +718,19 @@ Action results and errors never take page layout. They are toasts from `src/lib/
|
||||
### Personal report reader
|
||||
|
||||
- **Buttons on the report surfaces:** the BUG-1078 report-only override (2026-09-28) was replaced the same day by the site-wide solid-button pair (BUG-1079, see Button); report buttons look as before. Outline buttons there (the header 「生成报告」 while submitting) now fade like outline buttons elsewhere instead of the report-only grey. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode).
|
||||
- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** native modal `<dialog>`, bottom sheet below 860px (up to 85dvh) and a centred `min(520px, 92vw)` panel at ≥860px, app colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); when the report has an English edition, a two-way segment 中文 / English (starts on the edition being read, does not switch the page) with one caption 「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」, otherwise no caption: opening the sheet raises one info toast (id `report-export-english`) saying why; two radio cards 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」; only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The file size appears once, on the footer button: 「导出整篇 · 约 195 KB」 / 「导出 6 项 · 约 80 KB」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch.
|
||||
- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** a component-library Sheet. Desktop slides in from the right (`data-side="right"`, width `min(520px, 92vw)`, max 520px). Below 767px it slides up from the bottom (`data-side="bottom"`, up to 85dvh). App colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); segmented tabs for language (`aria-label="导出语言"`, 中文 / English) only when an English edition exists. They start on the edition being read and do not switch the page. There is no AI caption. Without an English edition, opening the sheet raises one info toast (id `report-export-english`) saying why. Then two segmented tabs (`aria-label="导出范围"`) 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」 (the second tab also carries 「只带走需要的部分」); only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The footer button does not show a file size: 「导出整篇」 / 「导出 N 项」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch.
|
||||
- **Export selection:** 整篇 = every block of the chosen edition; 自选 starts with all 正文 chapters ticked. Switching the sheet's language resets the checklist to that edition's chapters (block ids differ per edition). Presets 常用 / 全部分盘 / 仅核对表 and 全选 were retired with the redesign.
|
||||
- **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`. Chinese chart house lines use `SIGN_ZH`(白羊、金牛……,不带「座」). English chart house lines keep the English sign name.
|
||||
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
|
||||
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows a two-segment pill (`.report-language-switch`, 40px segments, selected = `--color-canvas-muted` + ink + 600) before 「导出」. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
|
||||
- **导出框里的英文版提示:** reading Chinese with an English edition available, the drawer header adds one `--color-canvas-muted` band (`.report-export-ai-hint`) with the AI sentence and an outline 「切到 English」; it stacks below 767px. Old reports get one caption line (`.report-export-edition-note`) instead. The drawer is keyed by edition, so switching resets the selection to that edition's blocks; the file name gains `-EN`.
|
||||
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows segmented tabs (`.report-language-switch`, `aria-label="报告语言"`, 中文 / English, the same raised slot as the chart-type strip) before 「导出」. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
|
||||
- **导出框里的英文版提示 (2026-10-25, K5 / D8):** the caption and the 「切到 English」 band are gone. A report without an English edition still raises one info toast (id `report-export-english`) when the sheet opens. Switching the sheet's language still resets the checklist to that edition's blocks; the file name still gains `-EN`.
|
||||
- **Opening a report (2026-09-30, BUG-1106):** one waiting screen, `ReportOpening` — the 46px header 「个人报告」 over a centred spinner and 「正在加载报告…」. The route's `loading.tsx` and the reader's first fetch both render it, so nothing swaps or jumps between them.
|
||||
- **Phones (2026-09-30, BUG-1107):** the action bar is one row, about 53px: an arrow-only back link (screen readers still hear 「返回报告中心」), the 中文 / English pill at 36px segments, and 「导出」 at 40px. In the export sheet below 767px the description line is hidden, the count is one line 「已选 n/N 块 · 约 X KB(全篇约 Y KB)」, the presets scroll sideways in a single row, 关闭 is a × icon, and the sheet may take up to 85dvh so the block list gets most of it.
|
||||
- **Phones (2026-09-30, BUG-1107):** the action bar is one row, about 53px: an arrow-only back link (screen readers still hear 「返回报告中心」), the 中文 / English pill at 36px segments, and 「导出」 at 40px. In the export sheet below 767px the sheet slides up from the bottom and may take up to 85dvh. There is no description line and no file size. 关闭 is a × icon (accessible name 「关闭导出」).
|
||||
- **Switching editions keeps your place (BUG-1108):** the chapter at the top of the reader is remembered; the new edition renders every chapter up to it at once and scrolls back to it.
|
||||
- **判断不了的分盘提示 (2026-10-01, BUG-1139):** for a segment-v1 adoption whose card left D9/D10 undetermined, the longform body carries one plain paragraph directly under each such chart heading and its 数值位置表 heading — no new component, no blockquote, no colour. Under a chart heading it becomes the chart card's caption (`report-chart-grid-rehype.ts::parseCard` accepts exactly one paragraph between heading and fence), so the chart grid never splits. Added before the snapshot hash; legacy reports are untouched.
|
||||
- **到底部 / 回到顶部:** one floating pill (`.report-edge-jump`, fixed bottom-right, same look as the chat's 跳到最新). It reads 「到底部」 until the reader is within 480px of the end, then 「回到顶部」 — never both. Sections below the fold mount lazily, so the click first mounts every section synchronously (`flushSync`) and then scrolls to `scrollHeight`, landing on the real end in one click. It is not a scroll follower and owns no anchor; it is hidden on paper.
|
||||
- **到底部 / 回到顶部:** one floating control (`.report-edge-jump`, a component-library Button `variant="outline"` with class `report-edge-jump__button` and Lucide ArrowDown / ArrowUp, fixed bottom-right, same look as the chat's 跳到最新). It reads 「到底部」 until the reader is within 480px of the end, then 「回到顶部」 — never both. Sections below the fold mount lazily, so the click first mounts every section synchronously (`flushSync`) and then scrolls to `scrollHeight`, landing on the real end in one click. It is not a scroll follower and owns no anchor; it is hidden on paper.
|
||||
- **Long full-data reports (2026-10-08):** when the Markdown is longer than 200,000 characters and has more than one chapter, the reader shows one chapter at a time. The TOC and 「上一章 / 下一章」 (44px, screen only) change the chapter; the article carries `data-chapter-index` so a 中文 / English switch returns to the same chapter. 「到底部」 opens the last chapter. Shorter reports keep the lazy-section reader. There is no full-text search. Print still mounts every chapter. Markdown download stays on the existing 「导出」 sheet.
|
||||
- **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer.
|
||||
- **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3.
|
||||
@@ -1150,7 +1150,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
- **流式:** 折叠块保持收起,不渲染 children,副标写「正在写…」。不得出现 spinner / 骨架 / 「正在加载」。流式结束后再解析报告 markdown。口语层在流式期间仍走 `StreamingMarkdown`。
|
||||
- **个人报告页**走 `personal-report-markdown-view.tsx`,不经过本组件。
|
||||
- **窄屏表格:** 三列表(技法审计)继续卡片化。四列及以上(Yoga、统一参数)在 `.conversation` 里横向滚动,右缘用 canvas 渐隐提示还可滑;`.conversation` 自身 `overflow-x: hidden`,页面不得出现横向滚动条。个人报告表格不走这套规则。
|
||||
- **表单输入 16px:** 全局 `input` / `select` 用 `--type-body-md`(16px)。iOS Safari 对小于 16px 的字段聚焦时会整页放大且不缩回。`.otp-input` 仍是 20px,`.composer textarea` 仍是 16px。
|
||||
- **表单输入 16px:** 全局 `input` / `select` 用 `--type-body-md`(16px)。iOS Safari 对小于 16px 的字段聚焦时会整页放大且不缩回。`.otp-input` 仍是 20px,`.composer textarea` 仍是 16px。登录页的六位验证码是组件库 InputOTP,格子 `size-11`。恢复码仍用 `.otp-input`,并加 `h-11`。
|
||||
|
||||
### Chat body lists
|
||||
|
||||
@@ -1338,7 +1338,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」在 46px 顶栏里,顶栏不放按钮;`.ephemeris-body` 是唯一滚动的东西。
|
||||
|
||||
- **结构:** 日期条;今日五要素;当日行运;未来九十天;底部提问提示卡。顺序固定。页尾不另写定性句。
|
||||
- **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
|
||||
- **日期条:** 居中的日期是主语。左右是组件库 Button(`size="icon"`)加 Lucide 的 ChevronLeft / ChevronRight,字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。日期下面的五要素、行运和未来九十天按方向翻页(已有的 Motion `dateTurn`,位移 24px)。翻页容器 `overflow: visible`,四周留 1px,卡片描边不会被裁掉。系统开了减少动态效果时只淡入,不位移。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
|
||||
- **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。
|
||||
- 切到未缓存的日期时,日期条和布局留下,五要素、行运立刻换成「这一天的五要素还没拿到。」「这一天的行运还没拿到。」事件区写「换座和停滞还在取。」,这时不用「还没接上」。没有 spinner、骨架或「正在加载」。请求失败时,三段都写「这一天的星历没取到,点日期再试一次。」再点这一天的日期,或在失败时点左右箭头,就重试这一天;日历仍可改去别的日期。旧日期的响应,包括进页时刷新的今天,不得盖住正在看的另一天。
|
||||
- **五要素(2026-09-26 可读性改版):** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块。767px 以下收成五行:左列标签固定 5.5rem、不加字距,行距收紧到 `--space-2`。
|
||||
@@ -1367,7 +1367,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
|
||||
- 星盘页 `ok` 后立即预取 `/api/birth-sky?subject=<当前人物>` 并在屏外画好 PNG;**画好之后**顶栏右侧才出现一枚描边按钮「那一刻的天空」(`<Button variant="outline" size="sm">`,顶栏里按 `.chat-header-actions button` 收成 32px + 命中区外扩)。2026-09-29 晚改(BUG-1102,产品「按钮不明显、慢一秒才出现」):星盘 `ok` 即显示按钮,不等 PNG;封面还在画时点击被记下,画好立即开播重放,中间不插转圈(重放的汇聚本身就是等待);样式改为 `.birth-sky-entry`:自绘小星座图标(四颗星细线相连,2026-09-30 替换星芒,BUG-1114)+ `--color-action` 12% 浅底 + accent 字色 600 字重(不用 `--color-action-soft` 实块,BUG-1078)。预取失败时按钮消失,资料不全、切人物时都不显示按钮,不报错。切换人物或改出生资料(指纹变)就按新人物重新预取。家人的盘也能生成。
|
||||
- 点按钮**重放群星汇聚并定格**(2026-09-29,TASK-mobile-chart-and-confirmed-edit-20260929 M4,替换第三轮 P1 的静态海报弹层):与首次建盘过场同一个组件 `BirthSkyConverge`、同一条时间轴,`mode="hold"`——汇聚约 3 秒、文字 600ms 淡入,然后**停在定格帧不动**,不淡出、不自动关闭。每次打开都从头重放。定格帧就是导出的海报(画布调用序列逐条相同,且用生成 PNG 时同一个宋体判定 `cover.serif`)。全屏原生 `<dialog>` portal 到 `document.body`,底色 `night` 页底色,画布取视口内最大 3:4、乘 DPR。
|
||||
- 控件浮在画面上方一行:**左上 ×**(只有图标,读屏名「关闭」)、**右上「分享」**(分享图标 + 字,night 墨色细描边)。播放中也在,都 ≥ 44px,顶部留安全区。点画面(不在控件上)直接跳到定格帧、**不关闭**;按 Esc 或点 × 关闭,焦点回到按钮。`prefers-reduced-motion` 时不汇聚,600ms 淡入到定格帧。
|
||||
- 控件浮在画面上方一行:**左上 ×**(只有图标,读屏名「关闭」)、**右上「分享」**(分享图标 + 字,night 墨色细描边)。两枚都是组件库 Button,类名仍是 `birth-sky-control` / `birth-sky-share`。星星汇聚不改。播放中也在,都 ≥ 44px,顶部留安全区。点画面(不在控件上)直接跳到定格帧、**不关闭**;按 Esc 或点 × 关闭,焦点回到按钮。`prefers-reduced-motion` 时不汇聚,600ms 淡入到定格帧。
|
||||
- 「分享」:浏览器能分享文件时(iOS Safari)调 `navigator.share({ files })`,系统面板里有「存储图像」;否则 Blob + `a.download`。分享的就是屏幕上定格的那张 PNG(按钮出现前已画好,分享从不等待)。用户取消不算错误、不提示;其他失败在右上方小字「没能分享,再试一次。」。
|
||||
- 重放层、画布与导出代码都在懒加载 chunk 里(`next/dynamic` + 动态 `import()`,`components/birth-sky/birth-sky-replay.tsx`),首页 `/` 不带它们。按钮本身是星盘页里的一个普通 `<Button>`(只在数据就绪时渲染)。
|
||||
- 等待:没有等待态。按钮出现时数据与 PNG 已就绪;打开就开始放。
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
[
|
||||
"src/app/error.tsx",
|
||||
"src/app/global-error.tsx",
|
||||
"src/components/ayanamsa-preference-field.tsx",
|
||||
"src/components/birth-place-picker.tsx",
|
||||
"src/components/birth-sky/birth-sky-converge.tsx",
|
||||
"src/components/birth-sky/birth-sky-replay.tsx",
|
||||
"src/components/birth-time-candidate-result.tsx",
|
||||
"src/components/birth-time-choice-question.tsx",
|
||||
"src/components/birth-time-evidence-draft-card.tsx",
|
||||
@@ -13,19 +11,9 @@
|
||||
"src/components/birth-time-legacy-rectification.tsx",
|
||||
"src/components/birth-time-rectification.tsx",
|
||||
"src/components/consultation-run-timeline.tsx",
|
||||
"src/components/email-otp-login.tsx",
|
||||
"src/components/ephemeris/ephemeris-page.tsx",
|
||||
"src/components/home-bootstrap-error.tsx",
|
||||
"src/components/home-onboarding-shell.tsx",
|
||||
"src/components/legal/legal-consent-gate.tsx",
|
||||
"src/components/legal/login-consent.tsx",
|
||||
"src/components/markdown-code-block.tsx",
|
||||
"src/components/personal-report/personal-report-center.tsx",
|
||||
"src/components/personal-report/personal-report-markdown-view.tsx",
|
||||
"src/components/personal-report/report-actions.tsx",
|
||||
"src/components/personal-report/report-edge-jump.tsx",
|
||||
"src/components/personal-report/report-export-drawer.tsx",
|
||||
"src/components/personal-report/report-row-delete.tsx",
|
||||
"src/components/rectification-agentic-chat.tsx",
|
||||
"src/components/rectification-board.tsx",
|
||||
"src/components/rectification-choice-card.tsx",
|
||||
|
||||
Generated
+11
@@ -29,6 +29,7 @@
|
||||
"cn": "0.4.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"input-otp": "^1.5.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"motion": "14.1.0",
|
||||
"next": "16.3.1",
|
||||
@@ -9303,6 +9304,16 @@
|
||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/input-otp/-/input-otp-1.5.0.tgz",
|
||||
"integrity": "sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"cn": "0.4.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"input-otp": "^1.5.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"motion": "14.1.0",
|
||||
"next": "16.3.1",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const canvas = "var(--bp-canvas)";
|
||||
const ink = "var(--bp-ink)";
|
||||
const inkSecondary = "var(--bp-ink-soft)";
|
||||
@@ -98,7 +100,7 @@ export default function GlobalError({
|
||||
marginTop: "24px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
className="global-error-action"
|
||||
onClick={() => reset()}
|
||||
style={{
|
||||
@@ -111,8 +113,8 @@ export default function GlobalError({
|
||||
type="button"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
className="global-error-action"
|
||||
onClick={() => window.location.assign("/")}
|
||||
style={{
|
||||
@@ -125,7 +127,7 @@ export default function GlobalError({
|
||||
type="button"
|
||||
>
|
||||
返回对话
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -4222,46 +4222,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.report-center-delete-confirm p { margin: 0; }
|
||||
.report-center-delete-confirm button, .report-center-menu button { min-height: 44px; }
|
||||
|
||||
/* Native modal: narrow screens keep the content-column sheet; desktop centers in the viewport. */
|
||||
/* Export sheet (K5): the component library slides it from the right, or from the
|
||||
bottom under 768px. Width and edge live here so the panel is not the default
|
||||
narrow utility width. The list scrolls; the header and footer stay. */
|
||||
.report-export-drawer {
|
||||
--report-export-sidebar: 0px;
|
||||
position: fixed;
|
||||
inset: auto 0 0 var(--report-export-sidebar);
|
||||
width: min(760px, calc((100% - var(--report-export-sidebar)) * .92));
|
||||
max-width: none;
|
||||
max-height: 85vh;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-viewport="desktop"][data-state="expanded"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-desktop); }
|
||||
[data-viewport="tablet"][data-state="expanded"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-tablet); }
|
||||
[data-viewport="desktop"][data-state="collapsed"] .report-export-drawer,
|
||||
[data-viewport="tablet"][data-state="collapsed"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-icon); }
|
||||
.report-export-drawer::backdrop { background: var(--color-scrim); }
|
||||
.report-export-panel { display: flex; flex-direction: column; max-height: 85vh; }
|
||||
@supports (height: 1dvh) {
|
||||
.report-export-drawer, .report-export-panel { max-height: 85dvh; }
|
||||
.report-export-drawer[data-side="right"] {
|
||||
width: min(520px, 92vw);
|
||||
max-width: 520px;
|
||||
}
|
||||
/* After the narrow-screen dvh upgrade so it cannot cap desktop at 85dvh. */
|
||||
@media (min-width: 860px) {
|
||||
.report-export-drawer {
|
||||
inset: 0;
|
||||
width: min(520px, 92vw);
|
||||
max-height: 80vh;
|
||||
margin: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.report-export-panel { max-height: calc(80vh - 2px); }
|
||||
.report-export-drawer[data-side="bottom"] {
|
||||
max-height: 85dvh;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
/* 导出报告 sheet (redesigned 2026-09-30): title row; the edition as a two-way
|
||||
segment with one caption; two radio cards, 整篇报告 / 自选章节; the checklist
|
||||
only in 自选; the size on the button. No bordered block cards, no presets. */
|
||||
.report-export-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) var(--space-3) var(--space-1) var(--space-5); flex-shrink: 0; }
|
||||
.report-export-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 600; }
|
||||
.report-export-body { display: grid; gap: var(--space-4); min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-5) var(--space-4); }
|
||||
@@ -4270,7 +4245,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.report-export-segment button { border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; }
|
||||
.report-export-segment button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-action); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent), inset 0 0 0 1px var(--color-action-strong); }
|
||||
.report-export-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
|
||||
.report-export-modes { display: grid; gap: var(--space-2); }
|
||||
.report-export-modes { display: grid; grid-template-columns: 1fr 1fr; width: 100%; height: auto; }
|
||||
.report-export-modes button { height: auto; white-space: normal; }
|
||||
.report-export-modes strong { display: block; font-size: var(--type-body-md); font-weight: 600; }
|
||||
.report-export-modes small { display: block; margin-top: 2px; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||
.report-export-mode { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font: inherit; text-align: left; cursor: pointer; }
|
||||
.report-export-mode[aria-checked="true"] { border-color: var(--color-action-strong); background: transparent; }
|
||||
.report-export-mode strong { display: block; font-size: var(--type-body-md); font-weight: 600; }
|
||||
@@ -6577,6 +6555,18 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
font-size: var(--type-title-md);
|
||||
line-height: 1;
|
||||
}
|
||||
/* The turn sits under the date bar. Padding keeps the 1px card border outside
|
||||
the grid cell so a sliding page does not clip it. No overflow clip. */
|
||||
.ephemeris-turn {
|
||||
display: grid;
|
||||
overflow: visible;
|
||||
margin: -1px;
|
||||
padding: 1px;
|
||||
}
|
||||
.ephemeris-turn-page {
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Only rendered off today, so it never needs a disabled face. */
|
||||
.ephemeris-today {
|
||||
min-height: 44px;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Share, X } from "lucide-react";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { BirthSkyConverge } from "./birth-sky-converge";
|
||||
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
@@ -44,13 +46,13 @@ export function BirthSkyReplay({ cover, onClose }: { cover: BirthSkyCover; onClo
|
||||
onDone={onClose}
|
||||
controls={(
|
||||
<div className="birth-sky-controls">
|
||||
<button className="birth-sky-control" type="button" onClick={onClose} aria-label="关闭">
|
||||
<Button className="birth-sky-control" type="button" onClick={onClose} aria-label="关闭">
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
<button className="birth-sky-control birth-sky-share" type="button" onClick={() => void share()}>
|
||||
</Button>
|
||||
<Button className="birth-sky-control birth-sky-share" type="button" onClick={() => void share()}>
|
||||
<Share aria-hidden="true" />
|
||||
<span>{BIRTH_SKY_SHARE_LABEL}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,11 @@ import Image from "next/image";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
import { LOGIN_CONSENT_HINT, LoginConsentField, LoginLegalFooter } from "@/components/legal/login-consent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { recordLegalConsent } from "@/lib/legal-consent-client";
|
||||
import { dismissNotice, notifyError, notifyInfo, notifySuccess, NOTICE_DURATION_MS } from "@/lib/notify";
|
||||
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
|
||||
@@ -29,6 +34,28 @@ export function offersFirstPasswordStep(testMode: boolean, mode: AuthMode): bool
|
||||
return !(testMode && mode === "otp");
|
||||
}
|
||||
|
||||
/** Six boxes for an email or authenticator code. Backup codes stay a normal field. */
|
||||
function EmailCodeField({ id, value, onValue }: { id: string; value: string; onValue: (value: string) => void }) {
|
||||
return (
|
||||
<InputOTP
|
||||
id={id}
|
||||
maxLength={6}
|
||||
value={value}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoFocus
|
||||
onChange={(next) => onValue(next.replace(/\D/g, "").slice(0, 6))}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTPSlot key={index} index={index} className="size-11 text-base" />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
);
|
||||
}
|
||||
|
||||
function clearAuthNotices() {
|
||||
dismissNotice(AUTH_NOTICE);
|
||||
dismissNotice(AUTH_ERROR);
|
||||
@@ -360,38 +387,33 @@ export function EmailOtpLogin({
|
||||
|
||||
{showLoginNavigation && (
|
||||
<nav className="auth-mode-nav" aria-label="登录方式">
|
||||
<div className="auth-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "otp"}
|
||||
onClick={() => chooseMode("otp")}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "password"}
|
||||
onClick={() => chooseMode("password")}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={mode === "password" ? "password" : "otp"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "otp" || value === "password") chooseMode(value);
|
||||
}}
|
||||
>
|
||||
<TabsList variant="default" className="auth-mode-tabs">
|
||||
<TabsTrigger value="otp">验证码登录</TabsTrigger>
|
||||
<TabsTrigger value="password">密码登录</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="auth-links">
|
||||
<button type="button" onClick={() => chooseMode("register")}>
|
||||
<Button type="button" variant="link" onClick={() => chooseMode("register")}>
|
||||
注册账号
|
||||
</button>
|
||||
<button type="button" onClick={() => chooseMode("forgot")}>
|
||||
</Button>
|
||||
<Button type="button" variant="link" onClick={() => chooseMode("forgot")}>
|
||||
忘记密码
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{showBackToLogin && (
|
||||
<nav className="auth-links" aria-label="返回登录">
|
||||
<button type="button" onClick={() => chooseMode("otp")}>
|
||||
<Button type="button" variant="link" onClick={() => chooseMode("otp")}>
|
||||
返回登录
|
||||
</button>
|
||||
</Button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
@@ -401,9 +423,10 @@ export function EmailOtpLogin({
|
||||
className="stack-form auth-step"
|
||||
onSubmit={signInWithPassword}
|
||||
>
|
||||
<label htmlFor="password-email">邮箱</label>
|
||||
<input
|
||||
<Label htmlFor="password-email">邮箱</Label>
|
||||
<Input
|
||||
id="password-email"
|
||||
className="h-11"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
@@ -416,9 +439,10 @@ export function EmailOtpLogin({
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<label htmlFor="current-password">密码</label>
|
||||
<input
|
||||
<Label htmlFor="current-password">密码</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
className="h-11"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
@@ -430,19 +454,20 @@ export function EmailOtpLogin({
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
className="button-primary"
|
||||
type="submit"
|
||||
disabled={!email.trim() || !password || busy}
|
||||
>
|
||||
{busy ? "登录中" : "密码登录"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
) : step === "email" ? (
|
||||
<form key="email" className="stack-form auth-step" onSubmit={sendOtp}>
|
||||
<label htmlFor="login-email">邮箱</label>
|
||||
<input
|
||||
<Label htmlFor="login-email">邮箱</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
className="h-11"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
@@ -455,13 +480,13 @@ export function EmailOtpLogin({
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
className="button-primary"
|
||||
type="submit"
|
||||
disabled={!email.trim() || busy}
|
||||
>
|
||||
{busy ? "发送中" : "发送验证码"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
) : step === "two-factor" ? (
|
||||
<form
|
||||
@@ -469,60 +494,57 @@ export function EmailOtpLogin({
|
||||
className="stack-form auth-step"
|
||||
onSubmit={verifyTwoFactor}
|
||||
>
|
||||
<div className="auth-mode-tabs" role="group" aria-label="二步验证方式">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mfaMethod === "totp"}
|
||||
onClick={() => {
|
||||
setMfaMethod("totp");
|
||||
setToken("");
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
>
|
||||
动态验证码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mfaMethod === "backup-code"}
|
||||
onClick={() => {
|
||||
setMfaMethod("backup-code");
|
||||
setToken("");
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
>
|
||||
恢复码
|
||||
</button>
|
||||
</div>
|
||||
<label htmlFor="login-two-factor-code">
|
||||
{mfaMethod === "totp" ? "验证器动态码" : "恢复码"}
|
||||
</label>
|
||||
<input
|
||||
id="login-two-factor-code"
|
||||
className="otp-input"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode={mfaMethod === "totp" ? "numeric" : "text"}
|
||||
pattern={mfaMethod === "totp" ? "[0-9]{6}" : undefined}
|
||||
minLength={mfaMethod === "totp" ? 6 : 4}
|
||||
maxLength={mfaMethod === "totp" ? 6 : 128}
|
||||
required
|
||||
autoFocus
|
||||
value={token}
|
||||
onChange={(event) => {
|
||||
const value = mfaMethod === "totp"
|
||||
? event.target.value.replace(/\D/g, "").slice(0, 6)
|
||||
: event.target.value.trimStart().slice(0, 128);
|
||||
setToken(value);
|
||||
<Tabs
|
||||
value={mfaMethod}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "totp" && value !== "backup-code") return;
|
||||
setMfaMethod(value);
|
||||
setToken("");
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
>
|
||||
<TabsList variant="default" aria-label="二步验证方式" className="auth-mode-tabs">
|
||||
<TabsTrigger value="totp">动态验证码</TabsTrigger>
|
||||
<TabsTrigger value="backup-code">恢复码</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Label htmlFor="login-two-factor-code">
|
||||
{mfaMethod === "totp" ? "验证器动态码" : "恢复码"}
|
||||
</Label>
|
||||
{mfaMethod === "totp" ? (
|
||||
<EmailCodeField
|
||||
id="login-two-factor-code"
|
||||
value={token}
|
||||
onValue={(next) => {
|
||||
setToken(next);
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="login-two-factor-code"
|
||||
className="otp-input h-11"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="text"
|
||||
minLength={4}
|
||||
maxLength={128}
|
||||
required
|
||||
autoFocus
|
||||
value={token}
|
||||
onChange={(event) => {
|
||||
setToken(event.target.value.trimStart().slice(0, 128));
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="button-primary"
|
||||
type="submit"
|
||||
disabled={!token || busy}
|
||||
>
|
||||
{busy ? "验证中" : "完成二步验证"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
) : step === "otp" && mode === "forgot" ? (
|
||||
<form
|
||||
@@ -530,27 +552,19 @@ export function EmailOtpLogin({
|
||||
className="stack-form auth-step"
|
||||
onSubmit={resetPassword}
|
||||
>
|
||||
<label htmlFor="reset-token">邮箱验证码</label>
|
||||
<input
|
||||
<Label htmlFor="reset-token">邮箱验证码</Label>
|
||||
<EmailCodeField
|
||||
id="reset-token"
|
||||
className="otp-input"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
value={token}
|
||||
onChange={(event) => {
|
||||
setToken(event.target.value.replace(/\D/g, "").slice(0, 6));
|
||||
onValue={(next) => {
|
||||
setToken(next);
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="reset-password-new">新密码</label>
|
||||
<input
|
||||
<Label htmlFor="reset-password-new">新密码</Label>
|
||||
<Input
|
||||
id="reset-password-new"
|
||||
className="h-11"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
@@ -562,9 +576,10 @@ export function EmailOtpLogin({
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="reset-password-confirm">确认新密码</label>
|
||||
<input
|
||||
<Label htmlFor="reset-password-confirm">确认新密码</Label>
|
||||
<Input
|
||||
id="reset-password-confirm"
|
||||
className="h-11"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
@@ -576,56 +591,47 @@ export function EmailOtpLogin({
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
className="button-primary"
|
||||
type="submit"
|
||||
disabled={!token || !password || !confirmation || busy}
|
||||
>
|
||||
{busy ? "重置中" : "重置密码"}
|
||||
</button>
|
||||
</Button>
|
||||
<div className="inline-actions">
|
||||
<button type="button" disabled={busy} onClick={() => void sendOtp()}>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => void sendOtp()}>
|
||||
{busy ? "发送中" : "重新发送验证码"}
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={changeEmail}>
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={changeEmail}>
|
||||
更换邮箱
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : step === "otp" ? (
|
||||
<form key="otp" className="stack-form auth-step" onSubmit={verifyOtp}>
|
||||
<label htmlFor="login-token">邮箱验证码</label>
|
||||
<input
|
||||
<Label htmlFor="login-token">邮箱验证码</Label>
|
||||
<EmailCodeField
|
||||
id="login-token"
|
||||
className="otp-input"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
value={token}
|
||||
onChange={(event) => {
|
||||
setToken(event.target.value.replace(/\D/g, "").slice(0, 6));
|
||||
onValue={(next) => {
|
||||
setToken(next);
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
className="button-primary"
|
||||
type="submit"
|
||||
disabled={!token || busy}
|
||||
>
|
||||
{busy ? "验证中" : "验证并登录"}
|
||||
</button>
|
||||
</Button>
|
||||
<div className="inline-actions">
|
||||
<button type="button" disabled={busy} onClick={() => void sendOtp()}>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => void sendOtp()}>
|
||||
{busy ? "发送中" : "重新发送验证码"}
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={changeEmail}>
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={changeEmail}>
|
||||
更换邮箱
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : step === "set-password" ? (
|
||||
@@ -634,9 +640,10 @@ export function EmailOtpLogin({
|
||||
className="stack-form auth-step"
|
||||
onSubmit={saveFirstPassword}
|
||||
>
|
||||
<label htmlFor="new-password">设置密码</label>
|
||||
<input
|
||||
<Label htmlFor="new-password">设置密码</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
className="h-11"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
@@ -649,9 +656,10 @@ export function EmailOtpLogin({
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="confirm-password">确认密码</label>
|
||||
<input
|
||||
<Label htmlFor="confirm-password">确认密码</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
className="h-11"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
@@ -663,23 +671,23 @@ export function EmailOtpLogin({
|
||||
dismissNotice(AUTH_ERROR);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
className="button-primary"
|
||||
type="submit"
|
||||
disabled={!password || !confirmation || busy}
|
||||
>
|
||||
{busy ? "保存中" : "设置密码并继续"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="stack-form auth-step">
|
||||
<button
|
||||
<Button
|
||||
className="button-primary"
|
||||
type="button"
|
||||
onClick={() => void enterApp()}
|
||||
>
|
||||
进入首页
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
transitHouseLine,
|
||||
} from "@/lib/ephemeris-view";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import { loadDomAnimation, loadMotionReact, peekMotionReact, type MotionReact } from "@/lib/motion/load-react";
|
||||
import { motionPresets } from "@/lib/motion/presets";
|
||||
import { PLANET_COLOR_CLASS, PLANET_GLYPH, planetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
type PageState =
|
||||
@@ -260,11 +262,8 @@ export function EphemerisView(props: {
|
||||
<SecondaryPageShell title={EPHEMERIS_COPY.title}>
|
||||
<div className="ephemeris-body">
|
||||
<section className="ephemeris-hero">
|
||||
{/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles.
|
||||
Three equal side-by-side buttons made 前一天 / 今天 / 后一天 read as
|
||||
three peers and pushed the date itself out of the group entirely.
|
||||
The arrow glyphs are decorative — the direction words stay as the
|
||||
accessible names, so nothing is lost to a screen reader. */}
|
||||
{/* The date is the subject. Lucide arrows are its handles.
|
||||
The direction words stay as the accessible names. */}
|
||||
<div className="ephemeris-date-bar" role="group" aria-label="切换日期">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -274,7 +273,7 @@ export function EphemerisView(props: {
|
||||
aria-label={EPHEMERIS_COPY.previousDay}
|
||||
onClick={props.onPrevious}
|
||||
>
|
||||
<span aria-hidden="true">‹</span>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
{/* The date itself opens a month grid. Stepping a day at a time was the
|
||||
only way to reach another date, so anything more than a few days
|
||||
@@ -322,7 +321,7 @@ export function EphemerisView(props: {
|
||||
aria-label={EPHEMERIS_COPY.nextDay}
|
||||
onClick={props.onNext}
|
||||
>
|
||||
<span aria-hidden="true">›</span>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* On today there is nowhere to go, so the control is absent rather than
|
||||
@@ -335,6 +334,7 @@ export function EphemerisView(props: {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<EphemerisTurn date={props.date}>
|
||||
<section className="ephemeris-section" aria-labelledby="ephemeris-panchanga-heading">
|
||||
<h2 id="ephemeris-panchanga-heading">{EPHEMERIS_COPY.panchangaHeading}</h2>
|
||||
{failed ? (
|
||||
@@ -428,7 +428,77 @@ export function EphemerisView(props: {
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
</aside>
|
||||
</EphemerisTurn>
|
||||
</div>
|
||||
</SecondaryPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function useMotionReact(): MotionReact | null {
|
||||
const [mod, setMod] = useState<MotionReact | null>(() => peekMotionReact());
|
||||
useEffect(() => {
|
||||
if (mod) return;
|
||||
let live = true;
|
||||
void loadMotionReact().then((next) => {
|
||||
if (live) setMod(next);
|
||||
}, () => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mod]);
|
||||
return mod;
|
||||
}
|
||||
|
||||
/** Page turn under the date bar. The date bar itself stays put.
|
||||
* Until Motion has loaded, the sections paint in place. Cards are not clipped. */
|
||||
function EphemerisTurn({ date, children }: { readonly date: string; readonly children: ReactNode }) {
|
||||
const motion = useMotionReact();
|
||||
const [frame, setFrame] = useState(() => ({ date, direction: 1, animated: false }));
|
||||
if (date !== frame.date) {
|
||||
const direction = date > frame.date ? 1 : -1;
|
||||
setFrame({ date, direction, animated: motion !== null });
|
||||
}
|
||||
if (!frame.animated || motion === null) {
|
||||
return <div className="ephemeris-turn">{children}</div>;
|
||||
}
|
||||
return (
|
||||
<EphemerisPresence motion={motion} date={frame.date} direction={frame.direction}>
|
||||
{children}
|
||||
</EphemerisPresence>
|
||||
);
|
||||
}
|
||||
|
||||
function EphemerisPresence({
|
||||
motion,
|
||||
date,
|
||||
direction,
|
||||
children,
|
||||
}: {
|
||||
readonly motion: MotionReact;
|
||||
readonly date: string;
|
||||
readonly direction: number;
|
||||
readonly children: ReactNode;
|
||||
}) {
|
||||
const reduced = motion.useReducedMotion() ?? false;
|
||||
const preset = motionPresets.dateTurn;
|
||||
const distance = reduced ? 0 : 24;
|
||||
const duration = reduced ? preset.reduced.duration : preset.duration;
|
||||
return (
|
||||
<motion.LazyMotion features={loadDomAnimation} strict>
|
||||
<div className="ephemeris-turn">
|
||||
<motion.AnimatePresence initial={false}>
|
||||
<motion.m.div
|
||||
key={date}
|
||||
className="ephemeris-turn-page"
|
||||
initial={{ x: direction * distance, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: direction * -distance, opacity: 0 }}
|
||||
transition={{ duration, ease: [...preset.ease] }}
|
||||
>
|
||||
{children}
|
||||
</motion.m.div>
|
||||
</motion.AnimatePresence>
|
||||
</div>
|
||||
</motion.LazyMotion>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { CURRENT_LEGAL_VERSION, LEGAL_RECONSENT_BLOCKING } from "@/lib/legal-consent";
|
||||
import { recordLegalConsent } from "@/lib/legal-consent-client";
|
||||
import { notifyError } from "@/lib/notify";
|
||||
@@ -21,7 +23,6 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) {
|
||||
const [required, setRequired] = useState(false);
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!LEGAL_RECONSENT_BLOCKING || !signedIn || checkedVersion === CURRENT_LEGAL_VERSION) return;
|
||||
@@ -34,10 +35,6 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) {
|
||||
return () => { cancelled = true; };
|
||||
}, [signedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (required) dialog.current?.showModal();
|
||||
}, [required]);
|
||||
|
||||
if (!required) return null;
|
||||
|
||||
async function agree() {
|
||||
@@ -45,28 +42,26 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) {
|
||||
setBusy(true);
|
||||
const ok = await recordLegalConsent();
|
||||
setBusy(false);
|
||||
if (ok) {
|
||||
dialog.current?.close();
|
||||
setRequired(false);
|
||||
} else {
|
||||
notifyError("没能记录,请稍后再试。");
|
||||
}
|
||||
if (ok) setRequired(false);
|
||||
else notifyError("没能记录,请稍后再试。");
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog ref={dialog} className="legal-consent-dialog" aria-labelledby="legal-consent-title" onCancel={event => event.preventDefault()}>
|
||||
<h2 id="legal-consent-title">协议已更新</h2>
|
||||
<p>我们更新了《用户协议》和《隐私政策》(版本 {CURRENT_LEGAL_VERSION})。请阅读后确认,以便继续使用。</p>
|
||||
<label className="auth-consent">
|
||||
<input type="checkbox" checked={accepted} onChange={event => setAccepted(event.target.checked)} />
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<Link href="/terms" target="_blank" rel="noopener">《用户协议》</Link>
|
||||
和
|
||||
<Link href="/privacy" target="_blank" rel="noopener">《隐私政策》</Link>
|
||||
</span>
|
||||
</label>
|
||||
<Button disabled={!accepted || busy} onClick={() => void agree()}>{busy ? "正在记录…" : "同意并继续"}</Button>
|
||||
</dialog>
|
||||
<Dialog open={required} onOpenChange={() => undefined} disablePointerDismissal>
|
||||
<DialogContent showCloseButton={false} className="legal-consent-dialog" aria-labelledby="legal-consent-title">
|
||||
<h2 id="legal-consent-title">协议已更新</h2>
|
||||
<p>我们更新了《用户协议》和《隐私政策》(版本 {CURRENT_LEGAL_VERSION})。请阅读后确认,以便继续使用。</p>
|
||||
<label className="auth-consent">
|
||||
<Checkbox checked={accepted} onCheckedChange={(value) => setAccepted(value === true)} />
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<Link href="/terms" target="_blank" rel="noopener">《用户协议》</Link>
|
||||
和
|
||||
<Link href="/privacy" target="_blank" rel="noopener">《隐私政策》</Link>
|
||||
</span>
|
||||
</label>
|
||||
<Button type="button" disabled={!accepted || busy} onClick={() => void agree()}>{busy ? "正在记录…" : "同意并继续"}</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { LEGAL_ENTITY } from "@/lib/legal-entity";
|
||||
|
||||
export const LOGIN_CONSENT_HINT = "请先阅读并勾选同意,再继续。";
|
||||
@@ -16,7 +19,7 @@ export function LoginConsentField({ checked, hint, onChange }: {
|
||||
return (
|
||||
<div className={`auth-consent${hint ? " is-attention" : ""}`}>
|
||||
<label>
|
||||
<input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
||||
<Checkbox checked={checked} onCheckedChange={(value) => onChange(value === true)} />
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<Link href="/terms" target="_blank" rel="noopener">《用户协议》</Link>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** The fenced language, from the `language-xxx` class react-markdown puts on <code>. */
|
||||
export function fencedLanguage(node: ReactNode): string {
|
||||
@@ -42,15 +43,16 @@ export function MarkdownCodeBlock({ children }: { readonly children?: ReactNode
|
||||
<div className="markdown-code">
|
||||
<div className="markdown-code-bar">
|
||||
<span className="markdown-code-language">{language || "代码"}</span>
|
||||
<button
|
||||
<Button
|
||||
aria-label={copied ? "已复制代码" : "复制代码"}
|
||||
className="markdown-code-copy"
|
||||
onClick={() => void copy()}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
||||
<span>{copied ? "已复制" : "复制"}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<pre>{children}</pre>
|
||||
</div>
|
||||
|
||||
@@ -225,9 +225,9 @@ export function PersonalReportCenter() {
|
||||
<section className="report-center-section">
|
||||
<div className="report-center-section-heading">
|
||||
<h2>过往的报告</h2>
|
||||
<button type="button" onClick={() => void load(true)} aria-label="刷新报告列表">
|
||||
<Button type="button" variant="ghost" onClick={() => void load(true)} aria-label="刷新报告列表">
|
||||
{state.phase === "loading" ? <InlineSpinner size={14} /> : <RefreshCw aria-hidden="true" />}刷新
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state.phase === "error" && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ReportFactTable } from "@/lib/report-fact-table-schema";
|
||||
import { ReportExportDrawer } from "./report-export-drawer";
|
||||
import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface ReportActionsProps {
|
||||
markdown: string;
|
||||
@@ -56,18 +57,17 @@ export function ReportActions({
|
||||
<div className="personal-report-action-end">
|
||||
{/* Only reports that carry an English edition show the switch (2026-09-29). */}
|
||||
{canSwitch && (
|
||||
<div className="report-language-switch" role="group" aria-label="报告语言">
|
||||
{(["zh", "en"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={language === option}
|
||||
onClick={() => onLanguageChange(option)}
|
||||
>
|
||||
{option === "zh" ? "中文" : "English"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs
|
||||
value={language}
|
||||
onValueChange={(value) => {
|
||||
if (value === "zh" || value === "en") onLanguageChange(value);
|
||||
}}
|
||||
>
|
||||
<TabsList variant="default" aria-label="报告语言" className="report-language-switch">
|
||||
<TabsTrigger value="zh">中文</TabsTrigger>
|
||||
<TabsTrigger value="en">English</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
{/* Labelled, outlined and full height (2026-09-29): the icon-only ghost
|
||||
button it replaces was the one control people could not find. */}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** Within this many px of the end the button flips to 「回到顶部」. */
|
||||
export const REPORT_EDGE_JUMP_NEAR_END_PX = 480;
|
||||
@@ -46,10 +47,10 @@ export function ReportEdgeJump({ revealAll }: Readonly<{ revealAll: () => void }
|
||||
const toEnd = direction === "end";
|
||||
return (
|
||||
<div ref={ref} className="report-edge-jump personal-report-screen-only">
|
||||
<button type="button" className="report-edge-jump__button" onClick={jump}>
|
||||
<Button type="button" variant="outline" className="report-edge-jump__button" onClick={jump}>
|
||||
{toEnd ? <ArrowDown aria-hidden="true" /> : <ArrowUp aria-hidden="true" />}
|
||||
{toEnd ? "到底部" : "回到顶部"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronRight, Minus, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { ChevronRight, X } from "lucide-react";
|
||||
import { detectPrintRestriction } from "@/lib/client-report-export";
|
||||
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
|
||||
import { notifyError, notifyInfo, notifySuccess } from "@/lib/notify";
|
||||
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, type ReportExportBlock } from "@/lib/report-export-blocks";
|
||||
import { exportFilename, joinExportBlocks, type ReportExportBlock } from "@/lib/report-export-blocks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
|
||||
|
||||
type Group = ReportExportBlock["group"];
|
||||
const GROUP_LABELS: Record<Group, string> = { body: "正文", chart: "分盘", table: "核对表" };
|
||||
const GROUP_UNITS: Record<Group, string> = { body: "章", chart: "张", table: "张" };
|
||||
const GROUPS = Object.keys(GROUP_LABELS) as Group[];
|
||||
const NARROW_EXPORT_SHEET = "(max-width: 767px)";
|
||||
|
||||
function exportSheetSide(): "bottom" | "right" {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "right";
|
||||
return window.matchMedia(NARROW_EXPORT_SHEET).matches ? "bottom" : "right";
|
||||
}
|
||||
|
||||
function subscribeExportSheetSide(listener: () => void) {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
|
||||
const query = window.matchMedia(NARROW_EXPORT_SHEET);
|
||||
query.addEventListener?.("change", listener);
|
||||
return () => query.removeEventListener?.("change", listener);
|
||||
}
|
||||
|
||||
/** Export blocks for each edition the report has; Chinese always, English when it exists. */
|
||||
export type ReportExportEditions = Readonly<{ zh: readonly ReportExportBlock[]; en?: readonly ReportExportBlock[] }>;
|
||||
|
||||
/**
|
||||
* 导出报告 (redesigned 2026-09-30, product: the block-picker sheet was ugly and
|
||||
* hid the list). Two steps: pick the edition, then 「整篇报告」 (one tap, the
|
||||
* common case) or 「自选章节」, which only then opens a plain checklist grouped
|
||||
* into 正文 / 分盘 / 核对表 with one checkbox per group. The file size appears
|
||||
* once, on the export button.
|
||||
* 导出报告. Desktop slides in from the right; a phone slides up from the bottom.
|
||||
* Pick the edition, then 「整篇报告」 or 「自选章节」. No caption and no file size.
|
||||
*/
|
||||
export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEdition = "legacy", reportDate, onClose }: {
|
||||
editions: ReportExportEditions;
|
||||
@@ -32,7 +45,7 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
reportDate?: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const side = useSyncExternalStore(subscribeExportSheetSide, exportSheetSide, () => "right" as const);
|
||||
const hasEnglish = englishEdition === "ready" && Boolean(editions.en?.length);
|
||||
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage === "en" && hasEnglish ? "en" : "zh");
|
||||
const blocks = (language === "en" ? editions.en : editions.zh) ?? editions.zh;
|
||||
@@ -45,29 +58,14 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
() => (mode === "whole" ? blocks : blocks.filter(block => selection.has(block.id))),
|
||||
[blocks, mode, selection],
|
||||
);
|
||||
const size = useMemo(() => formatExportSize(exportByteSize(selected)), [selected]);
|
||||
const counts = useMemo(() => countByGroup(blocks), [blocks]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
const previous = document.activeElement;
|
||||
element?.showModal();
|
||||
// Once per open. The sheet remounts every time it opens, so this mount effect
|
||||
// is the open. Later picks and language switches must not raise it again.
|
||||
// Same id collapses React Strict Mode's double invoke into one toast.
|
||||
if (!hasEnglish) {
|
||||
notifyInfo(
|
||||
englishEdition === "unavailable"
|
||||
? "这份报告的英文版没有生成成功,只能导出中文版。"
|
||||
: "这份报告生成于英文版上线前,重新生成即可获得中英两版。",
|
||||
{ id: "report-export-english" },
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
element?.close();
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true });
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- notify once per open, after showModal
|
||||
}, []);
|
||||
|
||||
function switchLanguage(next: ReportLanguage) {
|
||||
if (next === language) return;
|
||||
@@ -99,7 +97,6 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
const body = blocks.filter(block => block.group === "body");
|
||||
const full = mode === "whole" || (body.length > 0 && body.every(block => selection.has(block.id)));
|
||||
downloadMarkdownReport(exportFilename(reportDate, full, language), joinExportBlocks(selected));
|
||||
dialog.current?.close();
|
||||
onClose();
|
||||
notifySuccess("已导出");
|
||||
} catch (cause) {
|
||||
@@ -110,77 +107,107 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
|
||||
const summary = GROUPS.filter(group => counts[group] > 0).map(group => `${counts[group]} ${GROUP_UNITS[group]}${GROUP_LABELS[group]}`).join(" + ");
|
||||
|
||||
return <dialog ref={dialog} className="report-export-drawer" aria-labelledby="report-export-title"
|
||||
onCancel={event => { event.preventDefault(); onClose(); }}
|
||||
onClick={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div className="report-export-panel">
|
||||
<header className="report-export-header">
|
||||
<h2 id="report-export-title">导出报告</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭导出" title="关闭"><X aria-hidden="true" /></Button>
|
||||
</header>
|
||||
<div className="report-export-body">
|
||||
{hasEnglish ? (
|
||||
<div className="report-export-field">
|
||||
<div className="report-export-segment" role="radiogroup" aria-label="导出语言">
|
||||
{(["zh", "en"] as const).map(option => (
|
||||
<button key={option} type="button" role="radio" aria-checked={language === option} onClick={() => switchLanguage(option)}>
|
||||
{option === "zh" ? "中文" : "English"}
|
||||
</button>
|
||||
))}
|
||||
return (
|
||||
<Sheet open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<SheetContent
|
||||
side={side}
|
||||
showCloseButton={false}
|
||||
className="report-export-drawer gap-0 overflow-hidden p-0"
|
||||
aria-labelledby="report-export-title"
|
||||
>
|
||||
{hasEnglish ? null : <ExportEditionNotice englishEdition={englishEdition} />}
|
||||
<header className="report-export-header">
|
||||
<h2 id="report-export-title">导出报告</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭导出" title="关闭"><X aria-hidden="true" /></Button>
|
||||
</header>
|
||||
<div className="report-export-body">
|
||||
{hasEnglish ? (
|
||||
<Tabs value={language} onValueChange={(value) => { if (value === "zh" || value === "en") switchLanguage(value); }}>
|
||||
<TabsList variant="default" aria-label="导出语言" className="report-export-segment">
|
||||
<TabsTrigger value="zh">中文</TabsTrigger>
|
||||
<TabsTrigger value="en">English</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
<Tabs value={mode} onValueChange={(value) => { if (value === "whole" || value === "pick") setMode(value); }}>
|
||||
<TabsList variant="default" aria-label="导出范围" className="report-export-modes">
|
||||
<TabsTrigger value="whole">
|
||||
<span><strong>整篇报告</strong><small>{summary}</small></span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pick">
|
||||
<span><strong>自选章节</strong><small>只带走需要的部分</small></span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{mode === "pick" ? (
|
||||
<div className="report-export-list">
|
||||
{GROUPS.map(group => {
|
||||
const items = blocks.filter(block => block.group === group);
|
||||
if (!items.length) return null;
|
||||
const on = items.filter(block => selection.has(block.id)).length;
|
||||
const expanded = open.has(group);
|
||||
return <section key={group} className="report-export-group" aria-label={GROUP_LABELS[group]}>
|
||||
<div className="report-export-group-head">
|
||||
<label className="report-export-row is-group">
|
||||
<Checkbox
|
||||
aria-label={GROUP_LABELS[group]}
|
||||
checked={on === items.length}
|
||||
indeterminate={on > 0 && on < items.length}
|
||||
onCheckedChange={(checked) => toggle(items.map(block => block.id), checked === true)}
|
||||
/>
|
||||
<span>{GROUP_LABELS[group]}<small>{on}/{items.length}</small></span>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="report-export-expand"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "收起" : "展开"}${GROUP_LABELS[group]}`}
|
||||
onClick={() => setOpen(current => { const next = new Set(current); if (next.has(group)) next.delete(group); else next.add(group); return next; })}
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{expanded ? items.map(block => (
|
||||
<label key={block.id} className="report-export-row">
|
||||
<Checkbox
|
||||
aria-label={block.title}
|
||||
checked={selection.has(block.id)}
|
||||
onCheckedChange={(checked) => toggle([block.id], checked === true)}
|
||||
/>
|
||||
<span>{block.title}</span>
|
||||
</label>
|
||||
)) : null}
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
<p className="report-export-caption">拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="report-export-modes" role="radiogroup" aria-label="导出范围">
|
||||
<button type="button" role="radio" aria-checked={mode === "whole"} className="report-export-mode" onClick={() => setMode("whole")}>
|
||||
<span aria-hidden="true" className="report-export-radio" />
|
||||
<span><strong>整篇报告</strong><small>{summary}</small></span>
|
||||
</button>
|
||||
<button type="button" role="radio" aria-checked={mode === "pick"} className="report-export-mode" onClick={() => setMode("pick")}>
|
||||
<span aria-hidden="true" className="report-export-radio" />
|
||||
<span><strong>自选章节</strong><small>只带走需要的部分</small></span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="report-export-footer">
|
||||
<Button disabled={!selected.length} onClick={download}>
|
||||
{!selected.length ? "先勾选要导出的部分" : mode === "whole" ? "导出整篇" : `导出 ${selected.length} 项`}
|
||||
</Button>
|
||||
</footer>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
{mode === "pick" ? (
|
||||
<div className="report-export-list">
|
||||
{GROUPS.map(group => {
|
||||
const items = blocks.filter(block => block.group === group);
|
||||
if (!items.length) return null;
|
||||
const on = items.filter(block => selection.has(block.id)).length;
|
||||
const expanded = open.has(group);
|
||||
return <section key={group} className="report-export-group" aria-label={GROUP_LABELS[group]}>
|
||||
<div className="report-export-group-head">
|
||||
<button type="button" role="checkbox" aria-checked={on === 0 ? false : on === items.length ? true : "mixed"}
|
||||
className="report-export-row is-group" onClick={() => toggle(items.map(block => block.id), on < items.length)}>
|
||||
<CheckMark state={on === 0 ? false : on === items.length ? true : "mixed"} />
|
||||
<span>{GROUP_LABELS[group]}<small>{on}/{items.length}</small></span>
|
||||
</button>
|
||||
<button type="button" className="report-export-expand" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${GROUP_LABELS[group]}`}
|
||||
onClick={() => setOpen(current => { const next = new Set(current); if (next.has(group)) next.delete(group); else next.add(group); return next; })}>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? items.map(block => (
|
||||
<button key={block.id} type="button" role="checkbox" aria-checked={selection.has(block.id)} className="report-export-row"
|
||||
onClick={() => toggle([block.id], !selection.has(block.id))}>
|
||||
<CheckMark state={selection.has(block.id)} />
|
||||
<span>{block.title}</span>
|
||||
</button>
|
||||
)) : null}
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="report-export-footer">
|
||||
<Button disabled={!selected.length} onClick={download}>
|
||||
{!selected.length ? "先勾选要导出的部分" : mode === "whole" ? `导出整篇 · ${size}` : `导出 ${selected.length} 项 · ${size}`}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>;
|
||||
/** One toast per open, after the sheet panel is actually in the document. */
|
||||
function ExportEditionNotice({ englishEdition }: { englishEdition: EnglishEditionStatus }) {
|
||||
useEffect(() => {
|
||||
// Same id collapses React Strict Mode's double invoke into one toast.
|
||||
notifyInfo(
|
||||
englishEdition === "unavailable"
|
||||
? "这份报告的英文版没有生成成功,只能导出中文版。"
|
||||
: "这份报告生成于英文版上线前,重新生成即可获得中英两版。",
|
||||
{ id: "report-export-english" },
|
||||
);
|
||||
}, [englishEdition]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function bodyIds(blocks: readonly ReportExportBlock[]): ReadonlySet<string> {
|
||||
@@ -192,9 +219,3 @@ function countByGroup(blocks: readonly ReportExportBlock[]): Record<Group, numbe
|
||||
for (const block of blocks) counts[block.group] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function CheckMark({ state }: { state: boolean | "mixed" }) {
|
||||
return <span aria-hidden="true" className="report-export-box">
|
||||
{state === "mixed" ? <Minus /> : state ? <Check /> : null}
|
||||
</span>;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { notifyError } from "@/lib/notify";
|
||||
|
||||
/** Row-local confirmation; all states keep this entry, no global confirm dialog. */
|
||||
/** Row menu and a confirmation dialog. The row stays until the delete succeeds. */
|
||||
export function ReportRowDelete({ reportId, onDeleted }: { reportId: string; onDeleted: (id: string) => void }) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const menu = useRef<HTMLDetailsElement>(null);
|
||||
const trigger = useRef<HTMLElement>(null);
|
||||
const id = useId();
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
|
||||
async function remove() {
|
||||
if (pending.current) return;
|
||||
@@ -29,15 +41,31 @@ export function ReportRowDelete({ reportId, onDeleted }: { reportId: string; onD
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (busy) return;
|
||||
setConfirming(false);
|
||||
trigger.current?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
return <div className="report-center-delete">
|
||||
<details ref={menu} className="report-center-menu" onKeyDown={event => { if (event.key === "Escape" && menu.current) { menu.current.open = false; trigger.current?.focus({ preventScroll: true }); } }}>
|
||||
<summary ref={trigger} aria-label="报告更多操作" aria-controls={id}>⋯</summary>
|
||||
<div id={id}><Button variant="ghost" onClick={() => { if (menu.current) menu.current.open = false; setConfirming(true); }}>删除</Button></div>
|
||||
</details>
|
||||
{confirming && <div className="report-center-delete-confirm">
|
||||
<p>确定删除这份报告?</p>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void remove()}>{busy ? "删除中…" : "删除"}</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { setConfirming(false); trigger.current?.focus({ preventScroll: true }); }}>取消</Button>
|
||||
</div>}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger ref={trigger} className="report-center-menu" aria-label="报告更多操作">
|
||||
<Ellipsis aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="min-h-11" onClick={() => setConfirming(true)}>删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AlertDialog open={confirming} onOpenChange={(next) => { if (!next) cancel(); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除这份报告?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={cancel}>取消</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => void remove()}>{busy ? "删除中…" : "删除"}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"cn-input-otp flex items-center has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
spellCheck={false}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn(
|
||||
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
|
||||
role="separator"
|
||||
{...props}
|
||||
>
|
||||
<MinusIcon
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -21,8 +21,11 @@ test("chart page replay stays on the poster: × top left, 「分享」 top right
|
||||
const convergeCss = read("src/components/birth-sky/birth-sky-converge.css");
|
||||
assert.doesNotMatch(replay, /<h2|<header|birth-sky-panel|<img|保存图片/);
|
||||
assert.match(replay, /mode="hold"/);
|
||||
assert.match(replay, /<button className="birth-sky-control" type="button" onClick=\{onClose\} aria-label="关闭">\s*<X aria-hidden="true" \/>/);
|
||||
assert.match(replay, /<button className="birth-sky-control birth-sky-share" type="button" onClick=\{\(\) => void share\(\)\}>/);
|
||||
// 原值:两枚原生 button,类名 birth-sky-control / birth-sky-share
|
||||
// 新值:两枚 Button,类名、关闭的读屏名、分享调用 share() 不变
|
||||
// 原因:K5 只换天空回放上的控件,不改星星汇聚
|
||||
assert.match(replay, /<Button className="birth-sky-control" type="button" onClick=\{onClose\} aria-label="关闭">\s*<X aria-hidden="true" \/>/);
|
||||
assert.match(replay, /<Button className="birth-sky-control birth-sky-share" type="button" onClick=\{\(\) => void share\(\)\}>/);
|
||||
assert.match(replay, /await saveBirthSkyImage\(cover\.blob\)/);
|
||||
assert.match(convergeCss, /\.birth-sky-controls \{[^}]*justify-content: space-between;/);
|
||||
assert.match(convergeCss, /\.birth-sky-control \{[^}]*min-width: 44px;[^}]*height: 44px;/);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Base UI chooses useLayoutEffect only when `document` already exists at module
|
||||
// load. Sheet and menu portals never attach if that check runs in bare Node.
|
||||
// This module has to be the first import of any test that mounts those portals.
|
||||
// The placeholder is only for that check and for libraries that inject a style
|
||||
// tag while loading. The lifecycle harness replaces it before rendering.
|
||||
if (typeof globalThis.document === "undefined") {
|
||||
type Placeholder = {
|
||||
nodeType: number;
|
||||
tagName?: string;
|
||||
childNodes: Placeholder[];
|
||||
parentNode: Placeholder | null;
|
||||
style: Record<string, string>;
|
||||
textContent: string;
|
||||
appendChild: (child: Placeholder) => Placeholder;
|
||||
};
|
||||
const make = (tag: string): Placeholder => {
|
||||
const node: Placeholder = {
|
||||
nodeType: 1,
|
||||
tagName: tag.toUpperCase(),
|
||||
childNodes: [],
|
||||
parentNode: null,
|
||||
style: {},
|
||||
textContent: "",
|
||||
appendChild(child) {
|
||||
child.parentNode = node;
|
||||
node.childNodes.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return node;
|
||||
};
|
||||
const head = make("head");
|
||||
const body = make("body");
|
||||
const documentElement = make("html");
|
||||
const document = {
|
||||
nodeType: 9,
|
||||
head,
|
||||
body,
|
||||
documentElement,
|
||||
defaultView: null as unknown,
|
||||
createElement: (tag: string) => make(tag),
|
||||
createTextNode: (text: string) => ({ nodeType: 3, textContent: text, childNodes: [], parentNode: null, style: {}, appendChild(child: Placeholder) { return child; } }),
|
||||
getElementsByTagName: () => [],
|
||||
};
|
||||
Object.defineProperty(globalThis, "document", { value: document, writable: true, configurable: true });
|
||||
}
|
||||
@@ -37,7 +37,10 @@ test("the consent box starts unticked, shows a hint, and the login refuses to se
|
||||
let ticked: boolean | null = null;
|
||||
try {
|
||||
await h.render(<LoginConsentField checked={false} hint onChange={(value) => { ticked = value; }} />);
|
||||
const box = h.elements().find(node => node.tagName === "INPUT")!;
|
||||
// 原值:勾选的是原生 INPUT,onChange 事件是 { target: { checked: true } }
|
||||
// 新值:勾选的是 role=checkbox。点它会把 true 交给 onChange
|
||||
// 原因:K5 登录同意换成组件库 Checkbox
|
||||
const box = h.elements().find(node => node.getAttribute("role") === "checkbox")!;
|
||||
assert.ok(box);
|
||||
assert.match(h.container.text, /我已阅读并同意《用户协议》和《隐私政策》/);
|
||||
// 原值: /请先阅读并勾选同意,再继续。/ 写在框下面(页面里一行红字)
|
||||
@@ -45,7 +48,7 @@ test("the consent box starts unticked, shows a hint, and the login refuses to se
|
||||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
||||
assert.doesNotMatch(h.container.text, /请先阅读并勾选同意,再继续。/);
|
||||
assert.ok(h.elements().some(node => node.getAttribute("class") === "auth-consent is-attention"));
|
||||
await h.event(box, "onChange", { target: { checked: true } });
|
||||
await h.event(box);
|
||||
assert.equal(ticked, true);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally { await h.close(); }
|
||||
|
||||
@@ -6,6 +6,11 @@ import { createRoot } from "react-dom/client";
|
||||
* accessibility behavior is emulated. React's reconciler/effects are real.
|
||||
*/
|
||||
class HostElement {
|
||||
static DOCUMENT_POSITION_DISCONNECTED = 1;
|
||||
static DOCUMENT_POSITION_PRECEDING = 2;
|
||||
static DOCUMENT_POSITION_FOLLOWING = 4;
|
||||
static DOCUMENT_POSITION_CONTAINS = 8;
|
||||
static DOCUMENT_POSITION_CONTAINED_BY = 16;
|
||||
readonly nodeType = 1;
|
||||
readonly namespaceURI = "http://www.w3.org/1999/xhtml";
|
||||
readonly childNodes: HostElement[] = [];
|
||||
@@ -23,6 +28,34 @@ class HostElement {
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
scrollIntoView() {}
|
||||
// Component-library checkbox clicks the hidden input with a PointerEvent.
|
||||
// A real checkbox then flips `checked` and emits `change`.
|
||||
checked = false;
|
||||
getAnimations() { return []; }
|
||||
setPointerCapture() {}
|
||||
releasePointerCapture() {}
|
||||
hasPointerCapture() { return false; }
|
||||
append(...nodes: unknown[]) {
|
||||
for (const node of nodes) {
|
||||
if (node instanceof HostElement) this.appendChild(node);
|
||||
}
|
||||
}
|
||||
dispatchEvent(event: { type?: string }) {
|
||||
const inputType = this.getAttribute("type") ?? (this as unknown as { type?: string }).type;
|
||||
if (event?.type === "click" && this.tagName === "INPUT" && inputType === "checkbox") {
|
||||
this.checked = !this.checked;
|
||||
const change = {
|
||||
type: "change",
|
||||
target: this,
|
||||
currentTarget: this,
|
||||
nativeEvent: { defaultPrevented: false },
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
};
|
||||
(this.props.onChange as ((next: unknown) => void) | undefined)?.(change);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
scrollTop = 0;
|
||||
scrollHeight = 0;
|
||||
clientHeight = 0;
|
||||
@@ -46,6 +79,26 @@ class HostElement {
|
||||
querySelectorAll() { return []; }
|
||||
getRootNode() { return this.ownerDocument; }
|
||||
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
|
||||
compareDocumentPosition(other: HostElement): number {
|
||||
if (!other || other === this) return 0;
|
||||
if (this.contains(other)) return HostElement.DOCUMENT_POSITION_CONTAINED_BY | HostElement.DOCUMENT_POSITION_FOLLOWING;
|
||||
if (other.contains?.(this)) return HostElement.DOCUMENT_POSITION_CONTAINS | HostElement.DOCUMENT_POSITION_PRECEDING;
|
||||
const chain = (node: HostElement) => {
|
||||
const nodes: HostElement[] = [];
|
||||
let current: HostElement | null = node;
|
||||
while (current) { nodes.push(current); current = current.parentNode; }
|
||||
return nodes.reverse();
|
||||
};
|
||||
const a = chain(this);
|
||||
const b = chain(other);
|
||||
let index = 0;
|
||||
while (index < a.length && index < b.length && a[index] === b[index]) index += 1;
|
||||
const parent = a[index - 1];
|
||||
if (!parent) return HostElement.DOCUMENT_POSITION_DISCONNECTED;
|
||||
const aIndex = parent.childNodes.indexOf(a[index] ?? this);
|
||||
const bIndex = parent.childNodes.indexOf(b[index] ?? other);
|
||||
return aIndex <= bIndex ? HostElement.DOCUMENT_POSITION_FOLLOWING : HostElement.DOCUMENT_POSITION_PRECEDING;
|
||||
}
|
||||
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
|
||||
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
|
||||
readonly attributes = new Map<string, string>();
|
||||
@@ -115,12 +168,69 @@ export function createClientLifecycleHarness() {
|
||||
return id;
|
||||
};
|
||||
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
|
||||
const computedStyle = {
|
||||
direction: "ltr",
|
||||
overflow: "visible",
|
||||
overflowX: "visible",
|
||||
overflowY: "visible",
|
||||
display: "block",
|
||||
position: "static",
|
||||
transform: "none",
|
||||
translate: "none",
|
||||
scale: "none",
|
||||
rotate: "none",
|
||||
perspective: "none",
|
||||
backdropFilter: "none",
|
||||
filter: "none",
|
||||
willChange: "auto",
|
||||
contain: "none",
|
||||
};
|
||||
class KeyboardEvent {}
|
||||
class FocusEvent {}
|
||||
class MouseEvent {}
|
||||
class PointerEvent {
|
||||
bubbles = false;
|
||||
button = 0;
|
||||
shiftKey = false;
|
||||
ctrlKey = false;
|
||||
altKey = false;
|
||||
metaKey = false;
|
||||
constructor(readonly type: string, init: Record<string, unknown> = {}) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
class MutationObserver {
|
||||
constructor(callback: () => void) { this.callback = callback; }
|
||||
callback: () => void;
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() { return []; }
|
||||
}
|
||||
const window = {
|
||||
document,
|
||||
innerWidth: 1440,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
getComputedStyle: () => ({ direction: "ltr" }),
|
||||
getComputedStyle: () => computedStyle,
|
||||
matchMedia: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
}),
|
||||
KeyboardEvent,
|
||||
FocusEvent,
|
||||
MouseEvent,
|
||||
PointerEvent,
|
||||
ResizeObserver,
|
||||
MutationObserver,
|
||||
HTMLElement: HostElement,
|
||||
Element: HostElement,
|
||||
Node: HostElement,
|
||||
@@ -134,7 +244,7 @@ export function createClientLifecycleHarness() {
|
||||
// tsx's ESM parser binds its runtime to global `self` when that binding
|
||||
// exists. A partial window makes `cn` fail to parse. Next still calls
|
||||
// `self.setTimeout`, so `self` is the real global, which already has it.
|
||||
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, KeyboardEvent, FocusEvent, MouseEvent, PointerEvent, ResizeObserver, MutationObserver, getComputedStyle: () => computedStyle, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const originals = Object.fromEntries(Object.keys(globals).map((key) => [
|
||||
key, Object.getOwnPropertyDescriptor(globalThis, key),
|
||||
]));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
@@ -166,33 +167,49 @@ test("the language switch only exists when both editions do", async () => {
|
||||
await h.render(<ReportActions markdown="# 报告" englishEdition="legacy" language="zh" onLanguageChange={value => picked.push(value)} />);
|
||||
assert.ok(!h.elements().some(node => node.getAttribute("aria-label") === "报告语言"));
|
||||
await h.render(<ReportActions markdown="# 报告" englishEdition="ready" language="zh" onLanguageChange={value => picked.push(value)} />);
|
||||
const english = h.elements().find(node => node.tagName === "BUTTON" && node.text === "English");
|
||||
// 原值:English 是 BUTTON,aria-pressed="false"
|
||||
// 新值:English 是 role=tab,aria-selected="false"
|
||||
// 原因:K5 报告语言改用分段页签
|
||||
const english = h.elements().find(node => node.getAttribute("role") === "tab" && node.text === "English");
|
||||
assert.ok(english);
|
||||
assert.equal(english.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(english.getAttribute("aria-selected"), "false");
|
||||
await h.event(english);
|
||||
assert.deepEqual(picked, ["en"]);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test("the export drawer suggests English for AI only when it can switch", async () => {
|
||||
// 原值:没有英文版时,弹窗正文含「重新生成即可获得中英两版」
|
||||
// 新值:弹窗正文不含该句;toast.info 调用一次,文案为该句,id 为 report-export-english;仍没有 English 选项;英文可用时仍有「建议选 English」
|
||||
// 原因:issue #90 把英文不可用说明改成打开弹窗时的顶部浮层。更早的一轮(2026-09-30)已经把整页「切到 English」改成弹窗内的语言选择
|
||||
// 原值:没有英文版时正文含那句;英文可用时正文含「建议选 English」,选项是 radio
|
||||
// 新值:没有英文版时仍是一条 toast,正文不含那句,也没有 English 页签;英文可用时没有说明句,语言是页签
|
||||
// 原因:issue #90 的 toast 还在。K5 / D8 删掉说明句,D9 改成分段页签
|
||||
const zhBlocks = buildReportExportBlocks({ markdown: zhReader.markdown });
|
||||
const enBlocks = buildReportExportBlocks({ markdown: `# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n`, language: "en" });
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks, en: enBlocks }} onClose={() => {}} englishEdition="ready" />);
|
||||
assert.ok(h.container.text.includes("建议选 English"));
|
||||
const english = h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!;
|
||||
assert.equal(english.getAttribute("aria-checked"), "false");
|
||||
await h.event(english);
|
||||
assert.equal(h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!.getAttribute("aria-checked"), "true");
|
||||
await h.event(h.elements().find(node => node.getAttribute("role") === "radio" && node.text.startsWith("自选章节"))!);
|
||||
assert.ok(h.container.text.includes("Summary"), "the checklist is the English edition");
|
||||
const visible = () => `${h.container.text}\n${h.document.body.text}`;
|
||||
type Host = { childNodes?: readonly Host[]; getAttribute?: (name: string) => string | null; text: string };
|
||||
const edition = (label: string) => {
|
||||
const walk = (node: Host): Host[] => [node, ...(node.childNodes ?? []).flatMap((child) => walk(child))];
|
||||
const all = [h.container as Host, ...[...h.document.body.childNodes]].flatMap((node) => walk(node));
|
||||
return all.find((node) => node.getAttribute?.("role") === "tab" && node.text === label)
|
||||
?? all.find((node) => node.getAttribute?.("role") === "tab" && node.text.startsWith(label));
|
||||
};
|
||||
// 原值:正文含「建议选 English」,语言是 radio + aria-checked
|
||||
// 新值:没有这句;语言是 tab + aria-selected。切到 English 后清单仍是英文版
|
||||
// 原因:K5 / D8 删掉说明句;D9 单选用分段页签
|
||||
assert.equal(visible().includes("建议选 English"), false);
|
||||
const english = edition("English");
|
||||
assert.ok(english);
|
||||
assert.equal(english.getAttribute?.("aria-selected"), "false");
|
||||
await h.event(english as never);
|
||||
assert.equal(edition("English")?.getAttribute?.("aria-selected"), "true");
|
||||
const pick = edition("自选章节");
|
||||
await h.event(pick as never);
|
||||
assert.ok(visible().includes("Summary"), "the checklist is the English edition");
|
||||
await h.render(null);
|
||||
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks, en: enBlocks }} initialLanguage="en" onClose={() => {}} englishEdition="ready" />);
|
||||
assert.equal(h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!.getAttribute("aria-checked"), "true", "starts on the edition being read");
|
||||
assert.equal(edition("English")?.getAttribute?.("aria-selected"), "true", "starts on the edition being read");
|
||||
await h.render(null);
|
||||
const legacy = "这份报告生成于英文版上线前,重新生成即可获得中英两版。";
|
||||
const infos: { message: string; id?: string | number }[] = [];
|
||||
@@ -205,7 +222,9 @@ test("the export drawer suggests English for AI only when it can switch", async
|
||||
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks }} onClose={() => {}} englishEdition="legacy" />);
|
||||
assert.equal(h.container.text.includes(legacy), false);
|
||||
assert.deepEqual(infos, [{ message: legacy, id: "report-export-english" }]);
|
||||
assert.ok(!h.elements().some(node => node.getAttribute("role") === "radio" && node.text === "English"));
|
||||
const walk = (node: { childNodes: readonly unknown[]; getAttribute?: (name: string) => string | null; text: string }): unknown[] => [node, ...node.childNodes.flatMap((child) => walk(child as typeof node))];
|
||||
const tabs = [h.container, ...h.document.body.childNodes].flatMap((node) => walk(node as never));
|
||||
assert.ok(!tabs.some((node) => (node as { getAttribute?: (name: string) => string | null; text: string }).getAttribute?.("role") === "tab" && (node as { text: string }).text === "English"));
|
||||
} finally { toast.info = previousInfo; }
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
@@ -20,10 +21,29 @@ const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url),
|
||||
toast.info = () => 1;
|
||||
|
||||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||||
const button = (h: Harness, text: string) => { const node = h.elements().find(node => node.tagName === "BUTTON" && node.text.startsWith(text)); assert.ok(node, text); return node; };
|
||||
const checks = (h: Harness) => h.elements().filter(node => node.getAttribute("role") === "checkbox");
|
||||
const radio = (h: Harness, text: string) => { const node = h.elements().find(node => node.getAttribute("role") === "radio" && node.text.startsWith(text)); assert.ok(node, text); return node; };
|
||||
const exportButton = (h: Harness) => h.elements().filter(node => node.tagName === "BUTTON").find(node => /^导出|^先勾选/.test(node.text))!;
|
||||
type Node = ReturnType<Harness["elements"]>[number];
|
||||
// Sheet, tabs and menus portal onto document.body. The harness container walk misses them.
|
||||
function allNodes(h: Harness): Node[] {
|
||||
const walk = (node: Node): Node[] => {
|
||||
const children = node?.childNodes ?? [];
|
||||
return [node, ...[...children].flatMap((child) => walk(child as Node))];
|
||||
};
|
||||
return [...walk(h.container as unknown as Node), ...[...(h.document.body.childNodes ?? [])].flatMap((child) => walk(child as Node))];
|
||||
}
|
||||
const button = (h: Harness, text: string) => { const node = allNodes(h).find(node => node.tagName === "BUTTON" && node.text.startsWith(text)); assert.ok(node, text); return node; };
|
||||
const checks = (h: Harness) => allNodes(h).filter(node => node.getAttribute("role") === "checkbox");
|
||||
const namedCheck = (h: Harness, label: string) => {
|
||||
const node = checks(h).find(node => (node.getAttribute("aria-label") ?? "").startsWith(label));
|
||||
assert.ok(node, label);
|
||||
return node;
|
||||
};
|
||||
// 原值:单选是 role=radio + aria-checked
|
||||
// 新值:分段页签 role=tab。选中看 aria-selected
|
||||
// 原因:K5 / D9,导出语言和导出范围改用和星盘页盘型条一样的 Tabs
|
||||
const tab = (h: Harness, text: string) => { const node = allNodes(h).find(node => node.getAttribute("role") === "tab" && node.text.startsWith(text)); assert.ok(node, text); return node; };
|
||||
const exportButton = (h: Harness) => allNodes(h).filter(node => node.tagName === "BUTTON").find(node => /^导出|^先勾选/.test(node.text))!;
|
||||
const sheetOpen = (h: Harness) => allNodes(h).some(node => node.getAttribute("role") === "dialog" || node.getAttribute("data-slot") === "sheet-content");
|
||||
const sheetText = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
|
||||
|
||||
// 2026-09-30 redesign (product: 「分块导出太丑,需要重新设计」, chose 「两步:整篇 / 自选」).
|
||||
// Test names are kept for suite continuity; the presets 常用 / 全部分盘 / 仅核对表 and
|
||||
@@ -37,27 +57,31 @@ test("drawer mounts all golden blocks, presets replace selection and manual choi
|
||||
try {
|
||||
await h.render(<ReportExportDrawer editions={editions} onClose={() => {}} />);
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open);
|
||||
// 原值:原生 DIALOG 的 open 为真,按钮写「导出整篇 · 约 N KB」
|
||||
// 新值:Sheet 在文档里;按钮只写「导出整篇」,不带文件大小
|
||||
// 原因:K5 导出改用 Sheet,D8 去掉文件大小
|
||||
assert.equal(sheetOpen(h), true);
|
||||
assert.equal(checks(h).length, 0, "no checklist until 自选章节");
|
||||
assert.equal(radio(h, "整篇报告").getAttribute("aria-checked"), "true");
|
||||
assert.equal(exportButton(h).text, `导出整篇 · ${formatExportSize(exportByteSize(blocks))}`);
|
||||
await h.event(radio(h, "自选章节"));
|
||||
assert.equal(tab(h, "整篇报告").getAttribute("aria-selected"), "true");
|
||||
assert.equal(exportButton(h).text, "导出整篇");
|
||||
assert.doesNotMatch(exportButton(h).text, /KB|MB|·/);
|
||||
await h.event(tab(h, "自选章节"));
|
||||
const body = blocks.filter(block => block.group === "body");
|
||||
// Group rows (3) + the expanded 正文 rows.
|
||||
assert.equal(checks(h).length, 3 + body.length);
|
||||
assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, 1 + body.length);
|
||||
const chartGroup = checks(h).find(node => node.text.startsWith("分盘"))!;
|
||||
const chartGroup = namedCheck(h, "分盘");
|
||||
assert.equal(chartGroup.getAttribute("aria-checked"), "false");
|
||||
await h.event(chartGroup);
|
||||
assert.equal(checks(h).find(node => node.text.startsWith("分盘"))!.getAttribute("aria-checked"), "true");
|
||||
await h.event(checks(h).find(node => node.text.startsWith(body[0]!.title))!);
|
||||
assert.equal(checks(h).find(node => node.text.startsWith("正文"))!.getAttribute("aria-checked"), "mixed");
|
||||
assert.equal(namedCheck(h, "分盘").getAttribute("aria-checked"), "true");
|
||||
await h.event(namedCheck(h, body[0]!.title));
|
||||
assert.equal(namedCheck(h, "正文").getAttribute("aria-checked"), "mixed");
|
||||
for (const group of ["正文", "分盘", "核对表"]) {
|
||||
const row = checks(h).find(node => node.text.startsWith(group))!;
|
||||
const row = namedCheck(h, group);
|
||||
if (row.getAttribute("aria-checked") === "true") await h.event(row);
|
||||
if (checks(h).find(node => node.text.startsWith(group))!.getAttribute("aria-checked") === "mixed") {
|
||||
await h.event(checks(h).find(node => node.text.startsWith(group))!);
|
||||
await h.event(checks(h).find(node => node.text.startsWith(group))!);
|
||||
if (namedCheck(h, group).getAttribute("aria-checked") === "mixed") {
|
||||
await h.event(namedCheck(h, group));
|
||||
await h.event(namedCheck(h, group));
|
||||
}
|
||||
}
|
||||
assert.equal(exportButton(h).text, "先勾选要导出的部分");
|
||||
@@ -83,16 +107,18 @@ test("drawer download uses the existing Blob downloader exactly once and only th
|
||||
const originalCreate = document.createElement.bind(document);
|
||||
URL.createObjectURL = value => { blob = value as Blob; return "blob:test"; };
|
||||
URL.revokeObjectURL = () => {};
|
||||
Object.assign(document, { body: { append() {} } });
|
||||
// 原值:把 document.body 换成只有 append 的空对象,挡住原生下载
|
||||
// 新值:留下测试文档的 body。锚点仍是假的,body.append 对它不做事
|
||||
// 原因:K5 的 Sheet 要挂到 body 上。换掉 body 后传送门和下载都看不见
|
||||
document.createElement = ((tag: string) => {
|
||||
if (tag !== "a") return originalCreate(tag);
|
||||
return { set download(value: string) { filename = value; }, click() { clicked += 1; }, remove() {} } as unknown as HTMLAnchorElement;
|
||||
}) as typeof document.createElement;
|
||||
try {
|
||||
await h.render(<ReportExportDrawer editions={editions} reportDate="2026-09-24" onClose={() => { closed += 1; }} />);
|
||||
await h.event(radio(h, "自选章节"));
|
||||
await h.event(checks(h).find(node => node.text.startsWith("正文"))!);
|
||||
await h.event(checks(h).find(node => node.text.startsWith("核对表"))!);
|
||||
await h.event(tab(h, "自选章节"));
|
||||
await h.event(namedCheck(h, "正文"));
|
||||
await h.event(namedCheck(h, "核对表"));
|
||||
const tables = blocks.filter(block => block.group === "table");
|
||||
await h.event(button(h, `导出 ${tables.length} 项`));
|
||||
assert.equal(clicked, 1);
|
||||
@@ -104,7 +130,7 @@ test("drawer download uses the existing Blob downloader exactly once and only th
|
||||
});
|
||||
|
||||
test("download failure and WeChat restrictions leave drawer open without a success close", async () => {
|
||||
// 原值:按「导出选中 (正文块数)」;新值:按「导出整篇 · …」;原因:2026-09-30 重设计,失败与微信限制的行为断言不变
|
||||
// 原值:按「导出整篇 · …」;新值:按「导出整篇」;原因:K5 / D8 去掉文件大小。失败与微信限制的行为不变
|
||||
const h = createClientLifecycleHarness();
|
||||
const oldCreate = URL.createObjectURL;
|
||||
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
@@ -128,7 +154,7 @@ test("download failure and WeChat restrictions leave drawer open without a succe
|
||||
assert.match(errors.at(-1) ?? "", /系统浏览器/);
|
||||
assert.equal(closed, 0);
|
||||
assert.equal(successes, 0);
|
||||
assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open);
|
||||
assert.equal(sheetOpen(h), true);
|
||||
} finally {
|
||||
toast.success = successBefore;
|
||||
toast.error = errorBefore;
|
||||
@@ -143,7 +169,7 @@ test("reader actions keep only the accessible export icon", async () => {
|
||||
try {
|
||||
Object.assign(window, { location: { hash: "#export" }, history: { replaceState() { assert.fail("retired export hash must not be consumed"); } } });
|
||||
await h.render(<ReportActions markdown={reader.markdown} />);
|
||||
assert.ok(!h.elements().some(node => node.tagName === "DIALOG"), "old export hash does not auto-open the dialog");
|
||||
assert.equal(sheetOpen(h), false, "old export hash does not auto-open the sheet");
|
||||
assert.equal(h.elements().filter(node => node.tagName === "BUTTON").length, 1);
|
||||
assert.ok(h.elements().some(node => node.tagName === "A" && node.getAttribute("href") === "/reports"));
|
||||
assert.deepEqual(h.errors, []);
|
||||
@@ -157,57 +183,41 @@ test("reader actions keep only the accessible export icon", async () => {
|
||||
assert.ok(!h.container.text.includes("下载原始附录"));
|
||||
assert.ok(!h.container.text.includes("打印"));
|
||||
await h.event(exportButton!);
|
||||
assert.ok(h.elements().some(node => node.tagName === "DIALOG"));
|
||||
assert.equal(sheetOpen(h), true);
|
||||
// 原值:按可见文字「关闭」找按钮;新值:按读屏名「关闭导出」找(2026-09-30 改成 × 图标,标题改「导出报告」)
|
||||
await h.event(h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "关闭导出")!);
|
||||
await h.event(allNodes(h).find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "关闭导出")!);
|
||||
await h.idle();
|
||||
assert.ok(!h.elements().some(node => node.tagName === "DIALOG"));
|
||||
assert.equal(sheetOpen(h), false);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test("mounted drawer handles Escape and backdrop but not inner clicks and restores focus", async () => {
|
||||
// 原值:内部点击目标是「常用」套餐按钮;新值:「整篇报告」单选;原因:套餐已删除
|
||||
test("mounted drawer closes from its control and restores focus", async () => {
|
||||
// 原值:原生 dialog 的 onCancel(Esc)和点遮罩(target === currentTarget)各关一次;内部点「整篇报告」不关闭
|
||||
// 新值:点「整篇报告」不关闭;点「关闭导出」关闭;卸载后焦点回到打开前的按钮。Esc 和点遮罩由 Sheet 调 onOpenChange(false)
|
||||
// 原因:K5 导出改用 Sheet,不再使用原生 dialog
|
||||
const h = createClientLifecycleHarness(); let closes = 0;
|
||||
const previous = document.createElement("button"); previous.focus();
|
||||
try {
|
||||
await h.render(<ReportExportDrawer editions={editions} onClose={() => { closes += 1; }} />);
|
||||
const dialog = h.elements().find(node => node.tagName === "DIALOG")!;
|
||||
await h.event(dialog, "onClick", { target: radio(h, "整篇报告") }); assert.equal(closes, 0);
|
||||
await h.event(dialog, "onCancel"); assert.equal(closes, 1);
|
||||
await h.event(dialog); assert.equal(closes, 2);
|
||||
await h.event(tab(h, "整篇报告")); assert.equal(closes, 0);
|
||||
await h.event(allNodes(h).find(node => node.getAttribute("aria-label") === "关闭导出")!); assert.equal(closes, 1);
|
||||
await h.render(null); assert.equal(document.activeElement, previous);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test("drawer contract uses native dialog, Escape/backdrop close and only allowed responsive chrome", () => {
|
||||
assert.match(source, /<dialog/);
|
||||
assert.match(source, /onCancel=/);
|
||||
assert.match(source, /event.target === event.currentTarget/);
|
||||
assert.doesNotMatch(source, /InlineSpinner|navigator\.clipboard|fetch\(/);
|
||||
test("drawer contract uses a sheet from the right or the bottom, and the list still scrolls", () => {
|
||||
// 原值:原生 `<dialog>`、onCancel、点遮罩 target === currentTarget;窄屏 bottom inset 与桌面居中 min(520px)
|
||||
// 新值:`<Sheet`,onOpenChange 在 open 为 false 时 onClose;桌面 data-side=right 宽 min(520px, 92vw),窄屏 data-side=bottom 最高 85dvh
|
||||
// 原因:K5 导出从右侧滑出,手机从底部滑出。滚动仍在正文,头尾不滚走
|
||||
assert.match(source, /<Sheet open onOpenChange=\{\(open\) => \{ if \(!open\) onClose\(\); \}\}/);
|
||||
assert.match(source, /side=\{side\}/);
|
||||
assert.match(source, /max-width: 767px/);
|
||||
assert.doesNotMatch(source, /<dialog|onCancel=|InlineSpinner|navigator\.clipboard|fetch\(/);
|
||||
const css = styles.slice(styles.indexOf(".report-export-drawer {"), styles.indexOf("personal-report: unique block"));
|
||||
assert.match(css, /min-width: 860px/);
|
||||
assert.doesNotMatch(css, /var\(--report-(?:paper|rule|accent)\)/);
|
||||
assert.match(css, /min-height: 44px/);
|
||||
const narrow = css.slice(0, css.indexOf("@media (min-width: 860px)"));
|
||||
assert.match(narrow, /inset: auto 0 0 var\(--report-export-sidebar\)/);
|
||||
assert.match(narrow, /width: min\(760px, calc\(\(100% - var\(--report-export-sidebar\)\) \* \.92\)\)/);
|
||||
assert.match(narrow, /margin: 0 auto/);
|
||||
assert.match(narrow, /border-radius: var\(--radius-lg\) var\(--radius-lg\) 0 0/);
|
||||
// 原值:窄屏抽屉 `max-height: 70vh` / `70dvh`
|
||||
// 新值:`85vh` / `85dvh`
|
||||
// 原因:2026-09-30 产品真机:手机上头部占掉大半,分块列表看不见;头部压缩后再给列表更多高度(桌面 80vh 不变)
|
||||
assert.match(narrow, /max-height: 85vh/);
|
||||
assert.match(narrow, /@supports \(height: 1dvh\)\s*\{\s*\.report-export-drawer, \.report-export-panel \{ max-height: 85dvh;/);
|
||||
const desktopStart = css.indexOf("@media (min-width: 860px)");
|
||||
const desktop = css.slice(desktopStart);
|
||||
// 原值:桌面 `width: min(720px, 92vw)`;新值:`min(520px, 92vw)`;原因:2026-09-30 重设计后是单列的紧凑面板,720 宽只剩空白
|
||||
assert.match(desktop, /\.report-export-drawer\s*\{\s*inset: 0;\s*width: min\(520px, 92vw\);\s*max-height: 80vh;\s*margin: auto;\s*border-radius: var\(--radius-lg\);/);
|
||||
assert.match(desktop, /\.report-export-panel \{ max-height: calc\(80vh - 2px\); \}/);
|
||||
// 原值 70(d)vh → 新值 85(d)vh:窄屏上限改为 85 后,桌面仍不得继承窄屏上限(原因同上)
|
||||
assert.doesNotMatch(desktop, /max-height: 85(?:d)?vh|inset:.*--report-export-sidebar/);
|
||||
// 原值:`.report-export-list` 负责滚动、头尾 `flex-shrink: 0`
|
||||
// 新值:滚动区是 `.report-export-body`(语言、范围与清单在同一滚动区),头尾仍 `flex-shrink: 0`
|
||||
// 原因:2026-09-30 重设计,清单只在「自选」时出现
|
||||
assert.match(css, /\.report-export-drawer\[data-side="right"\] \{[^}]*width: min\(520px, 92vw\);/);
|
||||
assert.match(css, /\.report-export-drawer\[data-side="bottom"\] \{[^}]*max-height: 85dvh;/);
|
||||
assert.match(css, /\.report-export-body \{[^}]*min-height: 0;[^}]*overflow-y: auto;/);
|
||||
assert.match(css, /\.report-export-header \{[^}]*flex-shrink: 0;/);
|
||||
assert.match(css, /\.report-export-footer \{[^}]*flex-shrink: 0;/);
|
||||
@@ -220,7 +230,7 @@ test("an unavailable English edition is one info toast per open, not a caption",
|
||||
const previous = toast.info;
|
||||
const h = createClientLifecycleHarness();
|
||||
toast.info = (message, options) => {
|
||||
assert.equal(h.elements().find(node => node.tagName === "DIALOG")?.open, true, "notify after showModal");
|
||||
assert.equal(sheetOpen(h), true, "notify after the sheet is open");
|
||||
infos.push({ message: String(message), id: options?.id });
|
||||
return infos.length;
|
||||
};
|
||||
@@ -229,11 +239,11 @@ test("an unavailable English edition is one info toast per open, not a caption",
|
||||
assert.deepEqual(infos, [{ message: legacy, id: "report-export-english" }]);
|
||||
assert.equal(h.container.text.includes(legacy), false);
|
||||
assert.equal(h.container.text.includes(failed), false);
|
||||
await h.event(radio(h, "自选章节"));
|
||||
await h.event(tab(h, "自选章节"));
|
||||
const chapter = blocks.find(block => block.group === "body")!;
|
||||
await h.event(checks(h).find(node => node.text.startsWith(chapter.title))!);
|
||||
await h.event(checks(h).find(node => node.text.startsWith(chapter.title))!);
|
||||
const expand = h.elements().find(node => node.getAttribute("aria-label") === "展开分盘");
|
||||
await h.event(namedCheck(h, chapter.title));
|
||||
await h.event(namedCheck(h, chapter.title));
|
||||
const expand = allNodes(h).find(node => node.getAttribute("aria-label") === "展开分盘");
|
||||
assert.ok(expand, "展开分盘");
|
||||
await h.event(expand);
|
||||
assert.equal(infos.length, 1, "picking chapters does not notify again");
|
||||
@@ -252,7 +262,10 @@ test("an unavailable English edition is one info toast per open, not a caption",
|
||||
await h.render(null);
|
||||
await h.render(<ReportExportDrawer editions={{ zh: blocks, en: blocks }} onClose={() => {}} englishEdition="ready" />);
|
||||
assert.equal(infos.length, 3, "a ready English edition does not notify");
|
||||
assert.ok(h.container.text.includes("建议选 English"));
|
||||
// 原值:英文可用时抽屉正文含「建议选 English」
|
||||
// 新值:没有这句说明
|
||||
// 原因:K5 / D8 删掉说明句
|
||||
assert.equal(sheetText(h).includes("建议选 English"), false);
|
||||
} finally {
|
||||
toast.info = previous;
|
||||
await h.close();
|
||||
@@ -272,13 +285,16 @@ test("the drawer shows the real download size, not a character count (BUG-1094)"
|
||||
assert.equal(formatExportSize(186 * 1024), "约 186 KB");
|
||||
assert.equal(formatExportSize(300), "约 1 KB");
|
||||
assert.equal(formatExportSize(Math.round(1.44 * 1024 * 1024)), "约 1.4 MB");
|
||||
// 原值:统计行同时显示全篇与已选大小;新值:大小只在导出按钮上出现一次(整篇 = 全部块,自选 = 所选块)
|
||||
// 原因:2026-09-30 重设计,统计行删除
|
||||
// 原值:大小只在导出按钮上出现一次(整篇 = 全部块,自选 = 所选块)
|
||||
// 新值:按钮只写「导出整篇」或「导出 N 项」,页面上看不到「约 N KB」
|
||||
// 原因:K5 / D8 去掉文件大小。字节计算仍由上面的断言锁住
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(<ReportExportDrawer editions={editions} onClose={() => {}} />);
|
||||
assert.equal(exportButton(h).text, `导出整篇 · ${formatExportSize(exportByteSize(blocks))}`);
|
||||
await h.event(radio(h, "自选章节"));
|
||||
assert.equal(exportButton(h).text, `导出 ${body.length} 项 · ${formatExportSize(exportByteSize(body))}`);
|
||||
assert.equal(exportButton(h).text, "导出整篇");
|
||||
assert.doesNotMatch(sheetText(h), /约 \d| KB| MB/);
|
||||
await h.event(tab(h, "自选章节"));
|
||||
assert.equal(exportButton(h).text, `导出 ${body.length} 项`);
|
||||
assert.doesNotMatch(sheetText(h), /约 \d| KB| MB/);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
@@ -50,13 +50,16 @@ async function openReader(initialLanguage: "zh" | "en") {
|
||||
const read = () => {
|
||||
const layouts = h.elements().filter(node => hasClass(node, "personal-report-md-layout"));
|
||||
const toc = h.elements().find(node => hasClass(node, "personal-report-toc-desktop"));
|
||||
const pressed = h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-pressed") === "true");
|
||||
// 原值:选中的语言是 BUTTON 且 aria-pressed="true"
|
||||
// 新值:分段页签 role=tab 且 aria-selected="true"
|
||||
// 原因:K5 报告语言改用和星盘页盘型条一样的 Tabs。正文仍只能有一份
|
||||
const pressed = h.elements().find(node => node.getAttribute("role") === "tab" && node.getAttribute("aria-selected") === "true");
|
||||
// The article only: the 「到底部」 jump beside it is app chrome and stays Chinese (English edition decision 6).
|
||||
const articles = h.elements().filter(node => hasClass(node, "personal-report-md-article"));
|
||||
return { layouts: layouts.length, reader: articles.map(node => node.text).join(""), toc: toc?.childNodes[0]?.text, pressed: pressed?.text, url: location.href };
|
||||
};
|
||||
const pick = async (label: string) => {
|
||||
const button = h.elements().find(node => node.tagName === "BUTTON" && node.text === label);
|
||||
const button = h.elements().find(node => node.getAttribute("role") === "tab" && node.text === label);
|
||||
assert.ok(button, `${label} switch button`);
|
||||
await h.event(button);
|
||||
await h.idle();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { toast } from "sonner";
|
||||
@@ -11,7 +12,32 @@ import { createClientLifecycleHarness } from "./react-client-lifecycle-test-supp
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||||
const buttons = (h: Harness, text: string) => h.elements().filter(node => node.tagName === "BUTTON" && node.text === text);
|
||||
type Node = ReturnType<Harness["elements"]>[number];
|
||||
function allNodes(h: Harness): Node[] {
|
||||
const walk = (node: Node): Node[] => {
|
||||
const children = node?.childNodes ?? [];
|
||||
return [node, ...[...children].flatMap((child) => walk(child as Node))];
|
||||
};
|
||||
return [...walk(h.container as unknown as Node), ...[...(h.document.body.childNodes ?? [])].flatMap((child) => walk(child as Node))];
|
||||
}
|
||||
const textOf = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
|
||||
const buttons = (h: Harness, text: string) => allNodes(h).filter(node => node.tagName === "BUTTON" && node.text === text);
|
||||
const trigger = (h: Harness) => allNodes(h).find(node => node.getAttribute("aria-label") === "报告更多操作")!;
|
||||
// 原值:删除项一直在 details 里,是第一枚文字为「删除」的按钮;取消后焦点回到 SUMMARY
|
||||
// 新值:先点「报告更多操作」,再点菜单里的「删除」;确认框里的「删除」是按钮;取消后焦点回到菜单按钮
|
||||
// 原因:K5 行内 details 换成 DropdownMenu 和 AlertDialog
|
||||
async function askDelete(h: Harness) {
|
||||
// 原值:点一下就打开 details
|
||||
// 新值:菜单在按下主按钮时打开,打开排到下一帧,所以要等一拍再找「删除」
|
||||
// 原因:组件库菜单听的是 mousedown,内容在传送门里
|
||||
await h.event(trigger(h), "onMouseDown", { button: 0 });
|
||||
await h.idle();
|
||||
const item = allNodes(h).find(node => node.text === "删除" && node.getAttribute("role") === "menuitem")
|
||||
?? buttons(h, "删除")[0];
|
||||
assert.ok(item, "删除");
|
||||
await h.event(item);
|
||||
await h.idle();
|
||||
}
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
|
||||
// Explicitly fictional list metadata, not an engine-contract fixture.
|
||||
const row: ReportListItem = { id: "fictional-report", reportType: "personal_full", presentationMode: "default", depth: "standard", themes: ["career"], status: "ready", failureCode: null, createdAt: "2026-09-24T12:00:00Z", completedAt: null };
|
||||
@@ -31,22 +57,25 @@ test("row delete is confirmed inline, cancelled without request and retains fail
|
||||
globalThis.fetch = async () => { calls += 1; return new Response(null, { status: 500 }); };
|
||||
try {
|
||||
await h.render(<ReportRowDelete reportId={row.id} onDeleted={() => { removed += 1; }} />);
|
||||
await h.event(buttons(h, "删除")[0]);
|
||||
assert.match(h.container.text, /确定删除这份报告?/);
|
||||
await askDelete(h);
|
||||
assert.match(textOf(h), /确定删除这份报告?/);
|
||||
await h.event(buttons(h, "取消")[0]);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(document.activeElement, h.elements().find(node => node.tagName === "SUMMARY"));
|
||||
await h.event(buttons(h, "删除")[0]);
|
||||
await h.event(buttons(h, "删除")[1]);
|
||||
assert.equal(document.activeElement, trigger(h));
|
||||
await askDelete(h);
|
||||
await h.event(buttons(h, "删除").at(-1)!);
|
||||
await h.idle();
|
||||
// 原值: h.container.text 含「报告没能删除,请稍后重试。」(行内确认条里一行红字,两次失败各一次)
|
||||
// 新值: 同一句是 error toast,两次失败各一条;确认条仍保留供重试
|
||||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
||||
assert.deepEqual(errors, ["报告没能删除,请稍后重试。"]);
|
||||
assert.match(h.container.text, /确定删除这份报告?/);
|
||||
assert.match(textOf(h), /确定删除这份报告?/);
|
||||
assert.equal(removed, 0); assert.equal(calls, 1);
|
||||
globalThis.fetch = async () => { throw new Error("offline"); };
|
||||
await h.event(buttons(h, "删除")[1]); await h.idle();
|
||||
// 原值:`buttons(h, "删除")[1]`,details 里的「删除」和确认按钮同时在,下标 1 是确认
|
||||
// 新值:确认按钮是当前文字为「删除」的最后一枚。菜单关掉后那一项不留在文档里
|
||||
// 原因:K5 用下拉菜单。第二次失败仍点确认,仍要再报一次错且不删行
|
||||
await h.event(buttons(h, "删除").at(-1)!); await h.idle();
|
||||
assert.deepEqual(errors, ["报告没能删除,请稍后重试。", "报告没能删除,请稍后重试。"]);
|
||||
assert.equal(removed, 0);
|
||||
} finally { toast.error = errorBefore; globalThis.fetch = fetchBefore; await h.close(); }
|
||||
@@ -58,10 +87,10 @@ test("row delete sends one same-origin DELETE despite repeated confirmation clic
|
||||
globalThis.fetch = async (url, options) => { assert.equal(url, `/api/reports/${row.id}`); assert.equal(options?.method, "DELETE"); assert.equal(options?.credentials, "same-origin"); calls += 1; return response.promise; };
|
||||
try {
|
||||
await h.render(<ReportRowDelete reportId={row.id} onDeleted={id => removed.push(id)} />);
|
||||
await h.event(buttons(h, "删除")[0]);
|
||||
const confirm = buttons(h, "删除")[1];
|
||||
await askDelete(h);
|
||||
const confirm = buttons(h, "删除").at(-1)!;
|
||||
await h.event(confirm); await h.event(confirm);
|
||||
assert.equal(calls, 1); assert.match(h.container.text, /删除中/);
|
||||
assert.equal(calls, 1); assert.match(textOf(h), /删除中/);
|
||||
response.resolve(new Response(null, { status: 204 })); await h.idle();
|
||||
assert.deepEqual(removed, [row.id]);
|
||||
} finally { globalThis.fetch = fetchBefore; await h.close(); }
|
||||
@@ -99,7 +128,7 @@ test("mounted report center removes deleted row and an older GET cannot resurrec
|
||||
const router = { bfcacheId: "test", push() {}, replace() {}, refresh() {}, prefetch() {}, back() {}, forward() {} } as AppRouterInstance;
|
||||
await h.render(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>); await h.idle();
|
||||
assert.equal(gets, 1); assert.match(h.container.text, /标准深度/);
|
||||
await h.event(buttons(h, "删除")[0]); await h.event(buttons(h, "删除")[1]); await h.idle();
|
||||
await askDelete(h); await h.event(buttons(h, "删除").at(-1)!); await h.idle();
|
||||
assert.doesNotMatch(h.container.text, /标准深度/); assert.equal(peekReportsPage(), null);
|
||||
stale.resolve(Response.json({ reports: [row] })); await h.idle();
|
||||
assert.doesNotMatch(h.container.text, /标准深度/); assert.equal(peekReportsPage(), null);
|
||||
|
||||
@@ -32,7 +32,11 @@ test("global error boundary replaces the root layout without external styling",
|
||||
assert.match(globalError, /<body/);
|
||||
assert.match(globalError, /<\/body>/);
|
||||
assert.match(globalError, /<\/html>/);
|
||||
assert.doesNotMatch(globalError, /^import /m);
|
||||
// 原值:global-error 不得出现任何 import
|
||||
// 新值:只允许 import { Button } from "@/components/ui/button"
|
||||
// 原因:K5 把崩溃页的两个按钮换成组件库。颜色仍写在本文件的内联样式里,不依赖全局样式表
|
||||
assert.equal(globalError.match(/^import /gm)?.length, 1);
|
||||
assert.match(globalError, /import \{ Button \} from "@\/components\/ui\/button";/);
|
||||
assert.doesNotMatch(globalError, /className="[a-z-]*(flex|text-|bg-|min-h-)/);
|
||||
assert.match(globalError, /minHeight: "44px"/);
|
||||
assert.match(globalError, /onClick=\{\(\) => reset\(\)\}/);
|
||||
|
||||
Reference in New Issue
Block a user