Files
blog/src/pages/books-preview.astro
T
wait4xandGLM-5.3 907f64472a feat(books): 书架改版三方案预览页(临时路由)
用户要求「书架页多做出几个方案让我直接查看网页供我选择」,故先并陈三案:
A 竖排色脊墙(书脊 writing-mode 竖排、情绪色即是视觉线索)
B 封面网格(封面为主角,无封面用色脊占位 + 首字,悬停浮起并显出简介)
C 抽书阅读(一排书脊,点开原生 details 抽书,显示封面/简介/入口)

同一份数据渲染三案(posts category=reading),页顶 sticky 锚点跳转。
用户选定后:把选中方案落进 BooksPage.astro 并删除本临时路由。

验证: 构建 exit 0;dist/books-preview 27KB;Playwright 实测三案均渲染
      (脊 5 / 卡 5 / 抽书行 5)、1280 与 390 均零横向溢出、破图 0

Spec: 2026-11-02_blog-5i-fonts-polish (T7 预览)

Co-authored-by: GLM-5.3
2026-11-03 11:41:10 +08:00

358 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
/**
* 书架改版方案预览(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);
};
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}/`,
}));
const years = [...new Set(items.map((i) => i.year))].sort((a, b) => b - a);
const withCover = items.filter((i) => i.cover).length;
---
<BaseLayout track="reading" title="书架方案预览 · wait4x" description="三个书架改版方案的并陈预览">
<!-- 临时预览路由:用户选定方案后连同本文件一起删除 -->
<h1 class="pv-title">书架改版:三个方案</h1>
<p class="pv-note">
共 {items.length} 本书,其中 {withCover} 本有封面图。三个方案用同一份数据渲染,
<strong>看完告诉我选哪个</strong>(回「A」「B」或「C」即可,也可以说「B 但标题字大一点」这类微调)。
</p>
<nav class="pv-switch" aria-label="方案跳转">
<a href="#va" class="pv-chip">A · 竖排色脊墙</a>
<a href="#vb" class="pv-chip">B · 封面网格</a>
<a href="#vc" class="pv-chip">C · 抽书阅读</a>
</nav>
<!-- ══════════ 方案 A:竖排色脊墙 ══════════ -->
<section id="va" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">A</span>
<h2>竖排色脊墙</h2>
<p class="pv-pros">像真书架:书脊竖排、颜色即情绪。<b>优点</b>最像"书架";<b>代价</b>无封面的书看不出内容,标题长会挤。</p>
</header>
{years.map((year) => (
<div class="va-year">
<p class="va-year-label mono">{year} · {items.filter((i) => i.year === year).length} 本</p>
<ul class="va-wall">
{items.filter((i) => i.year === year).map((b) => (
<li>
<a class="va-spine" href={b.href} style={`--mood:${b.mood}`} title={`${b.title}${b.author ? ' · ' + b.author : ''}`}>
<span class="va-spine-title">{b.title}</span>
{b.author && <span class="va-spine-author">{b.author}</span>}
</a>
</li>
))}
</ul>
</div>
))}
</section>
<!-- ══════════ 方案 B:封面网格 ══════════ -->
<section id="vb" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">B</span>
<h2>封面网格</h2>
<p class="pv-pros">以封面为主角,悬停浮起并显出简介。<b>优点</b>视觉最抓人、信息最全;<b>兜底</b>无封面的书用色脊占位,不出现空洞卡。</p>
</header>
{years.map((year) => (
<div class="vb-year">
<p class="vb-year-label mono">{year} · {items.filter((i) => i.year === year).length} 本</p>
<ul class="vb-grid">
{items.filter((i) => i.year === year).map((b) => (
<li>
<a class="vb-card" href={b.href} style={`--mood:${b.mood}`}>
<span class="vb-art">
{b.cover ? (
<Image src={b.cover} alt={`${b.title} 封面`} widths={[220, 440]} sizes="(min-width: 900px) 200px, 45vw" loading="lazy" />
) : (
<span class="vb-placeholder">
<i class="vb-stripe" aria-hidden="true" />
<span class="vb-initial">{b.title.slice(0, 1)}</span>
</span>
)}
</span>
<span class="vb-body">
<strong class="vb-title">{b.title}</strong>
{b.author && <span class="vb-author">{b.author}</span>}
<span class="vb-desc">{b.desc}</span>
</span>
</a>
</li>
))}
</ul>
</div>
))}
</section>
<!-- ══════════ 方案 C:抽书阅读 ══════════ -->
<section id="vc" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">C</span>
<h2>抽书阅读</h2>
<p class="pv-pros">一排书脊,点哪本抽哪本,展开看简介与入口。<b>优点</b>交互最有"抽书"感、竖向省空间;<b>代价</b>要点一下才看到内容(零 JS,原生 details)。</p>
</header>
<ul class="vc-list">
{items.map((b) => (
<li>
<details class="vc-book" style={`--mood:${b.mood}`}>
<summary class="vc-spine">
<span class="vc-bar" aria-hidden="true" />
<span class="vc-title">{b.title}</span>
{b.author && <span class="vc-author">{b.author}</span>}
<span class="vc-date mono">{b.year}</span>
</summary>
<div class="vc-body">
{b.cover && (
<span class="vc-cover">
<Image src={b.cover} alt={`${b.title} 封面`} widths={[120, 240]} sizes="120px" loading="lazy" />
</span>
)}
<div class="vc-info">
<p>{b.desc}</p>
<a class="vc-link" href={b.href}>读这篇书评 →</a>
</div>
</div>
</details>
</li>
))}
</ul>
</section>
</BaseLayout>
<style>
/* ── 预览页外壳(三个方案各自独立命名空间,互不串味) ── */
.pv-title { font-size: var(--text-h1); color: var(--ink-1); margin-top: var(--space-5); }
.pv-note { font-size: 14px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin-block: var(--space-2) var(--space-4); }
.pv-switch {
position: sticky;
top: 0;
z-index: 3;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-2) 0;
background: color-mix(in oklab, var(--bg) 92%, transparent);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
.pv-chip {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 12px;
transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease);
}
.pv-chip:hover { border-color: var(--accent); color: var(--ink-1); }
.pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; }
.pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); }
.pv-tag {
display: inline-block;
font-size: 11px;
color: var(--ink-4);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 5px;
}
.pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0 var(--space-1); }
.pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-4); }
.pv-pros b { color: var(--ink-2); font-weight: 600; }
/* ══════════ A:竖排色脊(书脊真实感) ══════════ */
.va-year + .va-year { margin-top: var(--space-4); }
.va-year-label,
.vb-year-label { font-size: 12px; color: var(--ink-4); letter-spacing: 0.06em; margin: 0 0 var(--space-2); }
.va-wall {
display: flex;
flex-wrap: wrap;
gap: 6px;
list-style: none;
padding: 0;
margin: 0;
align-items: flex-end;
}
.va-spine {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 44px;
height: 190px;
padding: 10px 6px;
border-radius: 3px 6px 6px 3px;
background: color-mix(in oklab, var(--mood) 16%, var(--bg-soft));
border: 1px solid color-mix(in oklab, var(--mood) 34%, var(--line));
border-left: 5px solid var(--mood);
transition: translate var(--dur) var(--ease), box-shadow var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.va-spine:hover {
translate: 0 -6px;
box-shadow: 0 10px 22px rgb(0 0 0 / 0.12);
}
.va-spine-title {
writing-mode: vertical-rl;
font-family: var(--font-serif);
font-size: 13px;
line-height: 1.15;
color: var(--ink-1);
max-height: 150px;
overflow: hidden;
letter-spacing: 0.02em;
}
.va-spine-author {
writing-mode: vertical-rl;
font-size: 10px;
color: var(--ink-4);
max-height: 48px;
overflow: hidden;
}
/* ══════════ B:封面网格 ══════════ */
.vb-year + .vb-year { margin-top: var(--space-5); }
.vb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-3);
list-style: none;
padding: 0;
margin: 0;
}
.vb-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--line);
border-radius: var(--radius-img);
transition: translate var(--dur) var(--ease), box-shadow var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.vb-card:hover {
translate: 0 -3px;
border-color: color-mix(in oklab, var(--mood) 45%, var(--line));
box-shadow: 0 10px 24px rgb(0 0 0 / 0.10);
}
.vb-art {
display: block;
aspect-ratio: 3 / 4;
border-radius: 4px;
overflow: hidden;
background: color-mix(in oklab, var(--mood) 10%, var(--bg-soft));
}
.vb-art :global(img) { width: 100%; height: 100%; object-fit: cover; display: block; }
.vb-placeholder {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.vb-stripe {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 12px;
background: var(--mood);
}
.vb-initial {
font-family: var(--font-serif);
font-size: 40px;
color: color-mix(in oklab, var(--mood) 62%, var(--ink-2));
opacity: 0.85;
}
.vb-body { display: flex; flex-direction: column; gap: 1px; }
.vb-title { font-family: var(--font-serif); font-size: 14.5px; color: var(--ink-1); line-height: 1.35; }
.vb-author { font-size: 11.5px; color: var(--ink-4); }
.vb-desc {
font-size: 12.5px;
color: var(--ink-3);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: 2px;
}
/* ══════════ C:抽书阅读 ══════════ */
.vc-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.vc-book { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.vc-spine {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 10px var(--space-3);
cursor: pointer;
list-style: none;
transition: background var(--dur) var(--ease);
}
.vc-spine::-webkit-details-marker { display: none; }
.vc-spine:hover { background: var(--bg-soft); }
.vc-bar { width: 6px; align-self: stretch; border-radius: 3px; background: var(--mood); flex: none; }
.vc-title { font-family: var(--font-serif); font-size: 15px; color: var(--ink-1); }
.vc-author { font-size: 12px; color: var(--ink-4); }
.vc-date { margin-left: auto; font-size: 11.5px; color: var(--ink-4); }
.vc-body {
display: flex;
gap: var(--space-3);
padding: var(--space-3);
border-top: 1px dashed var(--line);
background: var(--bg-soft);
}
.vc-cover { flex: none; width: 88px; border-radius: 4px; overflow: hidden; }
.vc-cover :global(img) { width: 100%; height: auto; display: block; }
.vc-info { min-width: 0; }
.vc-info p { font-size: 13.5px; line-height: 1.7; color: var(--ink-2); margin: 0 0 var(--space-2); }
.vc-link { font-size: 13px; color: var(--accent); }
@media (max-width: 560px) {
.va-spine { width: 38px; height: 160px; }
.vb-grid { grid-template-columns: repeat(auto-fill, minmax(126px, 1fr)); gap: var(--space-2); }
.vc-body { flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
.va-spine, .vb-card, .vc-spine { transition: none; }
}
</style>