余白 UI:按设计稿模块化还原 + 站主自定义 CSS + 详情 neighbors + 静态缓存头

前端(新增 ui/yohaku/,各视图按 ui_id=vivid 分支渲染;classic 与后台不受影响)
- 令牌层:设计稿数值原样落地,28 个 --v-* 为站主接口(自定义 CSS 可覆盖,
  放 @layer 让出优先级);另加防线,中和 styles.css 裸选择器
  (h1 衬线 / body 行高 / 后台 .stat/.toolbar/.chip 卡片)漏进余白元素
- 顶栏(滚动收缩态)/ 阅读进度(顶部 2px + READ n%)/ 页脚
- 首页:hero(统计条)/ 筛选(类型分段 + 标签 chips + 搜索)/
  长文卡片与短文纸条 / 分页 / 作品展示区(3 件 + 查看所有,参照 diygod.cc);
  面板栅格改 1.6fr 1fr 1fr 且高度自适应内容(标签云明显大一圈)
- 文章页:三栏「纸」—— 左时间线(前后各两篇)/ 中纸张 / 右目录与阅读进度;
  短文详情不显示标题(h1 视觉隐藏保留无障碍)
- 全部规则收敛在 html[data-ui='vivid'] 前缀下;无 !important

后端
- GET /api/posts/:slug 增 neighbors(前后各两篇,详情页侧栏时间线用)
- SPA 缓存头:index.html no-cache,assets/ 带 hash 长期 immutable
  (此前无缓存头,浏览器会一直拿旧构建)

其他
- 站主自定义 CSS 管线(customCss.js + ui/sections.js,按分区注入,仅 vivid)
- 删除旧 vivid 覆盖层实现(VividNav/Hero/Footer/Timeline/Toc、ui/vivid.css)
- 设计稿原型附于仓库根 index.html;作品表清掉外链 diygod.cc 的示例封面,
  改用本地占位图(public/covers/)
This commit is contained in:
Sakurasan
2026-09-23 15:37:42 +08:00
parent 3238175ef0
commit fbda293072
46 changed files with 4266 additions and 35 deletions
+193
View File
@@ -0,0 +1,193 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
// 中 main : back + article.paper(元信息 / 标题 / 导语 / 正文 / 标签)
// 右 side-r : 目录(正文 h2)+ 阅读进度
// 数据与消毒都由 PostView 负责(contentHtml 已注入 h2 id),这里只做呈现与交互。
const props = defineProps({
post: { type: Object, default: null },
headings: { type: Array, default: () => [] },
contentHtml: { type: String, default: '' },
neighbors: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
})
const fmtDate = (iso) => {
const d = new Date(iso)
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const nf = new Intl.NumberFormat('zh-CN')
const plain = (html) => {
const el = document.createElement('div')
el.innerHTML = html || ''
return (el.textContent || '').trim()
}
const isShort = computed(() => props.post?.kind === 'short')
const title = computed(() =>
isShort.value ? plain(props.post?.content_html).slice(0, 40) + '…' : props.post?.title || ''
)
/* 左栏时间线:后端已按时间线顺序给好(最旧在前、最新在后),
* 找到当前篇,按相对位置打标签 : 与设计稿的 更早/上一篇/当前/下一篇/更新 一致。 */
const tl = computed(() => {
const nb = props.neighbors || []
const idx = nb.findIndex((x) => x.slug === props.post?.slug)
if (idx < 0) return []
const label = (d) => (d === 0 ? '当前' : d === -1 ? '下一篇' : d === -2 ? '更新' : d === 1 ? '上一篇' : '更早')
return nb.map((p, i) => ({
label: label(i - idx),
now: i === idx,
date: fmtDate(p.published_at),
text: p.kind === 'long' ? p.title : plain(p.content_html).slice(0, 20) + '…',
slug: p.slug
}))
})
/* ---------- 阅读进度 + 目录高亮 + 右栏 meter ---------- */
const pbar = ref(null)
const ppct = ref(null)
const meterFill = ref(null)
const meterNum = ref(null)
const tocEl = ref(null)
let ticking = false
function onScroll() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
ticking = false
const h = document.documentElement.scrollHeight - window.innerHeight
const p = h > 0 ? Math.min(100, Math.max(0, (window.scrollY / h) * 100)) : 0
if (pbar.value) pbar.value.querySelector('i').style.width = p.toFixed(1) + '%'
if (ppct.value) ppct.value.querySelector('b').textContent = String(Math.round(p))
if (meterFill.value) meterFill.value.style.width = p.toFixed(1) + '%'
if (meterNum.value) meterNum.value.textContent = Math.round(p) + '%'
// 目录高亮:视口 120px 以上最后一个 h2
let active = null
document.querySelectorAll('.prose h2').forEach((h) => {
if (h.getBoundingClientRect().top < 120) active = h
})
if (tocEl.value) {
tocEl.value.querySelectorAll('a').forEach((a) => {
a.classList.toggle('on', !!active && a.getAttribute('href') === '#' + active.id)
})
}
})
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
})
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
/* 弹簧滚动:TOC 点击用(设计稿 springScrollTo 的同款手感) */
function springScrollTo(y) {
const start = window.scrollY
const dist = y - start
if (Math.abs(dist) < 2) return
const dur = Math.min(700, Math.max(350, Math.abs(dist) * 0.4))
const t0 = performance.now()
const overshoot = 1.15
function frame(t) {
const p = Math.min(1, (t - t0) / dur)
const e = 1 - Math.pow(1 - p, 3)
const wobble = p < 1 ? Math.sin(p * Math.PI) * overshoot * 0.06 * (1 - p) : 0
window.scrollTo(0, start + dist * (e + wobble))
if (p < 1) requestAnimationFrame(frame)
else window.scrollTo(0, y)
}
requestAnimationFrame(frame)
}
function onTocClick(e) {
const a = e.target.closest('a')
if (!a) return
const id = a.getAttribute('href')
if (!id || !id.startsWith('#h-')) return
e.preventDefault()
const el = document.getElementById(id.slice(1))
if (el) springScrollTo(el.getBoundingClientRect().top + window.scrollY - 80)
}
</script>
<template>
<!-- 顶部阅读进度:只在这类长页出现 -->
<div class="progress" ref="pbar"><i /></div>
<div class="progress-pct">READ <b>0</b>%</div>
<div v-if="props.loading" class="wrap"><div class="empty"><h3>载入中…</h3></div></div>
<div v-else-if="props.error" class="wrap">
<div class="empty"><h3>出错了</h3><p>{{ props.error }}</p><a class="chip on" href="/">回时间线</a></div>
</div>
<div v-else-if="!props.post" class="wrap">
<div class="empty"><h3>没有这篇</h3><p>链接可能过期了。</p><a class="chip on" href="/">回时间线</a></div>
</div>
<div v-else class="article-layout">
<aside class="side-l">
<div class="tl">
<div
v-for="x in tl"
:key="x.slug + x.label"
class="tl-item"
:class="{ now: x.now }"
>
<b>{{ x.label }}</b>
<time>{{ x.date }}</time>
<div>{{ x.text }}</div>
</div>
</div>
</aside>
<main>
<a class="back" href="/">← 返回时间线</a>
<article class="paper">
<div class="meta">
<span class="badge" :class="props.post.kind">{{ props.post.kind.toUpperCase() }}</span>
<time :datetime="props.post.published_at">{{ fmtDate(props.post.published_at) }}</time>
<span>· 约 {{ props.post.reading_minutes }} 分钟</span>
<span>· {{ nf.format(props.post.content_len || 0) }} 字</span>
</div>
<template v-if="!isShort">
<h1 class="serif">{{ props.post.title }}</h1>
<p v-if="props.post.summary" class="dek">{{ props.post.summary }}</p>
</template>
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
<h1 v-if="isShort" class="sr-only">{{ title }}</h1>
<div class="prose" v-html="sanitizeHtml(props.contentHtml)" />
<div class="end">
<span class="lbl">标签</span>
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
</div>
</article>
</main>
<aside class="side-r">
<nav class="toc" ref="tocEl" aria-label="本文目录">
<h4>目录</h4>
<ol>
<li v-for="h in props.headings" :key="h.id">
<a :href="`#${h.id}`" @click="onTocClick">{{ h.text }}</a>
</li>
</ol>
<ol v-if="!props.headings.length"><li><a>(本文无小标题)</a></li></ol>
</nav>
<div class="read-meter">
<div class="bar"><i ref="meterFill" /></div>
<div class="num"><span>阅读进度</span><b ref="meterNum">0%</b></div>
</div>
</aside>
</div>
</template>