284 lines
7.3 KiB
Vue
284 lines
7.3 KiB
Vue
<script setup>
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import LeftNav from '../components/LeftNav.vue'
|
||
import RightRail from '../components/RightRail.vue'
|
||
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
|
||
import CommentSection from '../components/comments/CommentSection.vue'
|
||
import LinkCard from '../components/LinkCard.vue'
|
||
import { openLightbox } from '../lightbox'
|
||
import { publicApi } from '../api'
|
||
import { site, applyDocTitle } from '../site'
|
||
import { applyImageRatio, formatDate, minutesLabel, sanitizeHtml } from '../utils'
|
||
|
||
const route = useRoute()
|
||
const post = ref(null)
|
||
const loading = ref(true)
|
||
const error = ref('')
|
||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||
|
||
// 正文 h2 加 id,供 vivid 右栏目录;classic 不用但无害
|
||
function withHeadingIds(html) {
|
||
const doc = new DOMParser().parseFromString(html || '', 'text/html')
|
||
const heads = [...doc.querySelectorAll('h2')]
|
||
heads.forEach((h, i) => {
|
||
if (!h.id) h.id = 'h-' + i
|
||
})
|
||
return {
|
||
html: doc.body.innerHTML,
|
||
headings: heads.map((h) => ({ id: h.id, text: h.textContent || '' }))
|
||
}
|
||
}
|
||
|
||
const contentHtml = ref('')
|
||
const headings = ref([])
|
||
|
||
// 顶部阅读进度条(classic):滚动过正文才算数,短文太短不显示
|
||
const progress = ref(0)
|
||
function onScroll() {
|
||
const doc = document.documentElement
|
||
const total = doc.scrollHeight - window.innerHeight
|
||
progress.value = total > 300 ? Math.min(100, (window.scrollY / total) * 100) : 0
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
error.value = ''
|
||
post.value = null
|
||
contentHtml.value = ''
|
||
headings.value = []
|
||
try {
|
||
post.value = await publicApi.post(route.params.slug)
|
||
const prepared = withHeadingIds(post.value.content_html)
|
||
contentHtml.value = applyImageRatio(prepared.html)
|
||
headings.value = prepared.headings
|
||
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
|
||
} catch (e) {
|
||
error.value = e.message || '加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
load()
|
||
window.addEventListener('scroll', onScroll, { passive: true })
|
||
})
|
||
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
|
||
watch(() => route.params.slug, load)
|
||
|
||
const isShort = computed(() => post.value && post.value.kind === 'short')
|
||
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
|
||
</script>
|
||
|
||
<template>
|
||
<YohakuArticle
|
||
v-if="isVivid"
|
||
:post="post"
|
||
:headings="headings"
|
||
:content-html="contentHtml"
|
||
:neighbors="post?.neighbors || []"
|
||
:loading="loading"
|
||
:error="error"
|
||
/>
|
||
|
||
<div v-else class="shell">
|
||
<!-- 阅读进度:贴在视口顶部的细线 -->
|
||
<div class="reading-bar" :style="{ width: progress + '%' }" aria-hidden="true"></div>
|
||
<div class="layout">
|
||
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
|
||
补上,和 HomeView 的三栏一致。 -->
|
||
<LeftNav />
|
||
<main class="main">
|
||
<div v-if="loading" class="loading">载入中…</div>
|
||
|
||
<div v-else-if="error" class="empty">
|
||
{{ error }}
|
||
<p><RouterLink to="/">← 回到时间线</RouterLink></p>
|
||
</div>
|
||
|
||
<article v-else class="wrap" :class="{ short: isShort }">
|
||
<header class="head">
|
||
<div class="byline">
|
||
<div class="avatar">{{ initial }}</div>
|
||
<div class="who">
|
||
<span class="author">{{ site.author_name || 'ONE' }}</span>
|
||
<span class="sub">
|
||
<time :datetime="post.published_at">{{ formatDate(post.published_at) }}</time>
|
||
<span class="dot">·</span>
|
||
<span>{{ minutesLabel(post) }}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 短文不渲染大标题 -->
|
||
<h1 v-if="!isShort" class="title">{{ post.title }}</h1>
|
||
<p v-if="!isShort && post.summary" class="lede">{{ post.summary }}</p>
|
||
</header>
|
||
|
||
<div
|
||
class="prose"
|
||
:class="{ 'prose-short': isShort }"
|
||
v-html="sanitizeHtml(contentHtml)"
|
||
></div>
|
||
|
||
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
|
||
<div
|
||
v-if="isShort && post.images && post.images.length"
|
||
class="post-imgs"
|
||
:class="'n' + Math.min(post.images.length, 4)"
|
||
>
|
||
<img
|
||
v-for="(u, i) in post.images.slice(0, 4)"
|
||
:key="i"
|
||
:src="u"
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
@click="openLightbox(post.images, i)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
|
||
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
|
||
|
||
<footer class="foot">
|
||
<div v-if="post.tags && post.tags.length" class="tags">
|
||
<RouterLink
|
||
v-for="t in post.tags"
|
||
:key="t"
|
||
:to="`/tag/${encodeURIComponent(t)}`"
|
||
class="tag-chip"
|
||
>
|
||
{{ t }}
|
||
</RouterLink>
|
||
</div>
|
||
<RouterLink to="/" class="back">← 回到时间线</RouterLink>
|
||
</footer>
|
||
</article>
|
||
|
||
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
|
||
<CommentSection v-if="post" :post-id="post.id" />
|
||
</main>
|
||
<RightRail :toc="isShort ? [] : headings" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.main {
|
||
min-width: 0;
|
||
border-left: 1px solid var(--line);
|
||
border-right: 1px solid var(--line);
|
||
padding-bottom: 60px;
|
||
}
|
||
|
||
.wrap {
|
||
padding: 26px 26px 40px;
|
||
}
|
||
|
||
.head {
|
||
padding-bottom: 18px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.byline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.avatar {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-family: var(--serif);
|
||
font-size: 18px;
|
||
}
|
||
|
||
.who {
|
||
display: flex;
|
||
flex-direction: column;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.author {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.sub {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.dot {
|
||
margin: 0 5px;
|
||
color: var(--faint);
|
||
}
|
||
|
||
.title {
|
||
margin: 20px 0 0;
|
||
font-size: 32px;
|
||
line-height: 1.2;
|
||
/* 大号衬线标题需要负字距,越紧越像"排"出来的而不是"摆"出来的 */
|
||
letter-spacing: -0.015em;
|
||
}
|
||
|
||
.lede {
|
||
margin: 10px 0 0;
|
||
font-family: var(--serif);
|
||
font-size: 16.5px;
|
||
color: var(--ink-soft);
|
||
font-style: italic;
|
||
}
|
||
|
||
.foot {
|
||
margin-top: 32px;
|
||
padding-top: 18px;
|
||
border-top: 1px solid var(--line);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.back {
|
||
font-size: 13px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
@media (max-width: 780px) {
|
||
.main {
|
||
border: 0;
|
||
}
|
||
|
||
.wrap {
|
||
padding: 18px 4px 40px;
|
||
}
|
||
|
||
.title {
|
||
font-size: 27px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
/* 整站手机页边距是 12px(styles.css 的 .shell),这里把侧边距归零,
|
||
正文才和评论区落在同一条 12px 左缘上 */
|
||
.wrap {
|
||
padding: 18px 0 40px;
|
||
}
|
||
}
|
||
</style>
|