# 界面规范 这份文档规定**界面长什么样、为什么**。它约束的是客户端的外观层,不涉及排版 (书的排版见 `architecture.md` §8 —— 那里的原则相反:对书不动手)。 之所以需要一份成文规范:这个客户端的界面层不是一个人一次写完的,而是被反复 改出来的。每一次改动单独看都合理,合起来就成了「这个控件 0.35rem、那个 0.6rem, 这个图标是 Unicode 字符、那个是 emoji」。规范的作用是让「再改一处」这件事有一个 可以对照的基准。 --- ## 1. 图标 图标统一使用 `lucide-react` 的 SVG,`web/src/ui/icon.tsx` 集中维护名称映射,线宽 1.8,尺寸 1em。 界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位或 emoji。 装饰图标标记 aria-hidden;没有文字的按钮必须有中文 aria-label。原字体文件与生成工具仅供历史兼容,不再加载到阅读界面。 ## 2. 设计令牌 间距、字号、动效、焦点环、圆角、安全区定义在 `web/src/styles/reader.css` 的 `:root` 里; 颜色令牌在 `web/src/styles/theme-tokens.css`(§6)。**界面层不应出现字面量**。 ### 2.1 间距:四档 ``` --space-1 0.35rem 控件内部的缝隙 --space-2 0.6rem 控件内部的内边距 --space-3 0.9rem 区块之间的间距 --space-4 1.5rem 页面级区块的内边距 ``` **只有四档**,这是重点。八档的尺度读起来像精确,用起来像建议:一旦 `1.75rem` 和 `2rem` 同时存在,一半样式表用一个、一半用另一个,而任何单条规则里 都看不出矛盾。四档强制回答「这是哪一档」——这个问题有答案,而「再大一点点」 没有。 ### 2.2 字号:按角色 ``` --text-display 1.375rem 页面大标题、空状态标题 --text-title 1.05rem 屏幕标题、卡片标题;也是输入框的下限(见下) --text-body 0.95rem 阅读侧正文与按钮 --text-ui 0.875rem 应用界面的列表行、设置行(影音正文) --text-label 0.82rem 字段标签、区块标题、次要按钮 --text-meta 0.75rem 标题下方的元信息:数量、时间、来源 --text-micro 0.625rem 封面与图标上的角标,别处不用 ``` 按角色命名而非按尺寸,因为尺寸会变、角色不会:`--text-meta` 永远是「标题下面 那行」,无论今天是 0.72rem 还是明天 0.75rem。(它确实从 0.72rem 改成了 0.75rem: 影音界面 200 多处 12px 的元信息因此原样落档,阅读侧的元信息只大了半个像素。) 迁移前四档只约束了阅读侧,影音样式表里 8–24px 每个整数都用过。现在 24px 以下的字号 必须用令牌,`web/test/design-tokens.test.ts` 会拒绝字面量;24px 以上是装饰性大字 (播放页的首字、大数字),保留字面量。 **输入框至少 16px**:更小的字号会让 iOS Safari 在聚焦时放大视口。需要这条下限的输入框 用 `--text-title`,不要退到 `--text-body`。 ### 2.3 动效:一个时长、一条曲线 ``` --motion-duration 0.18s --motion-ease cubic-bezier(0.32, 0.72, 0, 1) ``` 一个时长,一条曲线,所有过渡都用它们。两个时长和一个时长的问题是同一个: 各自单独看都合理,成对出现就是错的。 `prefers-reduced-motion` 通过**把 `--motion-duration` 折叠成 0.001s** 来满足, 而不是给每条动画各加一个媒体查询。后者的失败是可预期的:下一个新增的动画就是 唯一没加的那条。折叠令牌之后,所有使用令牌的规则(现在是全部)自动被覆盖, 而硬编码自己时长的规则成为**显式的例外**而不是隐式的漏洞。 ### 2.4 焦点环 ``` --focus-ring 2px solid color-mix(in srgb, var(--reader-accent) 70%, transparent) --focus-offset 2px ``` **这是本次改动前完全缺失的一层。** 样式表里搜索框有 `:focus-visible` 规则, 而**所有按钮都没有**——书架上一整屏控件,用键盘 Tab 走一遍看不到焦点在哪。 现在是一条规则覆盖一组选择器(`.button`、`.icon-button`、`.chip`……), 而不是每个组件各写一份。新增控件把它加进列表即可,这就是全部工作。 用 `:focus-visible` 而非 `:focus`:鼠标点击按钮不应留下焦点环,键盘 Tab 必须 始终显示。 ### 2.5 悬停 统一包在 `@media (hover: hover) and (pointer: fine)` 里。不加这层的话,触屏上 每次点击都会让最后点的控件停在「悬停」外观上——手指抬起就进入 hover,看起来 像坏了,而不是像有反馈。 `test/design-tokens.test.ts` 检查 `src/` 下每条带 `:hover` 的规则都在这样一个块里。 `.x:hover, .x:focus-visible` 这类混写要拆开:`:focus-visible` 留在外面(键盘在触屏 设备上也要有反馈),`:hover` 那一半紧跟其后放进守卫块,层叠顺序不变。 ### 2.6 圆角:六档 ``` --radius-xs 4px 角标、标签、进度条 --radius-sm 8px 小控件、菜单项、缩略图、封面 --radius-md 10px 按钮、输入框、菜单、列表卡片(= --reader-radius) --radius-lg 14px 面板、页面级卡片、浮层 --radius-xl 20px 底部 Sheet、大卡片 --radius-pill 999px 胶囊:芯片、开关、圆角等于半高的按钮 ``` 圆形用 `50%`,直角用 `0`,这两个不算档位。迁移前样式表里有 2/3/4/5/6/7/8/9/10/11/12/13/14/15/18/19/20/22/24px 外加十几个 rem 值,同一类控件在相邻两屏差 1–2px,单独看都对,放在一起就是两套界面。 **胶囊要写 `pill`,不要写半高。** `height: 26px; border-radius: 13px` 在高度改成 28px 的那天 就不再是胶囊了;`--radius-pill` 与高度无关。 ### 2.7 无封面的占位 没有封面的书、影片按标题取一个色相(`web/src/ui/cover-tint.ts`,12 档、每档 30°), 写进 `--cover-hue`,样式表把 `hsl(var(--cover-hue) 55% 55%)` 以 10–20% 混进当前主题的 surface。这是「组件不写死颜色」的唯一例外,理由与封面图片相同:它是内容的颜色,不是界面的颜色。 - **按标题取,不按位置取**:同一本书在书架、书库、搜索里颜色一致,刷新也不变。 - **只混少量**:占位上的文字仍用 ink/muted,对比度由主题保证;深色模式下自然变成暗底微染。 - 不加色相之前,一屏全是同一个色块,只能逐个读字找书。 --- ## 3. 布局 ### 3.1 安全区 四个 `--safe-*` 令牌在 `:root` 定义一次,各处只引用不直接读 `env()`。 两个理由,第二个才是关键:一个边一个名字,意味着手机、浏览器和 Android 壳不会 各自对「屏幕从哪开始」有略微不同的理解;而且**未定义的自定义属性会让整个 `calc()` 失效并静默丢弃声明**——`padding: calc(0.9rem + var(--safe-left))` 在变量未设置时不是「0.9rem 的内边距」,而是**完全没有内边距**。 **谁贴着哪条边,谁加哪个安全区。** 只有真正贴着屏幕边的那一层加,而且只加一次: - **顶边**:贴顶的页头加 `--safe-top`(书架/书库 `.shelf-head`、`.collection-header`, 书源 `.sources-header`,手机上的影音页 `.media-screen`,首页与我的,阅读器顶栏与页眉)。 - **底边**:手机底栏 `.app-nav`、Sheet 的底部、阅读器底栏与页脚加 `--safe-bottom`; 浮在底部的东西(Notyf、`.pwa-update` 更新条、迷你播放器)放在这些栏的**上方**, 偏移量与 `styles/notifications.css` 一致,不能盖住导航或阅读器底栏。 - **左右边(横屏刘海)**:宽度 ≥701px 时手机横屏也会进入侧栏布局。侧栏贴左边, 由它加 `--safe-left`;内容列贴右边,由 `.app-shell-body` 用 `padding-right` 统一 承担右侧安全区,并在内部把 `--safe-left` / `--safe-right` 置为 0——内容列里各条带 自己也会加安全区,不置 0 就会被缩进两次,或被一个它根本不挨着的刘海缩进。 阅读器不在外壳里,它的顶栏和底栏左右各加 `--safe-left` / `--safe-right`。 - 一条规则同时写四个方向的 `padding` 时,核对左右没有写反(`.shelf-head` 曾经把 `--safe-right` 加在左边)。 iOS 独立窗口用 `apple-mobile-web-app-status-bar-style: default`:页面从状态栏下方开始, 竖屏 `--safe-top` 为 0。`black-translucent` 会让状态栏文字恒为白色,在默认浅色主题上 看不见,所以不用;上面的顶边规则仍然保留,给会报告顶部安全区的浏览器和方向用。 Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView 里四个值都是 0。 验证安全区不用真机:Chromium 的 CDP `Emulation.setSafeAreaInsetsOverride` 可以给 `env(safe-area-inset-*)` 填任意值。改动贴边的条时,至少用竖屏(上 59 / 下 34)和 横屏(左右 59 / 下 21)各看一遍。 ### 3.2 一列,封顶 首页、书架、影音、我的共用一条内容列:内容宽度封顶 `--page-measure`(`--collection-measure` 80rem 减去书架带内边距,约 1251px), 两侧留白 `--page-gutter`(手机 16px,侧栏模式 32px),切换标签页时内容边缘不动。宽屏加的是留白而不是更多列。 1600px 窗口排十一个封面、每个书名十二个字,比手机上的三列**更难扫**—— 这与「多出来的宽度是为了好读」正好相反。 ### 3.3 触控目标 图标按钮 2.5rem(40px),页脚里 2.1rem(34px)。翻页器的页码是 2rem(32px): 它是读者会反复瞄准的控件,而 0.72rem 的数字装在一个 16px 的盒子里是需要用眼睛 而不是用拇指去点的。页脚那两个翻页按钮是唯一 小于 40px 的交互目标,理由是它们最常用、且紧邻一个同样大的兄弟控件;四个 2.5rem 的控件加一个章节标签会超出 360px 手机的宽度。 `SwitchRow` 用按钮而不是复选框:整行是 44px 的命中区,复选框会把命中区缩到 16px 的方块上,拇指按不中。 底线是 28px,由 `npm run ui:review` 逐屏量过(§7):小于这个数的控件在 拇指上不是「偏小」,而是需要瞄准。 在 `(pointer: coarse)` 设备上,外观必须小的控件用透明的 `::before` 把命中区扩到 约 44px,外观不变:筛选/排序芯片 `.chip`(29px 高;水平方向只扩一点,免得相邻芯片 互相抢点击)、封面角上的 `⋯`(`.book-more`)和「加入书架」(`.book-shelve`)。 手指能点到的输入框,字号至少 16px(`--text-title`)。iOS 不理会 `maximum-scale=1`,聚焦小于 16px 的输入框会把整页放大;侧栏的全局搜索在 `(pointer: coarse)` 下因此改用 `--text-title`。 ### 3.4 两个列表屏:书架与书库 **这是两个屏幕,不是两个标签页。** 书架回答「我能读什么」,书库回答「服务端有什么」, 两者的差别不是同一份数据的两个视图——一条是 `books` 表(带用户自己的上下架状态), 一条是挂载目录本身。所以它们是两个路由: ``` #/shelf[?page=<页>&sort=…&q=…] 书架 #/library[?path=<路径>&page=<页>&q=<搜索>] 书库浏览页(见 §3.4.1) #/library/files[?path=<路径>&page=<页>] 书库文件管理页 ``` 做成同一个屏幕的两个标签只会更省事一点点,而会让三件事同时变坏: - **返回键。** 标签切换会进同一段历史轨迹,于是 Back 变成「退回上一个标签」而不是 离开这个屏幕。书库的面包屑本来是「走进去的文件夹用面包屑退,不用返回键退」, 加一个标签进去,Back 就得同时扮演两个角色。 - **链接。** `#/library?path=科幻` 是能给别人的;「书架的第 3 页」也是(`#/shelf?page=3`)。 - **一屏一个标题。** 参照稿里两个屏幕各有自己的标题与右侧入口,而标签栏要占一行。 #### 3.4.1 书库自己是两个屏幕(不是一个屏幕的两个页面) **这一版改了上一版。** 上一版把书库做成「一个屏幕、两个页面、一个页签」,理由是 「标签进了历史就是 Back 走标签而不是离开书库」。那一版被 #40 退回来了,理由更大: > 书库预览页和管理页拆开,两者列表显示逻辑不一样。预览页需要显示搜索框,类似一个 > 商城的布局。而书库管理页面只有管理员才能看到使用。 一个页面装不下两个列表,因为它们**根本不是同一份数据的两种画法**: | | 浏览(读者那一半) | 文件管理(管理员那一半) | | --- | --- | --- | | 问的问题 | 「这里有什么可读的」 | 「我的书在哪,让我挪一下」 | | 端点 | `/api/v1/books?path=…&search=…` | `/api/v1/library/browse?path=…` | | 一条是什么 | 一本书(书名、封面、作者) | 一个文件(文件名、大小、扫描标记) | | 布局 | 商城:搜索框在最上,封面网格铺满窗口 | 清点:面包屑 + 文件行 + 批量条 | | 一页多少 | 60 本 | 200 条 | | 谁能用 | 任何登录用户(成员可在这里上传书籍) | 只有管理员 | 做成一个屏幕的两个页面会同时逼出三句假话: - **一个头部服务两个列表。** 浏览页的头部要有搜索框,文件页的头部要有面包屑、 上传和新建;共用之后两边都得让步,让步的产物就是上一版里那个既不像商城也不像 文件管理器的头部。 - **一条可达性规则。** `#/library?path=科幻` 打开的是封面,而文件管理器离它只有一次 「页签」点击——也就是**每一个能发给别人的链接**都通向一个可以删书的页面。 - **一个页码。** 两边分页的列表和每页条数都不同,切换必须重置页码,于是「第 4 页」 按哪个页面在看会是两个意思。 **所以是两个路由:** ``` #/library[?path=<路径>&page=<页>&q=<搜索>] #/library/files[?path=<路径>&page=<页>] ``` (`#/library/books` 是浏览页的旧别名。) - **`#/library?path=科幻`** 是浏览页。没有 `files` 段,因为这是**读者**要的页面,也是 别人发给你的那个链接。搜索是 `?q=`:它是自由文本、可以没有、并且和路径是**两个**参数 ——这样文件夹里叫什么都无所谓。 - **`#/library/files?path=科幻`** 是文件管理页。只有管理员进得来,而这是**服务端**的规矩 (每一行都写磁盘);客户端这一层是「不给他建那个页面」,见 §3.4.4。 两个路由的代价是:切换从「页签」变成**导航**,于是 Back 回到你刚才在的那一半。 对两个不同受众的屏幕来说,这正是对的读法,也是这次报告要的。 预览页要的是「这个文件夹里的书」,而那是 `/api/v1/books` 的形状,所以它问的是 `/api/v1/books?path=<目录>&search=<词>`。做一个新端点、或者把 `/library/browse` 的行 渲染成网格,都不行:browse 的一条是**文件名**,不是书名,而且没有封面——那会是一屏 「把文件名画在书名位置」的格子。`path` 是对 `rel_path` 的**前缀匹配**, 带上分隔符(`dir/%` 而不是 `%dir%`),否则「筛选科幻」会把「筛选科学」一起拖进来; `%` 和 `_` 是合法文件名字符也是 `LIKE` 通配符,所以两个都转义。 ##### 3.4.1.1 浏览页是商城式的 「类似一个商城的布局」落到三条具体规则上: - **搜索框在最上面**,是这一页自己的,筛的是**这个文件夹**(面包屑是范围)。 它和文件夹、页码一起进 URL,所以一次筛选是一个链接。 - **网格不被 `--shelf-measure` 截住。** 书架要截,因为书架是读者自己那一柜、 按他选的大小铺,一屏十一张封面是墙不是列表。书库是服务端的目录,大小是磁盘的 事实,宽屏上读者想要的是**多看见一些**而不是多两边的留白。所以这一页是 `auto-fill` 无上限,并且封面尺寸阶梯比书架高一档——去掉上限以后用书架的阶梯 会立刻退化成一屏 130px 的缩略图,正是阶梯存在的理由。 - **每张封面一个动作。** 「加入书架」只画在**不在书架上**的那几本上(§3.4.1 末段、§3.5.2)。 ##### 3.4.1.2 搜索框两边的留白 「书架的搜索框两边需要增加 margin」是一条经验,但它说的是一件比「加个 margin」更 具体的事:字段原来**和封面列同宽**,于是胶囊的 1px 边框、以及清除按钮那个 40px 的 圆,都落在封面的同一条竖线上——在那个距离上,边框读起来像封面的一部分,清除按钮 看起来像在第一张封面**里面**。 所以: - 留白是一个 token(`--shelf-search-inset`),**两边相等**,否则字段会整体偏移, 那是「什么都没做错但看着歪」的另一种成因; - 它声明在 `:root` 而不是 `.shelf` 上。这一条是修出来的:token 原来挂在 `.shelf` 上,而书库的搜索框**不在 `.shelf` 里**,`padding-inline: var(--undefined)` 就是 `padding-inline: 0`——留白静默地不出现,跑了一整轮评审 才被「字段左 0px / 右 0px」这句测量抓住; - 输入框`type=search` 自带的那颗清除按钮要关掉(`::-webkit-search-cancel-button`), 否则一屏上有两个清除控件,其中一个是浏览器自己的蓝色小叉。 **「加入书架」画在 `shelfState: 'off'` 的书上,而 `shelfState` 来自**这一页自己的书单**。** 这一条修了两遍,第一遍修错了地方,值得记下来——因为错的那个方案在当时看起来更省事: - **第一版**从**文件列表**上取状态,并且要求「这本书的文件得在**本页**上」。理由是端点收 的是**路径**,而 `BookDto` 没有路径,两者只能靠**去掉扩展名的文件名**去对。 于是文件夹第二页上、文件在第一页的那本书**报不出路径**,卡片就不画控件。 - **第二版**(#40 退回第一版之后)把整个 join 拿掉了:写入带的是 **`book.id`** (§3.5.2),所以「文件在哪一页」不再影响任何事,状态也从**书单**上取。于是三态变成两态: | 情况 | 画什么 | | --- | --- | | `shelfState: 'on'` | 不画(已经在了) | | `shelfState: 'off'` | 画「加入书架」——这一页存在的理由 | | `shelfState` 缺省(老服务端没有这个字段) | 不画(按钮会写出一个没人能预测的结果) | **这一页问的是索引,不是读者自己的书架**(`/api/v1/books?scope=library&path=…`): 问书架的话,返回的每一本都已经在书架上,`shelfState` 恒为 `'on'`—— **这一页存在的那个动作永远画不出来**。这是第一版真正的病根,比 join 更早: 一个商城只陈列你已经买过的东西,就没有东西可以加。 网格上面那行(「这一页有 N 本还不在书架上」)数的是**同一份集合**, 而不是整个文件夹:一行说 12 本、下面只有一个角标,是一句和自己画的东西矛盾的话。 `ui:review` 里有一条断言同时盯两边:角标数 `> 0` 且 `< 卡片数`—— 恒为 0 和恒为全部,都是这一页坏掉的样子。 #### 3.4.2 书架的排序:默认「最近阅读」,键是阅读时间 书架有四个排序:**最近阅读**(默认)、最近入库、书名、作者。 两个「最近」问的是**两个不同的时间**——读者上一次读这本书,和这本书进这个库—— 而这个区别曾经被一个「最近更新」盖住了,它问的是第三个时间(文件改动时间)。 三件事因此成立: - **默认是「最近阅读」。** 打开应用要回答的问题是「我上次读到哪」,而它一天问四十次; 「我刚放了什么进来」是扫描完那一次才问的问题。把后者当默认, 等于每次打开都先回答一个没人问的问题。 - **「最近入库」留着。** 它差点被删掉,而留着是对的:它就是上面那个「扫描完才问」的问题, 而那时候「书名/作者」都不是读者想要的那个顺序。 - **存储里的 `updated` 向前折叠成 `added`。** 名字换过(它本来就是「什么时候进的库」), 而一个存在本地、界面里选不到、服务端也认不出的排序值,会变成「设置被忽略了」。 排序键的**归属**不一样,这是最要紧的一条: - `added` / `title` / `author` **是服务端的键**,所以它们由服务端排序、服务端分页。 - `recent` **服务端没有**:阅读时间在 progress 行上,不在书行上。所以它由客户端排, 而且**必须先读一整段书架**——把一页六十本按阅读时间排完画在一个写着「第 1 / 34 页」 的翻页器下面,是对另外三十三页的谎。所以这个排序一次读多页(上限 500 本), 在本地排完,再切出自己那一页;超出的尾巴在副标题里**说出来**, 而不是静默少掉几本。 同一个 `SortKey` 还决定了「离线时怎么画」:服务端排序离线时画的是镜像里的全部, 客户端排序离线时画的是同一段切片——两边的页数才对得上同一个翻页器。 #### 3.4.4 文件管理页只有管理员能用 文件管理页的每一个控件都写服务端的磁盘:上传、重命名、移动、删除。所以它只给管理员, 而且是**两层**,不是一层: - **入口。** 书架的「书库」按钮打开的是**浏览页**——那是一个读者的按钮。文件管理页 的入口只在浏览页的头部,写着「文件管理」,是一个**带文字的**控件而不是一个裸图标: 读者需要知道按下去会离开书、进入一个能删东西的页面。 - **路由。** 判定在路由组件 `LibraryFilesPage`,不在按钮上。URL 是**用户输入**——它从书签、从聊天 消息、从手写的链接过来——藏起一个按钮只是对读者的**提示**,路由组件里那一句才是 **检查**。不是管理员却打开 `#/library/files?path=科幻`,落到的是**同一个文件夹的浏览页**, 而不是一个错误页:书库是允许他去的地方,链接说的那个文件夹就是链接真正要说的东西。 - **成员的上传。** 成员没有文件管理入口;业务设置「书库 → 允许成员上传书籍」开启时(默认开启, 客户端从 `GET /library/upload` 的 `allowed` 得知),浏览页头部的位置换成「上传书籍」:传到当前 文件夹,重名保留两份(`rename`)。服务端只收书籍格式、不解包压缩包、不允许覆盖 (见 [API](api.md) `POST /library/upload`),跳过的文件在结果提示里说明原因。上传只**新增** 书籍,不改动已有文件,所以它不算文件管理。设置关闭时头部没有这个按钮,空书库的提示改为 「书库的书由管理员添加。」 角色在进应用时从 `GET /api/v1/auth/me` 读一次,**没读到之前一律按非管理员算**: 「还没问」不能是一段「成员也能打开删文件那个页面」的时间窗。后端仍然是每一行的 权限判定(那个页面上的每个端点都会拒绝),客户端这一层只保证不给他建那个页面。 ### 3.5 翻页,而不是无限滚动 两个列表都**分页**,页是 URL 的一部分,翻页器在列表下方。 书架原来是在滚到底时追加一批(`loadMore`)。它有两个问题,而且都不是「体验」问题: 列表没有读者能看见的长度,位置也回不去——关掉再打开从第一屏开始,因为「我在第三屏」 只存在于一个滚动偏移里,而重绘会把它扔掉。两千本书是一个**地方**,地方需要有位置。 `#/shelf?page=3` 就是一个位置:可以返回、可以前进、可以收藏、可以发给别人。 页数是**服务端算的**,页是**服务端切的**: - 书架:`/api/v1/books?page=N&pageSize=60`。60 恰好等于原来一次追加的量, 所以一个本来就不卡的书库仍然一个请求铺满一屏。 - 书库:`/api/v1/library/browse?path=…&page=N`,一页 200 条。书库面对的是**文件系统**, 一个放四千张扫描件的 `漫画` 目录是真实存在的,把四千行发给浏览器丢掉 3940 行 是那个让屏幕显得坏掉的请求。上界 `BROWSE_PAGE_SIZE_MAX` 保证调用方换不来那个请求。 **页码超界在响应之后夹,不在请求之前猜。** 唯一知道一共几页的是 `total`,而它随响应回来—— 读者在第三页时书库缩到两页,问第九页的答案是一个空列表而不是错误;不夹的话读者得到 一张空网格、上面一个页码,没有任何东西告诉他列表其实没问题。夹完再问一次最后一页, 并且把 URL 一起改写,否则 Back 会回到那个被赶走的空页。 **翻页是导航,不是本地改数字。** 屏幕不自己改页码:它把页码写进 URL 查询参数 (`setSearchParams`),再从 URL 读回页码去请求。这一趟往返就是页能活过 Back、Forward 和刷新的全部原因。 翻页器也照此画:`aria-current="page"` 标当前页,两端禁用而不隐藏,页码窗口是 「首、尾、以及当前页的邻居」——50 页在手机上是一排需要**读**而不是**按**的按钮。 ### 3.5.1 上架与下架:书库必须说得出「这本书为什么不在书架上」 书库页存在的理由就是回答「我的书去哪了」,所以它必须能说出「这本书不在你的书架上」。 这件事曾经没有,那就是 #40 报的「书库的书不再自动加入书架,需要手动加入」的第一层: - **`add` 没有清掉 `hidden`。** 服务端把 `add` 写成了 `INSERT OR IGNORE`, 而被拿掉过的书**已经有**一行 `user_books`(`remove` 为了可逆写下的 `hidden = 1`), 于是 `OR IGNORE` 跳过它、书继续不在书架上。读者选中它、点「加入书架」、 什么都没发生。可逆性只存在于表结构里而不存在于端点里,就不是可逆性。 现在 `add`/`unhide` 是 upsert:不存在则插入,存在则清标志。 - **书库不显示书架状态。** `BrowseEntry.shelfState` 是按调用者解析的字段 (§3.4:书库说的是「**你**的书架有没有」)。只在 `"off"` 的行上画徽章: 给一整个目录的书都画「在书架上」是用一列噪音盖住唯一一行例外, 而例外正是读者来这里找的东西。 **这个徽章留下了,那个按钮没有**(#40 的下一轮:「书库管理页面去掉 从书架拿掉 按钮」)。 区别不是妥协,是这两件事不同的归属: - **徽章是「答案」**,而这个屏幕的职责就是回答「我的书去哪了」。一条不说自己不在 书架上的行,就是这个屏幕什么都没说。 - **按钮是「写入」,而它写错了地方。** 文件页上其他每一个控件(上传、重命名、移动、 删除、改资料)改的都是**挂载盘上的东西**;`下架` 改的是一行 `user_books`, 一个文件都不动。它因此是这个菜单里唯一一个**后果和这一页的承诺对不上**的条目, 而它挨着的就是「删除」。它还答错了人:这一半是**管理员**的(§3.4.4 路由按角色守), 而书架是每个读者自己的。 所以文件页现在只写盘。两个方向都搬到了**读者站的那一屏**上(§3.5.2 / §3.4.1)。 ### 3.5.2 两个方向都要在**读者站的那一屏**上,而且**按书 id 写** 书架是每个读者每天打开四十次的那一屏,所以方向落在**封面自己**上 (#40:「补全完善书架的加入、移除逻辑」): - **每张封面右上角一个「⋯」**,点开是这一本书的动作:标记想读/在读/已读、清除标记, 以及「从书架拿掉」(§10)。即使只有「从书架拿掉」一条它也该是菜单:入口才是「这个方向存在」 的证据,而在每张封面上直接画一个「从书架拿掉」按钮,是在读者想打开书的那根手指下面放一个破坏性控件。 - **它是图标按钮,不是滑动、也不是长按。** 书架是纵向滚动的,横向手势跟它所在的 滚动抢事件;长按没有任何可供性——文件管理页的行也是同一条理由,用的是 `⋯`。 - **先问,再写。** `remove` 是两个方向里唯一拿走东西的那个,也是读者最容易误读 后果的那个。确认框里写的是**读者真正在问的那句话**:磁盘上的文件一个都不会动。 - **写入之后重读这一页,而不是本地删掉那张卡片。** 列表是这次动作的答案, 而只有服务端知道书架现在有什么;本地删卡会让计数、页码和离线镜像三方各说一套。 消息 `从书架拿掉 N 本` 在刷新之后通过全局浮动提示显示,目录摘要继续展示书籍数量。 **写入带的是 `book.id`,不是路径。** 这是 #40 报的「从书架移除时,找不到「xxx」 在磁盘上的路径」的根因,而它值得单独说,因为错的那个方案看上去更直接: 卡片手里只有一本 `Book`(一个 id、以及扫描器记下来的东西),而端点当时收的是 **路径**。两者之间隔着一个 join,客户端是这么补的:列出书库根目录的第一页, 拿**书名**去和文件名对(去掉扩展名)。这个猜测有两处必然失手: - **书名不等于文件名**,而这是常态而不是例外——扫描器会从文件里读出书名。 报告里的那本书叫「半小时漫画宇宙大爆炸(半小时读完138亿年宇宙史,一口气搞懂大爆炸、 奇点、黑洞、引力波、暗物质……混子哥陈磊新作!)」,磁盘上那个文件不叫这个, 于是什么都没有匹配上。读者被告知这本书**找不到**——在他要求把它从**自己的书架**上 拿掉之后,而那是文件叫什么名字完全无关的一件事。 - **列表是分页的**,所以根目录第一页之外的书、以及任何子目录里的书, 在那次查找里根本不存在。 映射其实**已经在服务端**了(`book_files.book_id` → `rel_path`),客户端一直在猜一件 服务端已经知道的事。现在写入带 id,服务端按 id 解析路径:书名和文件的位置都不再参与。 端点两种命名**只收一种**(`paths` 与 `bookIds` 同时给会被拒)——两份命名说的是同一个写, 同时带上就是「客户端自己不知道该说哪一个」,合并还会让同一本书被写两次、被数两次。 **浏览页(§3.4.1)用的是同一对方向**,也带 id,所以同一本书从书架还是从书库下架, 发出去的是同一句话。文件页两样都不用了(§3.5.1)。 ### 3.5.3 卡片是容器,按下去的地方是里面的「打开」按钮 一张卡片一旦带上第二个动作,它就不能再是一个 `