余白 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:
@@ -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>
|
||||
Reference in New Issue
Block a user