Files
blog/src/pages/archives/[...slug].astro
T
wait4xandGLM-5.3 2bfe2ef3db feat(5f): 代码块窗条+书评纸卡(T5/T6 车道收编,视觉复核 PASS)
- T5:≥4 行块挂 .code-win 窗条(JS 数行标类+伪元素渲染:语言
  标签取 shiki pre[data-language]+灰阶三点 #30363d 投影法一点化
  三点,弃红黄绿;条 #161b22 恒深区亮暗同判);单行/2行裸块;
  csge 文 4/18 判定边界实测准确;scoped 够不着 Content 内 pre
  坑用 :global 解
- T6:书评正文圆角纸卡(亮 #f4f4f2/暗 #201d18 暖棕夜灯,零新
  变量吃 T7 已入 tokens;1px hairline 四边抑制长文竖线感;标题
  区外置结构未动)
- 视觉复核(glm-5.3-flash:max 像素级):T5 四项全 PASS(三点实
  采样无红黄绿、条/点/体 github-dark 同族灰、与块顶圆角对齐);
  T6 双态 PASS;暗态窗条未入截图框为残留观察项(恒定 CSS 推定
  成立,判词 /tmp/beauty-impl/verdict.md)
- tasks.md T5 verify 落点修正(src/pages/archives,车道如实报
  告);E2E ? 浮层偶发竞态 2/2 复跑绿

车道:op-artalk(自验 5/5+2/2);收编验收:窗条 4/18+data-lang+
纸卡双态 computed+视觉双卡 PASS

验证: E2E 34/34 PASS(E2E_BASE=dist 口);窗条/纸卡 computed 与
      像素采样全对

Spec: 2026-10-26_blog-5f

Co-authored-by: GLM-5.3
2026-11-01 09:31:22 +08:00

342 lines
11 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.
---
import type { CollectionEntry } from 'astro:content';
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArtalkComments from '../../components/ArtalkComments.astro';
import { trackOf } from '../../content.config';
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
export async function getStaticPaths() {
// 路径用全量(hidden 样张也要出静态页);prev/next 导航滤 hidden
// (真实文章的上下篇不链向样张——op-artalk 车道连带提醒的实现)
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const nav = all.filter(({ data }) => !data.tags.includes('hidden'));
return all.map((entry) => {
const i = nav.findIndex((e) => e.id === entry.id);
return {
params: { slug: entry.id },
props: {
entry,
prev: i >= 0 ? (nav[i + 1] ?? null) : null,
next: i > 0 ? (nav[i - 1] ?? null) : null,
},
};
});
}
interface Props {
entry: CollectionEntry<'posts'>;
prev: CollectionEntry<'posts'> | null;
next: CollectionEntry<'posts'> | null;
}
const { entry, prev, next } = Astro.props;
const { Content, headings } = await render(entry);
const isReading = entry.data.category === 'reading';
const date = entry.data.pubDate.toISOString().slice(0, 10);
// 阅读时长:正文汉字数 / 400 向上取整(最低 1 分钟)
const hanzi = entry.body?.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const minutes = Math.max(1, Math.ceil(hanzi / 400));
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
<BaseLayout title={`${entry.data.title} · wait4x`} track={isReading ? 'reading' : 'tech'}>
{isReading ? (
<article class="post post-reading">
<header class="post-head">
<p class="post-cat">Reading · {trackOf(entry.data.category)}</p>
<h1 class="post-title">{entry.data.title}</h1>
<p class="post-meta">
<i
class="emo"
style={`background:${entry.data.mood ?? 'var(--mood)'}`}
title="情绪色"
aria-hidden="true"
/>
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
</header>
<div class="prose-reading">
<Content />
</div>
</article>
) : (
<>
<div class="read-progress" id="read-progress" aria-hidden="true"></div>
<article class="post post-tech">
<header class="post-head">
<p class="post-cat">Tech · {trackOf(entry.data.category)}</p>
<h1 class="post-title">{entry.data.title}</h1>
<p class="post-meta">
{entry.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<span aria-hidden="true">·</span>
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
</header>
<div class="tech-grid">
<div class="prose-tech">
<Content />
</div>
{toc.length > 0 && (
<aside class="toc" aria-label="目录">
<p class="toc-h">目录</p>
{toc.map((h) => (
<a class={h.depth === 3 ? 'toc-sub' : undefined} href={`#${h.slug}`}>{h.text}</a>
))}
</aside>
)}
</div>
</article>
</>
)}
<nav class="pn" aria-label="上一篇下一篇">
{prev ? (
<a class="pn-item" href={`/archives/${prev.id}/`}>
<span>← 上一篇</span>
<strong>{prev.data.title}</strong>
</a>
) : (
<span class="pn-item" aria-hidden="true"></span>
)}
{next ? (
<a class="pn-item right" href={`/archives/${next.id}/`}>
<span>下一篇 →</span>
<strong>{next.data.title}</strong>
</a>
) : (
<span class="pn-item right" aria-hidden="true"></span>
)}
</nav>
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
</BaseLayout>
<script>
// 阅读进度条(技术轨):ClientRouter 下模块脚本只执行一次,
// 单例滚动监听 + astro:page-load 重绘,条不在当前页时零开销;
// prefers-reduced-motion 时整条不初始化(CSS 层同步隐藏)
const paint = () => {
const bar = document.getElementById('read-progress');
if (!bar) return;
const el = document.documentElement;
const total = el.scrollHeight - el.clientHeight;
bar.style.transform = `scaleX(${total > 0 ? el.scrollTop / total : 0})`;
};
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
addEventListener('scroll', paint, { passive: true });
document.addEventListener('astro:page-load', paint);
}
// 代码块窗条标记(P 卡 §6-3):纯 CSS 无法按内容行数条件挂伪元素,
// 此处数行加类;语言标签取 shiki 的 language-* 类名写入 data-lang 供 CSS attr() 渲染
const markCodeWins = () => {
document.querySelectorAll('.prose-tech pre:not(.code-win)').forEach((pre) => {
const code = pre.querySelector('code');
if (!code) return;
const lines = (code.textContent ?? '').split('\n').length;
if (lines >= 4) {
// astro shiki 语言落 pre[data-language](code 类名为空)
const lang = pre.dataset.language ?? /language-([\w-]+)/.exec(code.className)?.[1] ?? 'text';
pre.dataset.lang = lang;
pre.classList.add('code-win');
}
});
};
document.addEventListener('astro:page-load', markCodeWins);
markCodeWins();
</script>
<style>
/* 版心放行:技术轨带 TOC 时加宽到 measure+200(丙路线 §3;BaseLayout 默认 720) */
@media (min-width: 1120px) {
:global(main#main:has(.post-tech)) {
max-width: calc(var(--measure) + 200px);
}
}
.read-progress {
position: fixed;
top: 0;
left: 0;
z-index: 20;
height: 2px;
width: 100%;
background: var(--accent);
transform: scaleX(0);
transform-origin: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.read-progress { display: none; }
}
.post { margin-top: var(--space-5); }
.post-cat {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-4);
}
.post-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-2);
text-wrap: balance;
}
.post-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
font-size: 14px;
color: var(--ink-3);
}
.emo {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
flex: none;
}
.tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: var(--bg-soft);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
/* 书评轨:Content 渲染的 blockquote 打不了 .quote-block 类,此处镜像同一引文形状 */
.prose-reading { margin-top: var(--space-4); }
.prose-reading :global(blockquote) {
border-left: 2px solid var(--paper-line);
padding-left: 1.35em;
margin-block: 1.2em;
color: color-mix(in oklab, var(--paper-ink) 72%, transparent);
}
/* 技术轨:TOC 侧栏仅 ≥1120 常驻,窄屏隐藏 */
.tech-grid { margin-top: var(--space-4); }
.toc { display: none; }
@media (min-width: 1120px) {
.tech-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 176px;
gap: var(--space-5);
align-items: start;
}
.toc {
display: block;
position: sticky;
top: 80px;
max-height: calc(100dvh - 120px);
overflow: auto;
font-size: 13px;
padding-left: var(--space-3);
border-left: 1px solid var(--line);
}
}
.toc-h {
font-weight: 600;
color: var(--ink-1);
margin-bottom: var(--space-2);
}
.toc a {
display: block;
padding: var(--space-1) 0;
color: var(--ink-3);
transition: color var(--dur) var(--ease);
}
.toc a:hover { color: var(--accent); }
.toc .toc-sub { padding-left: var(--space-3); }
.prose-tech :global(h2),
.prose-tech :global(h3),
.prose-reading :global(h2),
.prose-reading :global(h3) {
scroll-margin-top: 80px;
}
.pn {
display: flex;
justify-content: space-between;
gap: var(--space-4);
margin-top: var(--space-5);
}
.pn-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: var(--space-1) var(--space-2);
}
.pn-item.right {
text-align: right;
align-items: flex-end;
}
.pn-item span { font-size: 12px; color: var(--ink-4); }
.pn-item strong {
font-size: 15px;
font-weight: 600;
color: var(--ink-1);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--dur) var(--ease);
}
.pn-item:hover strong { color: var(--accent); }
#artalk-container { margin-top: var(--space-5); }
/* ---- T5 代码块窗条(08 裁定 §6-3):≥4 行挂窗条,单行块维持现状裸块 ---- */
.prose-tech :global(pre.code-win) {
position: relative;
padding-top: calc(var(--space-3) + 30px);
}
.prose-tech :global(pre.code-win::before) {
content: attr(data-lang);
position: absolute;
inset: 0 0 auto 0;
height: calc(var(--space-3) + 30px - 1px);
display: flex;
align-items: flex-end;
padding: 0 14px 8px 66px;
background: #161b22; /* code-bg 上一档:恒深区亮暗同判(§6-3 暗色收益最大) */
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px 8px 0 0;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: #6e7681;
text-transform: lowercase;
}
/* 灰阶三点(§6-3 裁定弃红黄绿):terminal 页同款点色,伪元素本体一点+投影两点 */
.prose-tech :global(pre.code-win::after) {
content: '';
position: absolute;
top: 10px;
left: 14px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #30363d;
box-shadow: 18px 0 0 #30363d, 36px 0 0 #30363d;
}
/* ---- T6 书评纸卡(08 裁定 §6-2):通栏 hairline 升圆角纸卡 ----
--paper 三态已入 tokens(亮米白/暗暖棕夜灯),零新变量;7000px 长文
左右边框恒为 1px hairline 防两根竖线感(§6-2 落地注意) */
.prose-reading {
border: 1px solid var(--paper-line);
border-radius: 12px;
padding: var(--space-5) clamp(20px, 4vw, 32px);
}
</style>