diff --git a/docs/管理台.md b/docs/管理台.md index 9fe1c42..6b2b7c9 100644 --- a/docs/管理台.md +++ b/docs/管理台.md @@ -30,7 +30,8 @@ 语义边界(刻意如此,别当 bug 报): - **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱 -- 多篇同时置顶时,置顶组内部仍按日期倒序 +- **支持多篇/多本同时置顶**:置顶组内部仍按日期倒序;书架页(/books)里每个置顶的书评都会排在前面并**默认展开** +- 书架页用「↑ + 横线」的回到顶部图标表示置顶(不写文字),与右侧展开箭头形状不同,不会混读 - 关掉开关立即恢复时间序,没有"冷却期" ## 瞬间(5h 起与文章同级) diff --git a/scripts/build-perf/content-lint.mjs b/scripts/build-perf/content-lint.mjs index a424580..cbeb089 100644 --- a/scripts/build-perf/content-lint.mjs +++ b/scripts/build-perf/content-lint.mjs @@ -16,10 +16,6 @@ if (MODE === 'skip') { const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障) const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对 -// 完全不参与检查的产物(连站内链接也不查):仅用于一次性预览路由。 -// 书架方案预览页没有 en 对应页,而 Header 的语言切换会自动生成 /en/… 链接, -// 会误报「断链 + 配对缺」;该路由在用户选定方案后即删除。 -const SKIP_ALL = new Set(['/books-preview/index.html']); function walk(dir, out = []) { for (const e of readdirSync(dir)) { @@ -54,7 +50,6 @@ const warns = []; for (const f of files) { const rel = f.slice(DIST.length) || '/'; - if (SKIP_ALL.has(rel)) continue; const html = readFileSync(f, 'utf8'); // 站内 :仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过 diff --git a/src/components/pages/BooksPage.astro b/src/components/pages/BooksPage.astro index eb77ced..c141edb 100644 --- a/src/components/pages/BooksPage.astro +++ b/src/components/pages/BooksPage.astro @@ -1,74 +1,128 @@ --- -// 书评索引画廊(/books):色脊排版卡 × 年份分组规整网格 -// 预研方案 B:色脊天然安全(mood 深浅跨亮暗对比度风险回避),零 JS +/** + * 书评索引(/books):方案 C「抽书阅读」(用户 2026-11-03 拍板定稿)。 + * + * 为什么是这一版:五个方案里它是唯一在「书少、封面少」时也不露怯的—— + * 一排放得下、长书名不裁、没有 4+1 孤儿位;点一行抽出封面与简介,靠原生 + * details 零 JS。 + * + * 置顶契约(与文章同一套,见 src/lib/post-order.ts): + * - frontmatter `pinned: true` → 排在最前(置顶优先 → 日期倒序) + * - **支持多本置顶**:所有 pinned 书都默认展开,不限于一本 + * - 置顶标记用「↑ + 横线」的回到顶部图标,不写文字(用户要求); + * 展开/收起是另一个形状的 chevron,避免两个箭头混读 + */ import { getCollection } from 'astro:content'; +import { Image } from 'astro:assets'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { t } from '../../i18n/ui'; import type { Locale } from '../../lib/locale'; import { sortPosts } from '../../lib/post-order'; -import { Image } from 'astro:assets'; -interface Props { locale?: Locale } +interface Props { + locale?: Locale; +} const { locale = 'zh' } = Astro.props; const books = sortPosts( - await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')), + await getCollection( + 'posts', + ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'), + ), ); -// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口): -// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字 +/** + * description 回退契约:缺省取正文第一个非空段落(去 markdown 记号)前 60 字。 + * 与历史实现保持一致——书架卡不能出现空白简介。 + */ const firstPara = (body: string | undefined): string => { - const line = (body ?? '').split(/\n\s*\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? ''; - return line.replace(/[*_>`\[\]]/g, '').slice(0, 60); + const line = + (body ?? '') + .split(/\n\s*\n/) + .map((l) => l.trim()) + .find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? ''; + return line.replace(/[*_>`[\]]/g, '').slice(0, 60); }; -const years = [...new Set(books.map((b) => b.data.pubDate.getFullYear()))].sort((a, b) => b - a); + +const items = books.map((b) => ({ + id: b.id, + title: b.data.title, + author: b.data.bookAuthor ?? '', + desc: b.data.description ?? firstPara(b.body), + cover: b.data.cover, + mood: b.data.mood ?? 'var(--ink-4)', + pinned: b.data.pinned === true, + href: locale === 'en' ? `/en/archives/${b.id}/` : `/archives/${b.id}/`, +})); +const pinnedCount = items.filter((i) => i.pinned).length; const L = { - shelf: locale === 'zh' ? '书架' : 'Books', - count: (n: number) => (locale === 'zh' ? `${n} 本` : `${n} books`), + shelf: t('books_title', locale), + count: locale === 'zh' ? `${items.length} 本` : `${items.length} books`, + note: + locale === 'zh' + ? '点一行展开封面与简介 · 置顶的书默认展开' + : 'Tap a row for the cover and blurb · pinned books start open', + read: locale === 'zh' ? '读这篇书评 →' : 'Read the review →', + pinned: t('pinned_label', locale), + expand: locale === 'zh' ? '展开封面与简介' : 'Expand cover and blurb', }; --- - -

{L.shelf}{L.count(books.length)}

- {years.map((year, yi) => ( -
- - {year} - · {books.filter((b) => b.data.pubDate.getFullYear() === year).length} {locale === 'zh' ? '本' : 'books'} - -
- ))} + + )} +
+ {b.desc &&

{b.desc}

} + {L.read} +
+ + + + ))} + diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 7cd1675..0509761 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -113,6 +113,7 @@ const dict = { // ===== F 车道:访问量小字(Artalk pv,组件内独立计数) ===== pv_viewed: { zh: '人看过', en: 'views' }, pinned_label: { zh: '置顶', en: 'Pinned' }, + books_title: { zh: '书架', en: 'Books' }, comment_count: { zh: '条评论', en: 'comments' }, moment_comments: { zh: '评论', en: 'Comments' }, lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' }, diff --git a/src/pages/books-preview.astro b/src/pages/books-preview.astro deleted file mode 100644 index 55e47d4..0000000 --- a/src/pages/books-preview.astro +++ /dev/null @@ -1,546 +0,0 @@ ---- -/** - * 书架改版方案预览(Phase 5i T7):一页并陈三个方案,供用户直接点选。 - * - * 为什么做成预览页而不是直接改 /books:书架是纯审美决策,改错要回滚一轮; - * 一页三案 + 页顶锚点,用户在手机或桌面点开就能比,选定后我再把选中的方案 - * 落到 BooksPage.astro,然后删掉本预览路由(含 sitemap 排除与 noindex)。 - * - * 三方案共同的数据契约与 BooksPage 一致:posts 里 category=reading 的条目, - * 封面走 Astro image()(cover 缺省时用 mood 色脊兜底),description 缺省取正文首段。 - */ -import { getCollection } from 'astro:content'; -import { Image } from 'astro:assets'; -import BaseLayout from '../layouts/BaseLayout.astro'; -import { sortPosts } from '../lib/post-order'; - -const books = sortPosts( - await getCollection( - 'posts', - ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'), - ), -); - -/** description 缺省回退正文首段(去 markdown 记号、截 60 字),与 BooksPage 同契约 */ -const firstPara = (body: string | undefined): string => { - const line = - (body ?? '') - .split(/\n\s*\n/) - .map((l) => l.trim()) - .find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? ''; - return line.replace(/[*_>`[\]]/g, '').slice(0, 60); -}; - -/** 竖排书脊的高度有限:中文书名超过 ~11 字就必然溢出,用确定性截断代替裁切 */ -const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n)}…` : t); -/** - * 置顶书目(用户 2026-11-03 的要求:「C 方案可以默认展开置顶的书目」)。 - * 真实来源是 frontmatter 的 `pinned: true`(与文章置顶同一字段、同一排序契约)。 - * 当前还没有书被置顶,预览里用最新一本演示该效果,并在页面上明示这是演示。 - */ -const pinnedBook = books.find((b) => b.data.pinned === true) ?? books[0]; -const pinnedId = pinnedBook?.id ?? ''; -const pinnedIsDemo = !books.some((b) => b.data.pinned === true); - -const items = books.map((b) => ({ - id: b.id, - title: b.data.title, - author: b.data.bookAuthor ?? '', - desc: b.data.description ?? firstPara(b.body), - cover: b.data.cover, - mood: b.data.mood ?? 'var(--ink-4)', - year: b.data.pubDate.getFullYear(), - href: `/archives/${b.id}/`, - pinned: b.id === pinnedId, - spineTitle: clip(b.data.title, 11), - spineAuthor: clip(b.data.bookAuthor ?? '', 4), - short: clip(b.data.title, 3), -})); -const years = [...new Set(items.map((i) => i.year))].sort((a, b) => b - a); - - -const withCover = items.filter((i) => i.cover).length; ---- - - - -

书架改版:三个方案

-

- 共 {items.length} 本书,其中 {withCover} 本有封面图。三个方案用同一份数据渲染, - 看完告诉我选哪个(回「A」「B」「C」或「D」即可,也可以说「B 但标题字大一点」这类微调)。 - {pinnedIsDemo && ( - <> -
- D 与 C 的「置顶默认展开」用最新一本做演示——真实使用时由该书的 - `pinned: true` 决定(后台书评编辑页有开关)。 - - )} -

- - - - -
-
- A -

竖排色脊墙

-

像真书架:书脊竖排、颜色即情绪。优点最像"书架";代价无封面的书看不出内容,标题长会挤。

-
- - {years.map((year) => ( - - ))} -
- - -
-
- B -

封面网格

-

以封面为主角,悬停浮起并显出简介。优点视觉最抓人、信息最全;兜底无封面的书用色脊占位,不出现空洞卡。

-
- - {years.map((year) => ( -
-

{year} · {items.filter((i) => i.year === year).length} 本

-