- internal/linkmeta:取正文第一个 http(s) 链接,解析 og: → twitter: → <title>/ description 逐级兜底,解 HTML 实体、相对图片补成绝对地址。按对外抓取设防: 只放行 http(s)、读满 512KB 即停、最多 3 次跳转、整体限时;SSRF 防护做在拨号 那一刻——解析出 IP 后筛掉回环/私网/链路本地/组播/CGNAT 再直连该 IP,堵住 DNS 重绑定窗口 - 字符集转换:按「HTTP 头 charset → <meta charset> → UTF-8」解码,gb2312 归一 成 gbk(超集,按声明解会漏字);认不出的字符名退回原始字节不报错。为此引入 golang.org/x/text(官方包,约 +1MB 二进制)。tidy 顺带把 aws-sdk 三个包从 indirect 修正为直接依赖——storage/r2.go 本来就直接用它们 - posts 加 link_card 列(JSON,沿用 images 的编解码);admin 保存短文时重算: 同链接沿用旧卡片不重复打远端,删链接或抓取失败则清空,长文不参与 - 前端 LinkCard 组件接两套 UI 的时间线与详情页,配色写成 vivid 变量优先、 classic 变量兜底,一份样式两边通用;外链图挂了自动隐藏不留空框 - 卡片用 div[role=link] 而非 <a>(vivid 整条短文包在 RouterLink 里,嵌套 <a> 非法),点击必须 preventDefault——只 stop 挡不住祖先 <a> 的默认激活行为
219 lines
8.0 KiB
Vue
219 lines
8.0 KiB
Vue
<script setup>
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { 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="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>
|