# 引言
多出去看看,见的多了,就知道该折腾啥了~
这次盯上的,是文章右侧那块平时不怎么起眼的 目录。
折腾博客主题迁移的时候,看到 Fumadocs 的文章目录:左边一根细线,遇到子标题会拐个弯,当前阅读位置对应的那一段还会亮起来。
好看,想要,安排 🤣
不过真开始做才发现,这东西不是给目录加一个 border-left 就完事了。线会随着阅读位置 游走,同屏几个标题可以同时高亮,目录展开和收起的时候,线还得跟着变。
于是乎,又有了这篇折腾记录。
本文右侧目录就是效果展示,往下滚动即可体验;窄屏需要先打开主题侧栏。
# 需求整理
# 先把效果看明白
第一眼看过去,很容易把那根线理解成 阅读进度条:读到哪儿,就从头亮到哪儿。
我第一版还真就是这么做的,然后对着参考页面一滚,发现不对劲。
它表达的是 当前视口中的标题范围,不是已经读过多少:
- 一个标题出现在屏幕里,对应目录项就参与高亮。
- 同屏能看到多个标题,它们可以一起亮。
- 标题离开屏幕后,不会因为“曾经读过”就一直亮着。
- 如果屏幕里全是正文、没有标题,则需要一个兜底位置。
所以高亮线的 起点和终点 都会动,而不是只有终点往下增长。
参考实现的标题观察逻辑可以看 Fumadocs 的 toc.tsx。本文记录的是这次 ShokaX 适配的行为,后续上游实现可能会变化。
# 好看的同时,原来的功能不能丢
曲线只是表现形式,目录该有的功能还是得有:
1.、2.1.这些层级编号保留。- 点击目录仍然跳到对应章节。
- 高亮颜色跟随主题,不单独写死一套蓝色。
- 长标题能够换行,日间和夜间模式使用主题已有颜色变量。
- 子目录按需展开,不能长文章一打开就铺满整个侧栏。
另外,这个效果不是所有文章都一定合适,所以需要 全局开关 + 文章单独覆盖。
# 命中就展开
这里又绕了一小段路。
最开始保留旧目录的展开条件:等某个章节成为原来的“当前章节”,再打开它的子目录;曲线则按照可见标题高亮。
结果出现了一个很别扭的情况:父标题明明已经亮了,下面却还是收着的。
原因也很直接,两套判断标准不一样。
最终统一为:只要标题命中可见范围,就展开它拥有的子目录;如果命中的是深层标题,则同时展开它的父级链。
不必等标题跨过屏幕顶部。没有命中的分支再收起,展开和高亮用同一份结果,这才算对上了。
# 解决方案
# 不搬整套框架,只迁移交互
当前主题是 ShokaX,已有 Pug 模板、Stylus 样式和 TypeScript 前端代码。
为了一个目录换掉整个页面技术栈,多少有点买椟还珠了 🤣
所以这次保留 Hexo 的 toc(page.content) 生成结果,在现有目录上补一层展示逻辑:
| 部分 | 负责的事情 |
|---|---|
| Pug | 判断文章最终使用哪种目录样式 |
| Stylus | 缩进、颜色、编号、换行和动画样式 |
| TypeScript | 可见标题判断、分支展开、轨道坐标与生命周期 |
| SVG | 绘制底线、弯折路径和高亮区间 |
原来的目录点击跳转继续复用;曲线模式不再依赖旧 .current 类决定高亮范围,也不让旧的单项居中滚动逻辑干扰它。default 模式保持原有行为。
# 文件放在哪里
本文路径以 主题源码目录 为起点,主要涉及:
_config.yml
layout/_mixin/sidebar.pug
source/css/_common/outline/sidebar/toc.styl
source/css/_common/outline/sidebar/toc-curve.styl
source/js/_app/components/toc-curve.ts
source/js/_app/components/sidebar.ts
source/js/_app/pjax/refresh.ts我本地的 themes/shokax-src 是主题源码入口,themes/shokax 是构建出来给 Hexo 使用的目录。
修改时要改源码,不要直接改构建产物,否则下次构建就被覆盖了。普通安装没有这层目录拆分的话,按自己的主题源码位置处理即可。
# 代码实现
先通过几个关键片段说明各层怎么配合;后面的「完整复刻:按文件接入」给出两个新增文件的全文和所有接入改动,实际操作按那一节逐项完成即可。
# 全局配置与文章覆盖
博客的 _config.shokax.yml 中,在已有 sidebar 下添加配置,不要重复声明两段 sidebar:
sidebar:
position: right
toc_style: curve支持两个值:
default:主题原来的目录。curve:这次新增的曲线目录。
主题自身的默认值仍然是 default,本站显式开启 curve。
如果某一篇文章希望使用旧目录,在它顶部的 Front-matter 加上:
---
title: 一篇使用原目录的文章
toc_style: default
---反过来,全局关闭的时候,也可以在单篇文章中设置 toc_style: curve。
对应的 Pug 判断很简单:
- const tocStyle = page.toc_style ?? theme.sidebar.toc_style ?? 'default'优先级就是 文章配置 > 全局配置 > 默认值。文章没写就继承全局,不需要每篇都加一遍。
最终只在开启时,给目录容器追加 toc-curve 类;新样式和新脚本都通过这个类限定范围。
# 一份命中结果,同时控制高亮和展开
先通过目录链接的 hash 找到正文标题。标题可能包含中文,所以取 id 时需要处理 URL 编码;找不到对应元素的条目直接跳过。
判断标题有没有进入视口,关键条件如下:
const rect = heading.getBoundingClientRect()
if (rect.bottom > 0 && rect.top < window.innerHeight) {
visible.push(index)
}当前实现以 浏览器视口 为边界,只要标题有部分进入就算命中,并没有额外扣除顶部固定导航的遮挡高度。
如果没有标题命中,就比较各标题顶部与视口顶部的距离,取绝对值最小的那个作为兜底。这不是判断文章“读完了没有”,也不一定是上一个章节;下一个标题更近时,也可能选中下一个。
然后,从命中的目录项向上收集父级:
const owners = new Set<Element>()
for (const index of visible) {
let item = links[index].closest('.toc-item')
while (item) {
owners.add(item)
item = item.parentElement?.closest('.toc-item') || null
}
}某个子目录的父节点在 owners 里,就展开;不在,就收起。
这样父标题刚进入屏幕时能展开,直接跳到深层小节时也不会出现“正文到了,目录还藏着”的情况。同屏命中多个分支时,也允许同时展开,不强行做成只能打开一项的手风琴。
# 左边细线怎么拐弯
目录本来就有缩进,没必要再从标题层级重新算一套位置。
读取每个目录链接的实际位置,减去目录根容器的位置,就能得到 SVG 使用的局部坐标。长标题换行后,高度也会自然反映到坐标里。
两个相邻节点:
- 横坐标相同,使用直线连接。
- 横坐标不同,使用三次贝塞尔曲线完成转折。
路径里的核心命令就是:
d += ` C ${prev.x} ${middle}, ${point.x} ${middle}, ${point.x} ${point.top + 8}`这里的 C 是 SVG 路径命令,不是什么目录插件的特殊语法。
同一条路径绘制两遍:底层是普通颜色,上层是主题高亮色。上层通过一个 clipPath 矩形裁剪,只露出当前命中范围。
于是移动高亮线时,不必重新造一条完全不同的曲线,只需要更新 裁剪窗口的位置和高度,起点的小圆点跟着一起移动即可。
每次初始化都会生成独立的裁剪 ID,避免 PJAX 切换时 SVG 引用混到别的实例上。
# 展开动画与细线同步
只给子目录加动画还不够。如果线只在动画开始时算一次位置,文字还在往下走,线却已经到了终点,看起来就会脱节。
这次分成三步处理:
- 先记录需要变化的分支当前高度,并取消它们上一轮未完成的动画。
- 将目标状态设为
auto或0px,统一测量终态高度。 - 使用 Web Animations API 在两个数值高度之间过渡,动画期间逐帧重算轨道。
const animation = branch.animate(
[{ height: `${from}px` }, { height: `${to}px` }],
{ duration: 220, easing: 'cubic-bezier(.22,.68,0,1)' }
)这里刻意没有直接尝试把 0 动画到 auto,而是先取得可插值的实际高度。嵌套分支也要先测量、再统一启动动画,避免父级把子级的中间高度当成最终高度。
收起过程中,还要检查目录项与各层父容器的裁剪范围。完全被藏住的项不参与绘线,露出的高度太小也先不画;暂时还没展开出来的命中项,则先映射到可见父项。
这样细线不会穿过已经隐藏的子目录,也不会留下突兀的长尾巴。
# 配色、编号和长标题
第一版为了贴近参考图,用了蓝色,还顺手把编号隐藏了。
结果放回本站一看,旁边都是主题粉色,就它另起炉灶;再一看,2.1. 也没了。属于是 形似了,自己的习惯丢了。
最后改回主题变量:
.contents.toc-curve {
--toc-accent: var(--primary-color);
.toc-number {
font-variant-numeric: tabular-nums;
}
.toc-link {
color: var(--grey-6);
overflow-wrap: anywhere;
}
.toc-link[data-toc-visible='true'] {
color: var(--toc-accent);
font-weight: 600;
}
}编号继续用 Hexo 原本生成的内容,不重新编号;轨道底色使用 --grey-4,高亮线和文字使用同一个主题主色。日夜模式切换时,也就能跟随主题变量变化。
# PJAX 切页不能只管创建
主题切页不是每次都刷新整个浏览器。如果每次只增加监听器、不清理,目录就可能越切越卡,甚至旧页面的回调还在操作新页面。
所以在 siteRefresh 中,原来的 sidebarTOC() 后面调用 refreshTocCurve();新组件每次初始化前,先清理上一个实例。
清理范围包括:
scroll、resize事件监听。MutationObserver和ResizeObserver。- 等待中的
requestAnimationFrame。 - 尚未结束的展开、收起动画。
- SVG 节点、临时高度、高亮属性和
inert状态。
滚动更新通过 requestAnimationFrame 合并,同一帧不重复排队。目录展开过程中继续调度,动画结束后则不再自行空转。
折叠分支设置 inert,避免键盘焦点进入隐藏链接;开启系统“减少动态效果”时,跳过折叠动画,并关闭轨道裁剪窗口与圆点的过渡。
# 完整复刻:按文件接入
前面讲的是原理,下面才是可以照着落地的代码。以本文所用 ShokaX 0.5.4 的目录结构为基线:新增文件给出全文,已有文件给出本功能的全部差异,不需要从原理片段里猜缺失的代码。
不额外安装 React、Fumadocs 或目录插件,复用主题已有的 Pug、Stylus 和 TypeScript 构建链。若你的主题版本不同,按代码上下文定位,不要按行号机械覆盖。
# 长代码怎么折叠
本站旧文《单例模式》(源文件 source/_posts/DesignPattern/singleton-design-pattern.md)已经使用了 +++ 折叠语法,当前 hexo-renderer-aether 仍会将它渲染成默认关闭的 details。
这里要区分一下:旧代码围栏的 min|语言 写法,不是当前 Shiki / shokax-uikit 的折叠参数;下面使用的是 Markdown 渲染器的折叠容器,里面还是普通代码块。这样代码高亮、复制和完整内容都能保留。
写法如下,primary 是容器样式,后面是展开标题;不写 open,默认收起:
+++primary 点击展开完整代码
```ts
const enabled = true
```
+++# 1. 新建目录逻辑文件
新建 source/js/_app/components/toc-curve.ts,全文如下。不需要拼接上面的示意片段,也没有省略初始化、动画或清理部分。
source/js/_app/components/toc-curve.ts(完整文件,点击展开)
let cleanup: (() => void) | undefined
let instance = 0
// Keep native TOC navigation, but highlight visible headings rather than read progress.
export function refreshTocCurve() {
cleanup?.()
cleanup = undefined
const panel = document.querySelector<HTMLElement>('.contents.toc-curve')
const list = panel?.querySelector<HTMLElement>(':scope > .toc')
if (!panel || !list) return
const links = Array.from(list.querySelectorAll<HTMLAnchorElement>('.toc-link'))
if (!links.length) return
const headings = links.map(link => {
try {
return document.getElementById(decodeURIComponent(link.hash.slice(1)))
} catch { return null }
})
const ns = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(ns, 'svg')
svg.classList.add('toc-curve-rail')
svg.setAttribute('aria-hidden', 'true')
const rail = document.createElementNS(ns, 'path')
const progress = document.createElementNS(ns, 'path')
progress.classList.add('toc-curve-progress')
const defs = document.createElementNS(ns, 'defs')
const clip = document.createElementNS(ns, 'clipPath')
clip.id = `toc-curve-window-${++instance}`
clip.setAttribute('clipPathUnits', 'userSpaceOnUse')
const windowRect = document.createElementNS(ns, 'rect')
windowRect.classList.add('toc-curve-window')
clip.append(windowRect)
defs.append(clip)
progress.setAttribute('clip-path', `url(#${clip.id})`)
const dot = document.createElementNS(ns, 'circle')
dot.setAttribute('r', '2.5')
svg.append(defs, rail, progress, dot)
list.prepend(svg)
let frame = 0
const branches = Array.from(list.querySelectorAll<HTMLElement>('.toc-child'))
const expanded = new Map<HTMLElement, boolean>()
const animations = new Map<HTMLElement, Animation>()
const syncBranches = (visible: number[]) => {
const owners = new Set<Element>()
for (const index of visible) {
let item = links[index].closest('.toc-item')
while (item) {
owners.add(item)
item = item.parentElement?.closest('.toc-item') || null
}
}
const changes: { branch: HTMLElement, from: number, initial: boolean }[] = []
for (const branch of branches) {
const open = owners.has(branch.parentElement)
if (expanded.get(branch) === open) continue
const initial = !expanded.has(branch)
const from = branch.getBoundingClientRect().height
const previous = animations.get(branch)
if (previous) { previous.onfinish = null; previous.cancel() }
animations.delete(branch)
expanded.set(branch, open)
branch.inert = !open
branch.style.height = open ? 'auto' : '0px'
changes.push({ branch, from, initial })
}
// Measure all final heights before animating any nested branch.
const measured = changes.map(change => ({ ...change, to: change.branch.getBoundingClientRect().height }))
for (const { branch, from, initial, to } of measured) {
if (!initial && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const animation = branch.animate([{ height: `${from}px` }, { height: `${to}px` }], {
duration: 220, easing: 'cubic-bezier(.22,.68,0,1)'
})
animations.set(branch, animation)
animation.onfinish = () => {
animations.delete(branch)
schedule()
}
}
}
}
const draw = () => {
frame = 0
if (!list.offsetWidth) return
const visible: number[] = []
let nearest = -1
let distance = Infinity
headings.forEach((heading, index) => {
if (!heading || !heading.getClientRects().length) return
const rect = heading.getBoundingClientRect()
if (rect.bottom > 0 && rect.top < window.innerHeight) visible.push(index)
if (Math.abs(rect.top) < distance) {
nearest = index
distance = Math.abs(rect.top)
}
})
if (!visible.length && nearest >= 0) visible.push(nearest)
syncBranches(visible)
const root = list.getBoundingClientRect()
const points = links.map((link, index) => {
const rect = link.getBoundingClientRect()
let top = rect.top
let bottom = rect.bottom
let parent = link.parentElement?.parentElement
while (parent && parent !== list) {
if (parent.classList.contains('toc-child')) {
const clipRect = parent.getBoundingClientRect()
top = Math.max(top, clipRect.top)
bottom = Math.min(bottom, clipRect.bottom)
}
parent = parent.parentElement
}
return { index, x: rect.left - root.left + 9, top: top - root.top, bottom: bottom - root.top }
}).filter(point => point.bottom - point.top > 16)
if (!points.length) return
svg.setAttribute('width', String(root.width))
svg.setAttribute('height', String(root.height))
let d = `M ${points[0].x} ${points[0].top + 8}`
points.forEach((point, index) => {
if (index) {
const prev = points[index - 1]
if (prev.x !== point.x) {
const middle = (prev.bottom + point.top) / 2
d += ` C ${prev.x} ${middle}, ${point.x} ${middle}, ${point.x} ${point.top + 8}`
} else d += ` L ${point.x} ${point.top + 8}`
}
d += ` L ${point.x} ${point.bottom - 8}`
})
rail.setAttribute('d', d)
progress.setAttribute('d', d)
// During expansion, map a clipped heading to its currently visible parent.
const displayed = new Set(points.map(point => point.index))
const highlighted = new Set<number>()
for (const index of visible) {
let link: Element | null = links[index]
while (link) {
const mapped = links.indexOf(link as HTMLAnchorElement)
if (displayed.has(mapped)) {
highlighted.add(mapped)
break
}
link = link.parentElement?.parentElement?.closest('.toc-item')?.querySelector(':scope > .toc-link') || null
}
}
const activePoints = points.filter(point => highlighted.has(point.index))
links.forEach((link, index) => {
link.dataset.tocVisible = String(highlighted.has(index))
if (index === activePoints[0]?.index) link.setAttribute('aria-current', 'location')
else link.removeAttribute('aria-current')
})
const first = activePoints[0]
const last = activePoints[activePoints.length - 1]
progress.style.display = dot.style.display = first ? '' : 'none'
if (first) {
windowRect.setAttribute('width', String(root.width))
windowRect.style.y = `${first.top + 8}px`
windowRect.style.height = `${Math.max(0, last.bottom - first.top - 16)}px`
dot.style.cx = `${first.x}px`
dot.style.cy = `${first.top + 8}px`
}
// Keep the rail attached to the animated layout, not its final geometry.
if (animations.size) schedule()
}
const schedule = () => { if (!frame) frame = requestAnimationFrame(draw) }
const mutation = new MutationObserver(schedule)
mutation.observe(panel, { attributes: true, attributeFilter: ['class'], subtree: true })
const resize = new ResizeObserver(schedule)
resize.observe(list)
const article = document.querySelector('.post.block')
if (article) resize.observe(article)
window.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule, { passive: true })
schedule()
cleanup = () => {
mutation.disconnect()
resize.disconnect()
window.removeEventListener('scroll', schedule)
window.removeEventListener('resize', schedule)
cancelAnimationFrame(frame)
animations.forEach(animation => { animation.onfinish = null; animation.cancel() })
branches.forEach(branch => { branch.style.height = ''; branch.inert = false })
links.forEach(link => {
link.removeAttribute('aria-current')
delete link.dataset.tocVisible
})
svg.remove()
}
}# 2. 新建样式文件
新建 source/css/_common/outline/sidebar/toc-curve.styl,全文如下。它只作用于 .contents.toc-curve,不会把默认目录也改掉。
source/css/_common/outline/sidebar/toc-curve.styl(完整文件,点击展开)
.contents.toc-curve {
--toc-accent: var(--primary-color);
ol {
padding: 0;
list-style: none;
}
> .toc {
position: relative;
margin: 0;
}
.toc-child {
display: block;
padding-left: 1rem;
height: 0;
overflow: hidden;
}
.active > .toc-child {
height: auto;
}
.toc-item {
overflow: visible;
white-space: normal;
font-size: .9375rem;
}
.toc-number {
font-variant-numeric: tabular-nums;
}
.toc-link {
display: block;
position: relative;
padding: .5rem .25rem .5rem 1.5rem;
color: var(--grey-6);
line-height: 1.6;
overflow-wrap: anywhere;
transition: color .18s ease;
&:hover {
color: var(--text-color);
}
&:focus-visible {
outline: 2px solid var(--toc-accent);
outline-offset: -2px;
border-radius: .25rem;
}
}
.active > .toc-link,
.current > .toc-link {
color: var(--grey-6);
font-weight: normal;
}
.toc-link[data-toc-visible='true'],
.toc-link[data-toc-visible='true']:hover {
color: var(--toc-accent);
font-weight: 600;
}
.toc-curve-rail {
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
fill: none;
stroke: var(--grey-4);
stroke-width: 1.5;
stroke-linecap: round;
.toc-curve-progress {
stroke: var(--toc-accent);
}
circle {
fill: var(--toc-accent);
stroke: none;
transition: cx .18s ease, cy .18s ease;
}
.toc-curve-window {
transition: y .18s ease, height .18s ease;
}
}
}
@media (prefers-reduced-motion: reduce) {
.contents.toc-curve .toc-curve-rail {
circle, .toc-curve-window {
transition: none;
}
}
}然后在已有 source/css/_common/outline/sidebar/toc.styl 的 文件末尾、所有大括号之外追加:
@import 'toc-curve';只创建文件、不导入,是不会进入最终页面样式的。
# 3. 接上模板开关
修改 layout/_mixin/sidebar.pug,只有下面两处。代码使用 diff 展示:删除减号行,添加加号行,前面的 + / - 不属于 Pug 源码。
mixin sidebarRender(display_toc)
+ - const tocStyle = page.toc_style ?? theme.sidebar.toc_style ?? 'default'
- var related = _category_posts(page)找到原来的目录面板,把整个开始标签行替换掉;保留其下面的 if display_toc 和 toc(page.content):
- div(class="contents panel pjax" data-title=__('sidebar.toc'))
+ div(class="contents panel pjax" class=tocStyle === 'curve' ? 'toc-curve' : '' data-title=__('sidebar.toc'))
if display_toc
!= toc(page.content)这里没有新增一套目录 HTML,也没有手工拼编号。Hexo 输出的 .toc-item、.toc-link、.toc-child 等结构继续沿用。
# 4. 接上每次页面刷新
在 source/js/_app/pjax/refresh.ts 顶部的 import 区域添加:
import { refreshTocCurve } from '../components/toc-curve'在 siteRefresh 内找到原来的两行,改成下面三行,调用顺序保持不变:
sideBarTab()
sidebarTOC()
refreshTocCurve()不要仅在首次加载页面时调用,也不要包在 if (当前是 curve) 中:函数开头会清理上一页的实例,即使下一页使用默认目录或没有目录,也需要经过它。
# 5. 避免旧的目录自动居中干扰
在 source/js/_app/components/sidebar.ts 中,找到注释 Scrolling to center active TOC element 后的判断,按下面完整差异修改。只追加曲线模式的排除条件,保留原来的 pageScroll 调用:
- if (getComputedStyle(sideBar).display !== 'none' && tocElement.classList.contains('active')) {
+ if (getComputedStyle(sideBar).display !== 'none' && tocElement.classList.contains('active') && !tocElement.classList.contains('toc-curve')) {
pageScroll((tocElement as HTMLElement), target.offsetTop - ((tocElement as HTMLElement).offsetHeight / 4))
}不删除整个 sidebarTOC(),因为默认目录和原来的跳转行为仍然需要它。
# 6. 补齐配置并启用
在 主题源码的 _config.yml 中,合并到现有 sidebar 下,保留其他配置:
sidebar:
toc_style: default
position: left这只是相关配置示例,不是让你覆盖整个配置文件。主题默认仍是旧目录。
在 博客根目录的 _config.shokax.yml 中,合并:
sidebar:
toc_style: curve若你的主题名称不同,使用与你实际主题名对应的 Hexo 主题覆盖配置文件。不要把这两份 _config.yml 的位置混起来,也不要重复定义 sidebar。
文章顶部可单独设 toc_style: default 或 toc_style: curve。未设置时继承全局;这里是样式选择,不是目录显示与否的开关。
到这里,本功能需要的改动就齐了:2 个新增文件、5 个已有主题文件的接入改动,以及博客自己的启用配置。无需新增语言字段,也不依赖本站的一言、图库或阅读次数功能。
# 本地验证
这次主要验证了几个容易出问题的场景:
- 全局
curve,文章未配置:使用曲线目录。 - 全局
curve,文章default:恢复原目录。 - 全局
default,文章curve:单篇开启。 - 多个标题同时可见:高亮不再只认一个
.current。 - 标题仍在屏幕中部:已经命中,就应该展开。
- 跳到后面的章节:高亮起点也向下移动,不从头累计。
- 离开命中分支:子目录收起,细线同步缩回。
我这个工程已经封装了主题构建命令,在博客根目录执行:
pnpm run theme:build
pnpm exec hexo generate主题 TypeScript 检查则在主题源码目录执行:
pnpm testtheme:build 是本站工程脚本,不是所有 Hexo 博客都自带的命令。
修改全局配置后,如果运行中的 Hexo 服务仍然使用旧配置,需要重启服务;不要只盯着浏览器刷新,以为是 CSS 没生效。
# 结语
这次迁移看起来是在画一根线,实际上最重要的是统一 可见标题、高亮范围、展开分支 这三件事。
一开始只学了外形,后来把交互补齐,再把配色、编号和配置方式放回自己的主题里,才算真正用顺手了。
效果可以借鉴,习惯还是得留着。完结撒花~

有什么想法,欢迎留下评论。