377 lines
10 KiB
Vue
377 lines
10 KiB
Vue
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { publicApi } from '../api'
|
||
import { site } from '../site'
|
||
import { socialIcon } from '../socialIcons'
|
||
import { relativeDate, stripTags, thumbURL } from '../utils'
|
||
|
||
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
|
||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||
|
||
// 文章页目录(PostView 传入 headings;空数组时不渲染这张卡)。
|
||
// 高亮跟随滚动:视口上方最近的那个标题算「当前」。
|
||
const props = defineProps({
|
||
toc: { type: Array, default: () => [] }
|
||
})
|
||
const activeId = ref('')
|
||
let tocObserver = null
|
||
|
||
function watchToc() {
|
||
if (tocObserver) tocObserver.disconnect()
|
||
if (!props.toc.length) return
|
||
tocObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting) activeId.value = e.target.id
|
||
}
|
||
},
|
||
{ rootMargin: '0px 0px -65% 0px' }
|
||
)
|
||
nextTick(() => {
|
||
for (const t of props.toc) {
|
||
const el = document.getElementById(t.id)
|
||
if (el) tocObserver.observe(el)
|
||
}
|
||
})
|
||
}
|
||
watch(() => props.toc, watchToc, { deep: false })
|
||
onBeforeUnmount(() => tocObserver && tocObserver.disconnect())
|
||
|
||
// 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
|
||
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
|
||
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
|
||
|
||
// 社交 / 源码链接(后台「高级 → 社交链接」填写);url 必填,空的丢弃。
|
||
// 有图标的(按域名推导)渲染成单色图标,认不出的显示名称文字。
|
||
const social = computed(() => (site.social_links || []).filter((s) => s && s.url && s.url !== PROJECT_REPO))
|
||
|
||
const links = [
|
||
{ to: '/', label: '首页' },
|
||
{ to: '/archive', label: '归档' },
|
||
{ to: '/tags', label: '标签' },
|
||
{ to: '/projects', label: '作品' },
|
||
{ to: '/about', label: '关于' }
|
||
]
|
||
|
||
const latest = ref([])
|
||
const tags = ref([])
|
||
|
||
// 短文没有标题,侧栏拿正文开头当标识(flomo/Twitter 的做法),
|
||
// 纯图片短文显示占位;有配图时右侧带一张小缩略图。
|
||
const EXCERPT_LEN = 22
|
||
function excerpt(p) {
|
||
if (p.kind !== 'short') return p.title || '无题'
|
||
// 先剥 <a>:贴链接的短文开头是 URL 本身,摘出来没有信息量;
|
||
// 剥完没字了(纯链接贴)就退到链接卡片的标题
|
||
const noLinks = (p.content_html || '').replace(/<a\b[^>]*>[\s\S]*?<\/a>/gi, ' ')
|
||
const text = stripTags(noLinks).replace(/\s+/g, ' ').trim()
|
||
if (text) return text.length > EXCERPT_LEN ? text.slice(0, EXCERPT_LEN) + '…' : text
|
||
if (p.link_card && p.link_card.title) {
|
||
const t = p.link_card.title
|
||
return t.length > EXCERPT_LEN ? t.slice(0, EXCERPT_LEN) + '…' : t
|
||
}
|
||
return p.images && p.images.length ? '📷 图片' : '短文'
|
||
}
|
||
function thumbOf(p) {
|
||
return p.kind === 'short' && p.images && p.images.length ? thumbURL(p.images[0], 160) : ''
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (isVivid.value) return
|
||
try {
|
||
const [posts, tagData] = await Promise.all([publicApi.latest(), publicApi.tags()])
|
||
latest.value = posts.items || []
|
||
tags.value = (tagData.tags || []).slice(0, 12)
|
||
} catch (e) {
|
||
latest.value = []
|
||
tags.value = []
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<aside v-if="!isVivid" class="rail-right">
|
||
<section v-if="toc.length" class="card toc-card">
|
||
<p class="eyebrow">目录</p>
|
||
<ul class="toc-list">
|
||
<li v-for="t in toc" :key="t.id" :class="{ h3: t.level >= 3, on: activeId === t.id }">
|
||
<a :href="'#' + t.id" :class="{ on: activeId === t.id }">{{ t.text }}</a>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<p class="eyebrow">关于这里</p>
|
||
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
|
||
<RouterLink to="/about" class="more">了解更多 →</RouterLink>
|
||
</section>
|
||
|
||
<section v-if="latest.length" class="card">
|
||
<p class="eyebrow">最近更新</p>
|
||
<ul class="list">
|
||
<li v-for="p in latest" :key="p.id">
|
||
<RouterLink :to="`/post/${p.slug}`" class="row" :class="{ withThumb: thumbOf(p) }">
|
||
<span class="row-main">
|
||
<span class="title">
|
||
{{ excerpt(p) }}
|
||
<span v-if="p.kind === 'short'" class="badge">短</span>
|
||
</span>
|
||
<span class="date">{{ relativeDate(p.published_at) }}</span>
|
||
</span>
|
||
<img
|
||
v-if="thumbOf(p)"
|
||
class="thumb"
|
||
:src="thumbOf(p)"
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
</RouterLink>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section v-if="tags.length" class="card">
|
||
<p class="eyebrow">标签</p>
|
||
<div class="tags">
|
||
<RouterLink v-for="t in tags" :key="t.id" :to="`/tag/${t.slug}`" class="tag-chip">
|
||
{{ t.name }}<span class="count"> {{ t.count }}</span>
|
||
</RouterLink>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 订阅卡删掉了:RSS 入口在下方页脚小字里,一张卡片只为一个链接不值得 -->
|
||
|
||
<!-- 页脚信息(Twitter 式:右栏卡片下方的小字区)。通栏 ClassicFoot
|
||
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
|
||
所有宽度都不缺位。 -->
|
||
<footer class="rail-foot">
|
||
<div v-if="social.length" class="social-row">
|
||
<a
|
||
v-for="s in social"
|
||
:key="s.label + s.url"
|
||
class="social-icon"
|
||
:href="s.url"
|
||
:title="s.label"
|
||
:aria-label="s.label"
|
||
:target="s.url.startsWith('http') ? '_blank' : undefined"
|
||
:rel="s.url.startsWith('http') ? 'noopener' : undefined"
|
||
>
|
||
<svg v-if="socialIcon(s.url)" viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(s.url)" /></svg>
|
||
<span v-else>{{ s.label }}</span>
|
||
</a>
|
||
</div>
|
||
<nav class="foot-links" aria-label="页脚导航">
|
||
<template v-for="(l, i) in links" :key="l.to">
|
||
<span v-if="i" class="dot">·</span>
|
||
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
|
||
</template>
|
||
<span class="dot">·</span>
|
||
<a href="/rss.xml">RSS</a>
|
||
</nav>
|
||
<!-- 项目信息:固定的源码图标 + 版权,永远在 -->
|
||
<div class="foot-meta">
|
||
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(PROJECT_REPO)" /></svg>
|
||
</a>
|
||
<span class="copyright">
|
||
{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template>
|
||
</span>
|
||
</div>
|
||
</footer>
|
||
</aside>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.rail-right {
|
||
position: sticky;
|
||
top: 0;
|
||
align-self: start;
|
||
padding: 28px 0 40px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.card {
|
||
border: 1px solid var(--line);
|
||
border-radius: 4px;
|
||
background: var(--card);
|
||
padding: 16px 18px;
|
||
}
|
||
|
||
.bio {
|
||
margin: 8px 0 10px;
|
||
font-size: 13.5px;
|
||
line-height: 1.85;
|
||
color: var(--ink-soft);
|
||
}
|
||
|
||
.more {
|
||
font-size: 13px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* 文章目录:当前小节高亮,层级缩进 */
|
||
.toc-list {
|
||
list-style: none;
|
||
margin: 8px 0 0;
|
||
padding: 0;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.toc-list li {margin: 2px 0;
|
||
border-left: 2px solid transparent;
|
||
padding-left: 8px;}
|
||
.toc-list li.h3 {padding-left: 20px;
|
||
font-size: 12.5px;}
|
||
.toc-list li.on {border-left-color: var(--accent);}
|
||
.toc-list a {color: var(--muted);
|
||
text-decoration: none;}
|
||
.toc-list li.on a, .toc-list a:hover {color: var(--accent);}
|
||
|
||
.list {
|
||
list-style: none;
|
||
margin: 8px 0 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 0;
|
||
border-bottom: 1px dashed var(--line-soft);
|
||
}
|
||
|
||
.list li:last-child .row {
|
||
border-bottom: 0;
|
||
}
|
||
|
||
/* 文字列吃满剩余宽度,缩略图固定在右 */
|
||
.row-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.title {
|
||
display: block;
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.thumb {
|
||
flex: 0 0 auto;
|
||
width: 34px;
|
||
height: 34px;
|
||
object-fit: cover;
|
||
border-radius: 6px;
|
||
background: var(--card);
|
||
border: 1px solid var(--line-soft);
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
margin-left: 5px;
|
||
padding: 0 5px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 2px;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
vertical-align: 1px;
|
||
}
|
||
|
||
.date {
|
||
font-size: 12px;
|
||
color: var(--faint);
|
||
}
|
||
|
||
.tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.count {
|
||
color: var(--faint);
|
||
font-size: 11px;
|
||
}
|
||
|
||
/* 页脚小字区:无卡片边框,Twitter 式侧栏页脚。classic 的页脚信息只在这里
|
||
出现(通栏 ClassicFoot 已删):右栏在窄屏会堆到信息流下方,卡片之后
|
||
正好接页脚小字,所有宽度都不缺位。 */
|
||
.rail-foot {
|
||
padding: 4px 6px 0;
|
||
font-size: 12px;
|
||
line-height: 1.9;
|
||
color: var(--faint);
|
||
}
|
||
|
||
.foot-links a {
|
||
color: var(--faint);
|
||
transition: color 0.15s;
|
||
}
|
||
|
||
.foot-links a:hover {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.dot {
|
||
margin: 0 4px;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* 社交按钮:页脚第一排居中 */
|
||
.social-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.foot-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
/* 项目信息行和上面的社交账号排拉开一截:它们是两类信息 */
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.social-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 22px;
|
||
height: 22px;
|
||
padding: 0 3px;
|
||
border-radius: 6px;
|
||
color: var(--faint);
|
||
font-size: 12px;
|
||
transition: color 0.15s;
|
||
}
|
||
|
||
.social-icon:hover {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.social-icon svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
fill: currentColor;
|
||
}
|
||
|
||
.copyright {
|
||
color: var(--faint);
|
||
}
|
||
|
||
/* 窄屏:底部留白加高,右下角浮动主题钮压不到页脚小字 */
|
||
@media (max-width: 780px) {
|
||
.rail-right {
|
||
padding-bottom: 96px;
|
||
}
|
||
}
|
||
</style>
|