Files
ONE/frontend/src/ui/yohaku/YohakuArticle.vue
T

219 lines
8.0 KiB
Vue
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.
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { applyImageRatio, sanitizeHtml } from '../../utils'
import { openLightbox } from '../../lightbox'
import CommentSection from '../../components/comments/CommentSection.vue'
import LinkCard from '../../components/LinkCard.vue'
// 余白文章页。对应设计稿 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" :class="{ 'prose-note': isShort }" v-html="applyImageRatio(sanitizeHtml(props.contentHtml))" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && props.post.images && props.post.images.length"
class="post-imgs"
:class="'n' + Math.min(props.post.images.length, 4)"
>
<img
v-for="(u, i) in props.post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(props.post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && props.post.link_card" :card="props.post.link_card" />
<div class="end">
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
</div>
</article>
<!-- 评论在 .paper 外面:评论接口挂了也不该把文章一起带下去 -->
<CommentSection :post-id="props.post.id" />
</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>