Files
ONE/frontend/src/views/PostView.vue
T

284 lines
7.3 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, 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>