Files
ONE/frontend/src/ui/yohaku/YohakuArticle.vue
T
Sakurasan 661d157b6c 短文支持链接预览卡片:新增 linkmeta 抓取 + 字符集解码
- 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> 的默认激活行为
2026-09-28 17:13:20 +08:00

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 { 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>