Featured image of post 博客优化美化记录(持续更新)

博客优化美化记录(持续更新)

看原本过于简陋的博客实在有点不爽,借助ai一同完成了对于首页、分页、侧栏、搜索、About 页、页脚和终端欢迎语的持续优化

最后修改:
|
|
|

博客优化美化记录前言

这一篇接着之前的建站记录往下写跳转链接 ,主要整理这一阶段我对博客前端和交互做的连续优化。
这一次不再是“把博客搭起来”,而是开始一块一块把首页、分页、侧栏、搜索页、分类页、About 页、页脚、看板娘和首页欢迎语继续往自己想象的风格上收。

这篇里的内容基本按 git 历史和当前工作区还没提交的改动来整理。
和之前一样,我尽量把关键代码、资源路径和文件位置都写出来

这一阶段的提交线

优化完能从 git 里直接看到的提交一共有 19 个,主要集中在下面这些时间点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
2026-03-09 13:45 d06c4a7 Fix homepage footer overlap and dark mode toggle display
2026-03-09 14:46 4bcab79 Adjust footer glass and sidebar theme toggle
2026-03-09 15:00 9935a4c Revamp homepage layout and restore pagination jump
2026-03-09 15:24 82b0be4 Fix homepage theme toggle and default covers
2026-03-09 15:37 e7d6310 Fix footer glass theme switching
2026-03-09 15:48 c85d7eb Unify sidebar and page styling across non-home pages
2026-03-09 16:01 c9cec9d Refine about page and restore article TOC styling
2026-03-09 16:27 9f4fb5c Polish article styling and fix page banner suppression
2026-03-09 16:49 e7a9a3a Refine archive cards and article backgrounds
2026-03-10 12:08 6ca7fd0 fix search and category sidebar widgets
2026-03-10 12:25 d1b04e1 Refine category page tags and pagination
2026-03-10 12:31 c814ca1 Redesign shared pagination jump UI
2026-03-10 13:04 14726f3 Refine pagination to lighter expandable style
2026-03-10 13:12 876e21e Unify pagination style across home and category
2026-03-10 13:19 7dbc278 Polish home pagination to match category style
2026-03-10 17:40 a0ed400 Fix home pagination and compact list fallbacks
2026-03-10 18:05 c028f08 fix error image
2026-03-10 18:31 ff80ae0 Unify home pagination with category style
2026-03-10 21:44 635f8e9 Polish home floating pagination and dark dropdown

后面还有一批没有提交进 git 的新改动,主要集中在 核心是首页终端欢迎语、移动端主题切换按钮和 About 页继续调整。


首页底部遮挡、样式链路和返回顶部按钮

一开始最明显的问题,不是布局多花哨,而是首页底部看起来有遮挡感,另外很多自定义样式写进去后不一定真的生效。

接管站点自己的 SCSS 主入口

这一块最重要的不是视觉,而是先保证 custom.scss 真能编译进最终样式里。
我在根目录增加了自己的样式入口 assets/scss/style.scss,直接沿用主题原本的导入顺序,最后再手动引入自定义样式。

文件:assets/scss/style.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@import "breakpoints.scss";
@import "variables.scss";
@import "grid.scss";

@import "external/normalize.scss";

@import "partials/menu.scss";
@import "partials/article.scss";
@import "partials/widgets.scss";
@import "partials/footer.scss";
@import "partials/pagination.scss";
@import "partials/sidebar.scss";
@import "partials/base.scss";
@import "partials/layout/article.scss";
@import "partials/layout/list.scss";
@import "partials/layout/404.scss";
@import "partials/layout/search.scss";

@import "general.scss";
@import "custom.scss";

这样做的好处是后面所有写在 assets/scss/custom.scss 里的覆盖样式,都会走 Hugo 这条正式编译链,不会被主题默认入口绕开。

根模板里增加新的返回顶部按钮

后面我把旧的返回顶部按钮方案彻底换掉了,不再使用一个单独图片按钮,而是改成带进度环的结构

文件:layouts/_default/baseof.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<button id="back-to-top" class="back-to-top" type="button" aria-label="返回顶部">
    <span class="back-to-top__progress" aria-hidden="true"></span>
    <span class="back-to-top__inner" aria-hidden="true"></span>
</button>
<script>
    const backToTopButton = document.getElementById('back-to-top');
    function updateBackToTop() {
        const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
        const scrollHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
        const progress = scrollHeight > 0 ? scrollTop / scrollHeight : 0;

        backToTopButton.style.setProperty('--scroll-progress', progress.toFixed(4));

        if (scrollTop > 120) {
            backToTopButton.style.display = 'inline-flex';
        } else {
            backToTopButton.style.display = 'none';
        }
    }

    window.addEventListener('scroll', updateBackToTop, { passive: true });
    updateBackToTop();

    backToTopButton.addEventListener('click', function () {
        window.scrollTo({ top: 0, behavior: 'smooth' });
    });
</script>

这里还顺手把旧方案注释保留在模板里了,方便以后回头看:

1
2
3
4
5
{{/* --- 返回顶部按钮 (已屏蔽) ---
    <button id="back-to-top" class="back-to-top">
        <img src="/icons/back-to-top.svg" alt="Back to Top" />
    </button>
*/}}

对应资源也保留了两份:

1
2
assets/icons/backTop.svg
static/downloads/backTop.svg

这一步的重点不是“加了个按钮”,而是把返回顶部逻辑收成唯一入口,避免旧脚本、旧结构和文章里的示例代码互相冲突。


页脚结构回退后重新做成毛玻璃卡片

页脚这块改过很多次,最后还是回到“信息结构不要太花,但视觉要更稳”的方向。

页脚模板保留原信息结构

我最后没有坚持用完全重构后的新结构,而是把页脚信息收回到比较稳定的这套:

文件:layouts/partials/footer/footer.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<footer class="site-footer">
    <section class="copyright">
        &copy;
        {{ if and (.Site.Params.footer.since) (ne .Site.Params.footer.since (int (now.Format "2006"))) }}
            {{ .Site.Params.footer.since }} -
        {{ end }}
        {{ now.Format "2006" }} {{ default .Site.Title .Site.Copyright }}
    </section>

    <section class="running-time">
        本博客已稳定运行
        <span id="runningdays" class="running-days"></span>
    </section>

    <section class="totalcount">
        发表了{{ len (where .Site.RegularPages "Section" "post") }}篇文章 ·
        总计{{ $tenThousands }}万{{ $remainingThousands }}千字
    </section>

    <div class="site-footer__control-row">
        <p class="site-footer__links">
            <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">浙ICP备2024137952号</a>
            <a href="https://umami-beta-three.vercel.app/share/jV7iS6xOaXMTawSY/nan0in27.cn" target="_blank" rel="noopener">『网站统计』</a>
        </p>
    </div>

    <section class="powerby">
        {{ T "footer.builtWith" (dict "Generator" $Generator) | safeHTML }} <br />
        {{ T "footer.designedBy" (dict "Theme" $Theme "DesignedBy" $DesignedBy) | safeHTML }}
    </section>
</footer>

这样做的原因很简单,信息密度合适,而且不会像我中途那版大卡片一样显得太满。

用 SCSS 把它收成玻璃浮层

模板保留原信息结构后,视觉主要交给 assets/scss/partials/footer.scss 来做。
最后留下来的样式大概是这样:

文件:assets/scss/partials/footer.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
footer.site-footer {
    padding: 18px 20px 14px;
    font-size: 1.26rem;
    line-height: 1.62;
    color: rgba(46, 63, 85, 0.92);
    background: linear-gradient(135deg, rgba(250, 253, 255, 0.88), rgba(231, 239, 248, 0.78));
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
    border-radius: 24px;
    border: 1px solid rgba(147, 167, 196, 0.22);
    box-shadow: 0 16px 30px rgba(113, 135, 165, 0.16);

    &:before {
        content: "";
        display: block;
        height: 3px;
        width: 42px;
        background: color-mix(in srgb, #8fb7ff 60%, rgba(255, 255, 255, 0.28));
        margin-bottom: 14px;
        border-radius: 999px;
    }
}

[data-scheme="dark"] {
    footer.site-footer {
        color: rgba(233, 239, 247, 0.94);
        background: linear-gradient(135deg, rgba(32, 37, 45, 0.94), rgba(28, 33, 40, 0.88));
        border-color: rgba(147, 167, 196, 0.16);
        box-shadow: 0 16px 34px rgba(0, 0, 0, 0.2);
    }
}

这里关键不是加很多效果,而是:

  • 亮色下像半透明蓝灰玻璃
  • 暗色下真正变成深灰蓝玻璃
  • 不让页脚在切主题时“几乎不变”

首页布局改版和欢迎区升级

原先样式

首页最开始的欢迎区其实还是静态拼字

在真正做终端欢迎语之前,首页标题区还只是简单的 span 拼字动画。

历史文件:git show 9935a4c:layouts/index.html

1
2
3
4
5
6
7
8
<div class="home-intro welcome">
    <p class="home-intro__title">
        <span class="shake">$</span>
        <span class="jump-text2"> welcome to</span>
        <span class="jump-text3" style="color:#7aa2f7">N</span><span class="jump-text4" style="color:#7aa2f7">a</span><span class="jump-text5" style="color:#7aa2f7">n</span><span class="jump-text6" style="color:#7aa2f7">0</span><span class="jump-text7" style="color:#7aa2f7">in</span><span class="jump-text8" style="color:#7aa2f7">'s</span>
        <span class="jump-text9" style="color:#c0cad6">Blog</span>
    </p>
</div>

配套样式也放在 assets/scss/custom.scss 里:

1
2
3
4
5
6
7
.welcome {
  color: var(--card-text-color-main);
  background: var(--card-background);
  box-shadow: var(--shadow-l2);
  border-radius: 30px;
  display: inline-block;
}

这时候首页已经和默认主题不太一样了,参考的是koala 的美化博客,但是总感觉少了点什么…

修改:终端打字动画

这一部分是最近在工作区里继续改的,也是我这次最想要的效果之一。

把随机文案收进配置文件

为了不把文案写死在模板里,我先在 hugo.yaml 里加了一个可配置列表:

文件:hugo.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
params:
    sidebar:
        emoji: 😴
        subtitle: 覆于这极夜不休而歌,直至无息-

    homeTerminal:
        prompts:
            - pwn everything!
            - keep hungry,keep folish.no waste
            - zsh is the best shell.I love it.
            - C first,rust second,cpp?trash.
            - CISC,RISC,SuperScalar.

这样以后欢迎区要换句子,就不需要再进模板改 JS 数组。

首页模板从静态文案换成终端结构

文件:layouts/index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{{ $homeTerminalPrompts := default (slice "覆于这极夜不休而歌" "直至无息") .Site.Params.homeTerminal.prompts }}

<p class="home-intro__title home-terminal">
    <span class="home-terminal__viewport">
        <span class="home-terminal__welcome-layer">
            <span class="home-terminal__welcome" data-phase="welcome"></span>
        </span>
        <span class="home-terminal__line">
            <span class="home-terminal__prompt-static">
                <span class="home-terminal__prompt-mark">$</span>
                <span class="home-terminal__identity" data-phase="identity"></span>
            </span>
            <span class="home-terminal__message-viewport">
                <span class="home-terminal__message-track">
                    <span class="home-terminal__message"></span>
                    <span class="home-terminal__cursor" aria-hidden="true"></span>
                </span>
            </span>
        </span>
    </span>
</p>

JS 控制欢迎阶段、提示符阶段和循环打字

同一个模板里还写了控制逻辑:

文件:layouts/index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
    (function () {
        const root = document.querySelector('.home-terminal');
        if (!root) return;

        const prompts = {{ $homeTerminalPrompts | jsonify | safeJS }};
        const welcomeEl = root.querySelector('.home-terminal__welcome');
        const welcomeLayerEl = root.querySelector('.home-terminal__welcome-layer');
        const identityEl = root.querySelector('.home-terminal__identity');
        const messageEl = root.querySelector('.home-terminal__message');
        const lineEl = root.querySelector('.home-terminal__line');
        const messageTrackEl = root.querySelector('.home-terminal__message-track');
        const messageViewportEl = root.querySelector('.home-terminal__message-viewport');

        const welcomeText = " welcome to Nan0in's Blog!";
        const identityText = "nan0in27:";

        function syncLineOffset() {
            const hiddenWidth = Math.max(0, messageTrackEl.scrollWidth - messageViewportEl.clientWidth);
            messageTrackEl.style.setProperty('--message-shift', hiddenWidth > 0 ? `${hiddenWidth}px` : '0px');
        }
    })();
</script>

这里最重要的是 syncLineOffset(),因为我希望长句出现时,消息轨道整体左推,而不是把固定提示符一起挤掉。

对应 SCSS:欢迎层、提示符、消息轨道和光标

文件:assets/scss/custom.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
.home-terminal {
  margin: 0;
  overflow: visible;
}

.home-terminal__viewport {
  position: relative;
  display: inline-flex;
  align-items: center;
  width: 100%;
  max-width: 41ch;
  min-height: 1.45em;
  overflow: hidden;
  white-space: nowrap;
}

.home-terminal__welcome-layer {
  position: absolute;
  inset: 0;
  display: inline-flex;
  align-items: center;
  opacity: 1;
  transition: opacity 0.42s ease, filter 0.42s ease, transform 0.42s ease;
}

.home-terminal__welcome-layer.is-erasing {
  opacity: 0;
  filter: blur(8px);
  transform: translateX(-1.6ch);
}

.home-terminal__message-track {
  display: inline-flex;
  align-items: center;
  gap: 0.12em;
  min-width: max-content;
  transform: translateX(calc(var(--message-shift, 0px) * -1));
  transition: transform 0.28s ease;
}

.home-terminal__cursor {
  display: inline-block;
  width: 0.46em;
  height: 1.15em;
  background: #5ca8ff;
  animation: home-terminal-caret 0.95s step-end infinite;
}

这样首页欢迎区最后实现的是:

  • 先打出欢迎语
  • 欢迎层做模糊擦除
  • 再切到固定 $nan0in27:
  • 后面随机短句循环打字
  • 句子太长时只推动消息轨道,不挤掉 prompt
  • 光标跟着消息末尾移动

这一步做完之后,首页的辨识度终于比之前那版简单的欢迎字条高很多。


首页模板切到自己的文章区结构

首页文章区也在这一阶段从默认列表逐渐收成更明确的主内容区域:

文件:layouts/index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{{ define "body-class" }}template-home{{ end }}

{{ define "main" }}
    {{ $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections }}
    {{ $notHidden := where .Site.RegularPages "Params.hidden" "!=" true }}
    {{ $filtered := ($pages | intersect $notHidden) }}
    {{ $pag := .Paginate ($filtered) }}

    <section class="article-list article-list--home-grid">
        {{ range $pag.Pages }}
            {{ partial "article-list/default" . }}
        {{ end }}
    </section>

    {{- partial "pagination.html" . -}}
    {{- partial "footer/footer" . -}}
{{ end }}

这一步后,首页已经不是纯粹的主题原样了,后面很多分页和欢迎区调整,都是围绕这套首页模板继续做。


封面回退和无图文章收口

这一块其实特别实用,因为文章列表一旦有几篇没图或者封面路径失效,整个观感就会很乱。

紧凑卡片补上 has-image / has-no-image

为了防止无图文章还在右边留下一块空图片区,我又改了紧凑卡片模板:

文件:layouts/partials/article-list/compact.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{{- $image := partialCached "helper/image" (dict "Context" . "Type" "articleList") .RelPermalink "articleList" -}}
<article class="{{ if $image.exists }}has-image{{ else }}has-no-image{{ end }}">
    <a href="{{ .RelPermalink }}">
        <div class="article-details">
            <h2 class="article-title">
                {{- .Title -}}
            </h2>
            {{ with .Params.description }}
            <div class="article-subtitle">
                {{ . }}
            </div>
            {{ end }}
        </div>

        {{ if $image.exists }}
            <div class="article-image">
                <img src="{{ $Permalink }}"
                    width="{{ $Width }}"
                    height="{{ $Height }}"
                    alt="{{ .Title }}"
                    loading="lazy">
            </div>
        {{ end }}
    </a>
</article>

这样分类页、归档页和紧凑列表里的无图文章就不会再显得像“右边本来应该还有一张图”。


搜索页修数据源,分类页右栏改单独挂件

搜索页先修 data-json

搜索页一开始的问题不是样式,而是表单绑定和数据源本身。
最早搜索页模板大概是这样:

历史文件:git show 6ca7fd0:themes/hugo-theme-stack/layouts/page/search.html

1
2
3
4
5
6
<form action="{{ .RelPermalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .Permalink }}"{{- end }}>
    <p>
        <label>{{ T "search.title" }}</label>
        <input name="keyword" placeholder="{{ T `search.placeholder` }}" />
    </p>
</form>

后面为了避免脚本和左栏搜索框互相串掉,给搜索页主表单单独加了类名:

文件:themes/hugo-theme-stack/layouts/page/search.html

1
<form action="{{ .RelPermalink }}" class="search-form search-form--page"{{ with .OutputFormats.Get "json" -}} data-json="{{ .Permalink }}"{{- end }}>

搜索脚本只绑定目标表单

对应的逻辑在 文件:themes/hugo-theme-stack/assets/ts/search.tsx(我的博客底下有留存模板用于减少修改)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private async doSearch(keywords: string[]) {
    const startTime = performance.now();

    const results = await this.searchKeywords(keywords);
    this.clear();

    for (const item of results) {
        this.list.append(Search.render(item));
    }
}

public async getData() {
    if (!this.data) {
        const jsonURL = this.form.dataset.json;
        this.data = await fetch(jsonURL).then(res => res.json());
    }

    return this.data;
}

这里核心就是让 this.form.dataset.json 真正指向搜索 JSON,而不是被别的表单抢走。

分类页右栏换成“当前分类 / 分类 / 相关标签”

默认右栏对分类页不够有针对性,所以我后来单独补了一个挂件:

文件:layouts/partials/widget/category_context.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{{- $sourcePages := cond $context.Paginator $context.Paginator.Pages $context.Pages -}}
{{- $tagScratch := newScratch -}}

{{- range $sourcePages -}}
    {{- with .GetTerms "tags" -}}
        {{- range . -}}
            {{- $tagScratch.SetInMap "relatedTags" .RelPermalink . -}}
        {{- end -}}
    {{- end -}}
{{- end -}}

<section class="category-context-block">
    <h2 class="category-context-block__title">当前分类</h2>
    <div class="category-context-block__content category-context-block__content--current tagCloud-tags">
        <a href="{{ $context.RelPermalink }}" class="category-context-chip category-context-chip--current is-active">
            <span class="category-context-chip__prefix">#</span>
            <span class="category-context-chip__label">{{ $context.Title }}</span>
            <span class="category-count">{{ len $context.Pages }}</span>
        </a>
    </div>
</section>

这一版还做了两件事:

  • “相关标签”只统计当前分页里的文章
  • 标签用 map 去重,避免同一个 tag 重复输出

对应样式也在:

文件:assets/scss/custom.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
.category-context-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.45rem 0.95rem;
  border-radius: 999px;
}

.category-context-chip__prefix {
  color: #7aa2f7;
}

.category-context-chip--current {
  font-weight: 700;
}

分页反复重构,最后统一到首页和分类页

这一部分是这一轮里改动最密集的区域。

最开始的分页还是双下拉结构

最初分页模板大概是这个逻辑:

文件:layouts/partials/pagination.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{{ if gt .Paginator.TotalPages 1 }}
    <nav class="pagination">
        {{ $.Scratch.Set "hasPrevDropdown" false }}
        {{ $.Scratch.Set "hasNextDropdown" false }}

        {{ range .Paginator.Pagers }}
            {{ if eq . $.Paginator }}
                <span class="page-link current">
                    {{- .PageNumber -}}
                </span>
            {{ else if or (eq . $.Paginator.First) (eq . $.Paginator.Prev) }}
                <a class="page-link" href="{{ .URL }}">
                    {{- .PageNumber -}}
                </a>
            {{ else if or (eq . $.Paginator.Next) (eq . $.Paginator.Last) }}
                <a class="page-link" href="{{ .URL }}">
                    {{- .PageNumber -}}
                </a>
            {{ else }}
                <div class="page-dropdown-wrapper">
                    <select class="page-dropdown" onchange="location = this.value;">
                        <option value="#">选择页码</option>
                    </select>
                </div>
            {{ end }}
        {{ end }}
    </nav>
{{ end }}

能用,但交互感不够统一,而且首页和分类页看起来也不像同一站。

重做成可输入页码的跳转面板

后面我在 c814ca1 这一步把它重做成 details + form 结构:

历史文件:git show c814ca1:layouts/partials/pagination.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{{ if gt .Paginator.TotalPages 1 }}
    {{- $paginator := .Paginator -}}
    <nav class="pagination" aria-label="分页导航">
        <span class="page-link current" aria-current="page">
            {{- $paginator.PageNumber -}}
        </span>

        {{ if gt $paginator.TotalPages 5 }}
            <details class="pagination-jump">
                <summary class="page-link page-link--chooser">
                    <span class="page-link__text">选择页码</span>
                </summary>
                <div class="pagination-jump__panel">
                    <p class="pagination-jump__hint">输入 1 - {{ $paginator.TotalPages }} 之间的页码</p>
                    <form class="pagination-jump__form" data-total-pages="{{ $paginator.TotalPages }}">
                        <input
                            class="pagination-jump__input"
                            type="number"
                            name="page"
                            min="1"
                            max="{{ $paginator.TotalPages }}"
                            inputmode="numeric"
                            placeholder="页码">
                        <button class="pagination-jump__submit" type="submit">跳转</button>
                    </form>
                </div>
            </details>
        {{ end }}
    </nav>
{{ end }}

对应的前端跳转逻辑也直接写进模板里了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
    (() => {
        const paginations = document.querySelectorAll(".pagination");
        paginations.forEach((pagination) => {
            const jump = pagination.querySelector(".pagination-jump");
            if (!jump || jump.dataset.bound === "true") return;
            jump.dataset.bound = "true";

            const form = jump.querySelector(".pagination-jump__form");
            const input = jump.querySelector(".pagination-jump__input");
            const targets = jump.querySelectorAll(".pagination-jump__targets [data-page]");

            form.addEventListener("submit", (event) => {
                event.preventDefault();
                const page = Number(input.value || "0");
                const target = Array.from(targets).find((item) => Number(item.dataset.page) === page);
                if (!target) return;
                window.location.href = target.dataset.url;
            });
        });
    })();
</script>

分页样式继续收敛

后面我又一直在 assets/scss/custom.scss 里打磨分页层级,尤其是:

  • 当前页高亮
  • 暗色下非当前页不要发白
  • 首页和分类页完全统一
  • 展开页码和跳转面板分离

相关样式基本都集中在这些位置:

1
2
3
4
5
6
assets/scss/custom.scss
1108 行附近
1394 行附近
1612 行附近
1759 行附近
1849 行附近

这部分实际上是反复试,最后才收成统一观感。


非首页左栏统一、移动端主题切换和 GIF 懒加载

左栏加移动端顶部主题切换按钮

桌面端保留左栏主题切换后,移动端我单独做了一个按钮放在顶部。
模板里直接新增一个按钮节点:

文件:themes/hugo-theme-stack/layouts/partials/sidebar/left.html

1
2
3
4
<button class="theme-toggle-mobile-icon" type="button" data-role="theme-toggle" aria-label="切换明暗主题">
    <span class="theme-toggle-mobile-icon__sun" aria-hidden="true"></span>
    <span class="theme-toggle-mobile-icon__moon" aria-hidden="true"></span>
</button>

同时把桌面端原本的大按钮继续保留在菜单区:

1
2
3
4
5
6
7
8
<button id="dark-mode-toggle" class="theme-toggle theme-toggle--day-night" type="button" data-role="theme-toggle" aria-label="切换明暗主题">
    <span class="theme-toggle__scene" aria-hidden="true">
        <span class="theme-toggle__daytime-background theme-toggle__daytime-background--primary"></span>
        <span class="theme-toggle__sun-moon"></span>
        <span class="theme-toggle__stars"></span>
    </span>
    <span class="theme-toggle__label">明暗模式</span>
</button>

对应移动端 SCSS

这一块对应样式集中在 assets/scss/custom.scss 的移动端 media query:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@media (max-width: 767px) {
  .left-sidebar {
    padding-top: 58px;
  }

  .theme-toggle-mobile-icon {
    position: absolute;
    top: 6px;
    right: 50px;
    z-index: 3;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 42px;
    height: 42px;
    border-radius: 16px;
    background: rgba(255, 255, 255, 0.9);
  }

  [data-scheme="dark"] .theme-toggle-mobile-icon {
    background: rgba(29, 35, 44, 0.92);
  }

  .menu-theme-toggle {
    display: none;
  }

  .menu-gif-slot {
    display: none;
  }
}

这样移动端不会去硬复用桌面端那根长条轨道,而是直接用一个更紧凑的顶部按钮。

左栏 GIF 改成懒加载插入

我没有直接在模板里塞图片,而是用 data 属性占位,再由脚本按需插入:

文件:themes/hugo-theme-stack/layouts/partials/sidebar/left.html

1
2
<li class="menu-gif-slot" data-gif-src="/pics/lain.gif" data-gif-alt="Lain GIF">
</li>

资源路径:

1
2
static/pics/lain.gif
static/pics/lain_dance.webp

对应脚本在:

文件:themes/hugo-theme-stack/assets/ts/main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
document.querySelectorAll<HTMLElement>('.menu-gif-slot[data-gif-src]').forEach(slot => {
    if (!window.matchMedia('(min-width: 768px)').matches) return;
    if (slot.querySelector('img')) return;

    const img = document.createElement('img');
    img.src = slot.dataset.gifSrc;
    img.alt = slot.dataset.gifAlt || 'GIF';
    img.loading = 'lazy';
    slot.appendChild(img);
});

主题切换和菜单初始化提前到 DOMContentLoaded

为了不再等整页资源全加载完,主题切换和菜单逻辑被提前初始化:

文件:themes/hugo-theme-stack/assets/ts/main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
initEarly: () => {
    menu();

    document.querySelectorAll('[data-role="theme-toggle"]').forEach(toggle => {
        new StackColorScheme(toggle as HTMLElement);
    });
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
        Stack.initEarly();
    }, { once: true });
} else {
    Stack.initEarly();
}

这一步后,移动端折叠菜单和主题按钮在页面资源没完全加载完之前就能响应。


About 页重做、文章头图关闭和时间线短代码

About 页内容改成独立卡片区

这一步里,我不想让 About 页继续像普通文章一样,所以直接重写了内容结构:

文件:content/page/about/index.zh-cn.md

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
---
title: 关于我|about me
description: 敬朝阳之喜,黄昏之惚,敬明日砥砺前行的我和你。
date: 2024-11-13
image: false
menu:
  main:
    weight: -90
    params:
      icon: user
---

<div class="about-hero">
  <p class="about-hero__eyebrow">daisy</p>
  <h1 class="about-hero__title">About Me</h1>
  <p class="about-hero__lead">Whatever happens,I still love C.We can pwn everything,we can deploy every chain,we can get the shell.</p>
</div>

<div class="about-grid">
  <section class="about-card">
    <h2>Profile</h2>
    <p><strong>Name</strong>: Nan</p>
    <p><strong>Nickname</strong>: Nan0in / 楠牧音</p>
  </section>
</div>

这里最关键的是 image: false,因为我不想让 About 页继续带普通文章的头图。

文章头图模板里显式排除 About、归档和友链页

对应逻辑在:

文件:themes/hugo-theme-stack/layouts/partials/article/components/header.html

1
2
3
4
5
6
7
8
9
{{- $image := partialCached "helper/image" (dict "Context" . "Type" "article") .RelPermalink "article" -}}
{{- $hidePageBanner := or (eq .Layout "links") (eq .Layout "archives") (eq .File.Path "page/about/index.zh-cn.md") (eq .File.Path "page/about/index.md") (eq (printf "%v" .Params.image) "false") -}}
{{ if and $image.exists (not $hidePageBanner) }}
    <div class="article-image">
        <a href="{{ .RelPermalink }}">
            <img src="{{ $Permalink }}" loading="lazy" alt="Featured image of post {{ .Title }}" />
        </a>
    </div>
{{ end }}

这一步后,content/page/about/index.zh-cn.md、归档页和友链页就不会再误显示文章头图。

时间线短代码改成竖线节点结构

About 页里我还顺手把 timeline 短代码重写了一版:

历史文件:git show c9cec9d:layouts/shortcodes/timeline.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<div class="timeline__item">
    <div class="timeline__point"></div>
    <div class="timeline__meta">{{ $date }}</div>
    <div class="timeline__panel">
        {{ if $url }}
            <a href="{{ $url }}" class="timeline__title">{{ $title }}</a>
        {{ else }}
            <div class="timeline__title">{{ $title }}</div>
        {{ end }}
    </div>
</div>

配套样式直接写在短代码内部,避免漏引用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<style>
    .timeline__item {
        position: relative;
        display: grid;
        grid-template-columns: 120px 24px minmax(0, 1fr);
        gap: 20px;
    }

    .timeline__item::before {
        content: "";
        position: absolute;
        left: 132px;
        top: 12px;
        bottom: -14px;
        width: 2px;
        background: linear-gradient(180deg, rgba(114, 142, 182, 0.4), rgba(114, 142, 182, 0.08));
    }
}
</style>

这里还用到了 .Page.Scratch 避免样式重复注入:

1
2
{{ if not (.Page.Scratch.Get "timeline-style-loaded") }}
{{ .Page.Scratch.Set "timeline-style-loaded" true }}

粒子背景、字体和看板娘入口继续收口

这一块虽然不是这次的唯一重点,但它们和首页观感关系很大。

粒子背景依然走 Hugo 资源加载

文件:layouts/partials/footer/custom.html

1
2
3
4
5
6
7
8
<div id="particles-js"></div>

<script src="{{ (resources.Get "background/particles.min.js").Permalink }}"></script>
<script>
  particlesJS.load('particles-js', '{{ (resources.Get "background/particlesjs-config_1.json").Permalink }}', function() {
    console.log('particles.js loaded - callback');
  });
</script>

资源路径:

1
2
3
4
assets/background/particles.min.js
assets/background/particlesjs-config_1.json
static/background/particles.min.js
static/background/particlesjs-config_1.json

本地字体继续用 Hugo 资源输出

文件:layouts/partials/footer/custom.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<style>
  @font-face {
    font-family: 'MapleMono';
    src: url({{ (resources.Get "font/MapleMono-NF-CN-Regular.woff2").Permalink }}) format('truetype');
  }

  @font-face {
    font-family: 'JetBrainsMono';
    src: url({{ (resources.Get "font/JetBrainsMono-Regular.woff2").Permalink }}) format('truetype');
  }

  :root {
    --base-font-family: 'MapleMono';
    --code-font-family: 'MapleMono', monospace;
  }
</style>

资源路径:

1
2
3
4
assets/font/MapleMono-NF-CN-Regular.woff2
assets/font/JetBrainsMono-Regular.woff2
assets/font/MapleMono-NF-CN-Regular.ttf
assets/font/JetBrainsMono-Regular.ttf

看板娘加载逻辑合并为一个入口

这一块主要是修“首次点击不直接显示”和“关闭后入口不恢复”的问题。

文件:layouts/partials/footer/custom.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<script>
    const cssPath = {{ (resources.Get "waifu/waifu.css").Permalink }};
    const waifuTipsPath = {{ (resources.Get "waifu/waifu-tips.json").Permalink }};

    let live2dLoadPromise = null;
    let live2dCloseBound = false;

    function revealLive2DWidget() {
        const btn = document.getElementById('load-waifu-btn');
        const toggle = document.getElementById('waifu-toggle');
        const widget = document.getElementById('waifu');

        if (toggle) {
            toggle.style.display = 'none';
        }

        if (widget) {
            widget.style.bottom = '0px';
            widget.style.display = 'block';
        }

        if (btn && (toggle || widget)) {
            btn.style.display = 'none';
        }
    }

    function hideLive2DWidget() {
        const btn = document.getElementById('load-waifu-btn');
        const widget = document.getElementById('waifu');

        if (widget) {
            widget.style.bottom = '-1000px';
        }

        if (btn) {
            btn.style.display = 'inline-flex';
        }
    }
</script>

相关资源路径:

1
2
3
4
5
6
7
assets/waifu/waifu.css
assets/waifu/waifu-tips.json
assets/waifu/Resources/model_list.json
assets/waifu/Resources/model/Haru/config.json
assets/waifu/Resources/model/Hiyori/config.json
assets/waifu/Resources/model/Mao/config.json
assets/waifu/Resources/model/Wanko/config.json

这一步主要是把第三方按钮藏起来,只保留我自己站点里的入口。


代码块折叠效果升级(双向展开/收起)

上一阶段建站记录里做的代码块折叠只能展开、不能收起,而且展开后横向滚动条会消失。这一轮把它整体重写了一版。

核心改动:

  • 展开/收起双向都有按钮
  • 折叠态用 max-height: 400px + overflow: hidden 裁剪
  • 展开态恢复 overflow: visiblepre 层单独保持 overflow-x: auto,修复横向滚动条
  • 遮罩从底部渐变淡出,深色/浅色各一套
  • 按钮改成 32×32px 纯圆形,只有 SVG 箭头,无文字

文件:layouts/partials/footer/custom.html

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
<!-- 代码块折叠展开(支持收起,可恢复横向滚动条)-->

<style>
    body.template-home .code-more-box {
        display: none !important;
    }

    /* 折叠态:裁剪高度,隐藏溢出 */
    .highlight.code-collapsed {
        max-height: 400px;
        overflow: hidden;
        transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
    }

    /* 展开态:恢复横向滚动条,不截断内容 */
    .highlight.code-expanded {
        max-height: none !important;
        overflow: visible;
        transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
    }
    /* pre 层保持横向可滚动 */
    .highlight.code-expanded > pre {
        overflow-x: auto;
    }

    /* ── 遮罩容器(折叠时显示) ── */
    .code-more-box {
        width: 100%;
        height: 80px;
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 2;
        /* 暗色模式渐变 */
        background: linear-gradient(
            to bottom,
            transparent 0%,
            rgba(39, 40, 34, 0.82) 100%
        );
        border-bottom-left-radius: 20px;
        border-bottom-right-radius: 20px;
        display: flex;
        align-items: flex-end;
        justify-content: center;
        padding-bottom: 10px;
        pointer-events: none;
        transition: opacity 0.2s ease;
    }

    .code-more-box.hidden {
        opacity: 0;
        pointer-events: none;
    }

    /* 浅色模式遮罩 */
    [data-scheme="light"] .code-more-box {
        background: linear-gradient(
            to bottom,
            transparent 0%,
            rgba(255, 249, 243, 0.9) 100%
        );
    }

    /* ── 展开/收起按钮:纯圆形箭头 ── */
    .code-more-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 32px;
        height: 32px;
        padding: 0;
        background: rgba(57, 62, 70, 0.78);
        border: 1px solid rgba(110, 68, 255, 0.45);
        border-radius: 50%;
        cursor: pointer;
        color: rgba(220, 210, 255, 0.95);
        transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
        user-select: none;
        pointer-events: auto;
        flex-shrink: 0;
    }
    .code-more-btn:hover {
        background: rgba(110, 68, 255, 0.6);
        border-color: rgba(110, 68, 255, 0.75);
        transform: scale(1.1);
    }
    [data-scheme="light"] .code-more-btn {
        background: rgba(230, 225, 255, 0.88);
        border-color: rgba(110, 68, 255, 0.38);
        color: rgba(70, 30, 180, 0.9);
    }
    [data-scheme="light"] .code-more-btn:hover {
        background: rgba(110, 68, 255, 0.18);
    }

    /* 收起按钮包裹层 */
    .code-collapse-btn-wrap {
        display: flex;
        justify-content: center;
        margin-top: 6px;
    }
    .code-collapse-btn-wrap .code-more-btn {
        background: rgba(57, 62, 70, 0.55);
    }

    /* 箭头 SVG */
    .code-more-arrow {
        display: block;
        width: 16px;
        height: 16px;
        flex-shrink: 0;
        transition: transform 0.2s ease;
    }
    .code-more-btn.rotated .code-more-arrow {
        transform: rotate(180deg);
    }
</style>

<script>
  function initCodeMoreBox() {
    if (document.body.classList.contains('template-home')) return;

    const COLLAPSED_H = 400;
    const arrowDown = `<svg class="code-more-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`;

    document.querySelectorAll('.highlight').forEach(block => {
      // 只处理真正超高的代码块
      if (block.scrollHeight <= COLLAPSED_H) return;

      // 初始折叠
      block.classList.add('code-collapsed');

      // ── 展开按钮(叠在遮罩上) ──
      const box = document.createElement('div');
      box.className = 'code-more-box';

      const expandBtn = document.createElement('span');
      expandBtn.className = 'code-more-btn';
      expandBtn.innerHTML = arrowDown;
      box.appendChild(expandBtn);
      block.appendChild(box);

      // ── 收起按钮(代码块下方,展开后才出现) ──
      const collapseWrap = document.createElement('div');
      collapseWrap.className = 'code-collapse-btn-wrap';
      collapseWrap.style.display = 'none';

      const collapseBtn = document.createElement('span');
      collapseBtn.className = 'code-more-btn rotated';
      collapseBtn.innerHTML = arrowDown;
      collapseWrap.appendChild(collapseBtn);
      // 插到 block 的后面
      block.insertAdjacentElement('afterend', collapseWrap);

      // ── 展开逻辑 ──
      expandBtn.addEventListener('click', () => {
        block.classList.remove('code-collapsed');
        block.classList.add('code-expanded');
        box.classList.add('hidden');
        collapseWrap.style.display = 'flex';
        window.dispatchEvent(new Event('resize'));
      });

      // ── 收起逻辑 ──
      collapseBtn.addEventListener('click', () => {
        // 若当前视口已低于代码块顶部,先滚回去
        const top = block.getBoundingClientRect().top + window.scrollY - 80;
        if (window.scrollY > top + COLLAPSED_H) {
          window.scrollTo({ top, behavior: 'smooth' });
        }
        setTimeout(() => {
          block.classList.remove('code-expanded');
          block.classList.add('code-collapsed');
          box.classList.remove('hidden');
          collapseWrap.style.display = 'none';
          window.dispatchEvent(new Event('resize'));
        }, 80);
      });
    });
  }
  initCodeMoreBox();
</script>

效果:超过 400px 的代码块初始折叠,底部有渐变遮罩和向下箭头按钮;点击展开后遮罩消失,代码块下方出现向上箭头按钮;点击收起时若视口已滚过代码块顶部会先平滑滚回再收起。


手机端悬浮目录按钮

桌面端有左侧固定目录栏,但手机端没有,看长文时要滚回顶部才能看目录。这一次在手机端右下角加了一个悬浮按钮,点击从右侧滑出目录面板。

实现思路:

  • HTML 结构直接写在根模板 baseof.html 里,由 JS 判断条件后决定是否显示
  • 样式写在 custom.scss,通过 @media (min-width: 1025px) { display: none !important; } 确保只在手机端生效
  • JS 克隆桌面端 .widget--toc 内的 #TableOfContents,强制展开所有层级,注入手机面板;用 MutationObserver 同步桌面 TOC 的 active-class 到手机面板

HTML 结构

文件:layouts/_default/baseof.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!-- 手机端悬浮目录按钮(仅在文章页且有TOC时显示,由JS控制) -->
<button id="mobile-toc-btn" type="button" aria-label="文章目录" style="display:none;">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="18" y2="18"/></svg>
</button>
<!-- 手机端目录弹窗遮罩 -->
<div id="mobile-toc-overlay" style="display:none;"></div>
<!-- 手机端目录弹窗内容 -->
<div id="mobile-toc-panel" style="display:none;">
    <div class="mobile-toc-header">
        <span class="mobile-toc-title">文章目录</span>
        <button class="mobile-toc-close" aria-label="关闭目录">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
        </button>
    </div>
    <div id="mobile-toc-content"></div>
</div>

SCSS 样式

文件:assets/scss/custom.scss

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
// ============================================================
// 手机端悬浮目录按钮 & 弹窗样式
// 仅在 max-width: 1024px 下显示,适配深色/浅色两套主题
// ============================================================

// ---- 悬浮按钮 ----
#mobile-toc-btn {
  display: none; // 默认隐藏,由 JS 在合适条件下开启
  position: fixed;
  right: 16px;
  bottom: 110px; // 在 back-to-top 按钮上方
  z-index: 1000;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: none;
  cursor: pointer;
  outline: none;
  align-items: center;
  justify-content: center;
  padding: 0;

  background: linear-gradient(135deg, rgba(110, 68, 255, 0.88) 0%, rgba(80, 40, 190, 0.9) 100%);
  color: #fff;
  box-shadow: 0 4px 14px rgba(110, 68, 255, 0.4), 0 0 0 1px rgba(110, 68, 255, 0.25);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  transition: transform 0.22s ease, box-shadow 0.22s ease, background 0.22s ease;

  svg { display: block; flex-shrink: 0; }

  &:hover, &:active {
    transform: scale(1.08);
    box-shadow: 0 6px 20px rgba(110, 68, 255, 0.55), 0 0 0 1px rgba(110, 68, 255, 0.4);
  }

  &.toc-active {
    background: linear-gradient(135deg, rgba(80, 40, 190, 0.95) 0%, rgba(52, 73, 94, 0.95) 100%);
  }

  [data-scheme="dark"] & {
    background: linear-gradient(135deg, rgba(110, 68, 255, 0.75) 0%, rgba(57, 62, 70, 0.9) 100%);
    box-shadow: 0 4px 14px rgba(110, 68, 255, 0.3), 0 0 0 1px rgba(110, 68, 255, 0.2);
    color: rgba(255, 255, 255, 0.92);
    &.toc-active {
      background: linear-gradient(135deg, rgba(57, 62, 70, 0.95) 0%, rgba(34, 40, 49, 0.98) 100%);
    }
  }

  @media (min-width: 1025px) { display: none !important; }
}

// ---- 遮罩层 ----
#mobile-toc-overlay {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 1001;
  background: rgba(0, 0, 0, 0.45);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  opacity: 0;
  transition: opacity 0.3s ease;

  &.visible { opacity: 1; }

  [data-scheme="dark"] & { background: rgba(0, 0, 0, 0.6); }

  @media (min-width: 1025px) { display: none !important; }
}

// ---- 目录弹窗面板 ----
#mobile-toc-panel {
  display: none;
  position: fixed;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 1002;
  width: 80vw;
  max-width: 320px;
  overflow-y: auto;
  overscroll-behavior: contain;

  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(20px) saturate(1.8);
  -webkit-backdrop-filter: blur(20px) saturate(1.8);
  border-left: 1px solid rgba(110, 68, 255, 0.18);
  box-shadow: -6px 0 32px rgba(110, 68, 255, 0.12), -2px 0 8px rgba(0, 0, 0, 0.08);

  transform: translateX(100%);
  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  padding: 0;

  &.open { transform: translateX(0); }

  [data-scheme="dark"] & {
    background: rgba(39, 40, 50, 0.88);
    border-left: 1px solid rgba(110, 68, 255, 0.22);
    box-shadow: -6px 0 32px rgba(0, 0, 0, 0.4), -2px 0 8px rgba(0, 0, 0, 0.3);
  }

  @media (min-width: 1025px) { display: none !important; }
}

// ---- 弹窗头部 ----
.mobile-toc-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 16px 12px 20px;
  border-bottom: 1px solid rgba(110, 68, 255, 0.12);
  position: sticky;
  top: 0;
  z-index: 10;
  background: inherit;
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);

  [data-scheme="dark"] & { border-bottom-color: rgba(110, 68, 255, 0.18); }
}

.mobile-toc-title {
  font-size: 1.5rem;
  font-weight: 600;
  color: rgba(52, 73, 94, 0.9);
  letter-spacing: 0.03em;

  [data-scheme="dark"] & { color: rgba(255, 255, 255, 0.88); }
}

.mobile-toc-close {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: none;
  cursor: pointer;
  background: rgba(110, 68, 255, 0.1);
  color: rgba(110, 68, 255, 0.85);
  transition: background 0.2s ease;

  &:hover { background: rgba(110, 68, 255, 0.22); }

  [data-scheme="dark"] & {
    background: rgba(110, 68, 255, 0.15);
    color: rgba(180, 160, 255, 0.9);
    &:hover { background: rgba(110, 68, 255, 0.28); }
  }
}

// ---- 目录内容区 ----
#mobile-toc-content {
  padding: 8px 0 28px 0;

  #MobileTOC,
  #TableOfContents {
    ul, ol {
      display: block !important;
      margin: 0;
      padding: 0;
      list-style: none;
    }

    li { margin: 0; padding: 0; position: relative; }

    a {
      display: flex;
      align-items: baseline;
      gap: 6px;
      padding: 7px 16px 7px 0;
      line-height: 1.45;
      text-decoration: none;
      border-left: 2px solid transparent;
      transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
      color: var(--card-text-color-secondary, rgba(52, 73, 94, 0.75));

      &:hover {
        color: rgba(110, 68, 255, 0.95);
        border-left-color: rgba(110, 68, 255, 0.35);
        background: rgba(110, 68, 255, 0.05);
      }

      [data-scheme="dark"] & {
        color: rgba(255, 255, 255, 0.6);
        &:hover {
          color: rgba(180, 160, 255, 0.95);
          background: rgba(110, 68, 255, 0.08);
        }
      }
    }

    li.active-class > a {
      border-left-color: rgba(110, 68, 255, 0.85);
      color: rgba(110, 68, 255, 1);
      font-weight: 600;
      background: rgba(110, 68, 255, 0.07);

      [data-scheme="dark"] & {
        color: rgba(190, 170, 255, 1);
        border-left-color: rgba(180, 150, 255, 0.9);
        background: rgba(110, 68, 255, 0.12);
      }
    }

    // 一级
    > ul > li, > ol > li {
      border-top: 1px solid rgba(110, 68, 255, 0.08);
      &:first-child { border-top: none; }
      [data-scheme="dark"] & { border-top-color: rgba(255, 255, 255, 0.06); }

      > a {
        padding-left: 20px;
        font-size: 1.5rem;
        font-weight: 600;
        color: var(--card-text-color-main, rgba(20, 20, 35, 0.88));
        &::before {
          content: '';
          display: inline-block;
          width: 6px; height: 6px;
          border-radius: 50%;
          background: rgba(110, 68, 255, 0.55);
          flex-shrink: 0; margin-top: 1px;
          transition: background 0.15s ease;
        }
        [data-scheme="dark"] & {
          color: rgba(255, 255, 255, 0.85);
          &::before { background: rgba(180, 150, 255, 0.6); }
        }
      }
      &.active-class > a::before {
        background: rgba(110, 68, 255, 1);
        [data-scheme="dark"] & { background: rgba(190, 170, 255, 1); }
      }
    }

    // 二级
    > ul > li > ul > li, > ol > li > ol > li {
      > a {
        padding-left: 32px;
        font-size: 1.38rem;
        font-weight: 400;
        &::before {
          content: '';
          display: inline-block;
          width: 4px; height: 4px;
          border-radius: 50%;
          border: 1.5px solid rgba(110, 68, 255, 0.5);
          background: transparent;
          flex-shrink: 0;
        }
        [data-scheme="dark"] & { &::before { border-color: rgba(180, 150, 255, 0.5); } }
      }
      &.active-class > a::before {
        background: rgba(110, 68, 255, 0.7);
        border-color: rgba(110, 68, 255, 0.7);
        [data-scheme="dark"] & {
          background: rgba(190, 170, 255, 0.8);
          border-color: rgba(190, 170, 255, 0.8);
        }
      }
    }

    // 三级及更深
    > ul > li > ul > li > ul > li, > ol > li > ol > li > ol > li {
      > a {
        padding-left: 44px;
        font-size: 1.28rem;
        color: var(--card-text-color-tertiary, rgba(52, 73, 94, 0.6));
        &::before {
          content: '–';
          font-size: 10px;
          color: rgba(110, 68, 255, 0.4);
          background: none; width: auto; height: auto; border: none; border-radius: 0;
          [data-scheme="dark"] & { color: rgba(180, 150, 255, 0.4); }
        }
        [data-scheme="dark"] & { color: rgba(255, 255, 255, 0.5); }
      }
    }

    // 四级+
    > ul > li > ul > li > ul > li > ul > li > a,
    > ol > li > ol > li > ol > li > ol > li > a {
      padding-left: 56px;
      font-size: 1.2rem;
    }
  }
}

JS 逻辑

文件:layouts/partials/footer/custom.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<!-- 手机端悬浮目录按钮逻辑 -->
<script>
(function() {
    function initMobileToc() {
        // 只在手机端(宽度 <= 1024px)且有 TOC 的文章页运行
        if (window.innerWidth > 1024) return;
        if (!document.body.classList.contains('article-page')) return;

        const desktopToc = document.querySelector('.widget--toc');
        if (!desktopToc) return;

        const tocBtn = document.getElementById('mobile-toc-btn');
        const tocOverlay = document.getElementById('mobile-toc-overlay');
        const tocPanel = document.getElementById('mobile-toc-panel');
        const tocContent = document.getElementById('mobile-toc-content');
        const tocClose = tocPanel ? tocPanel.querySelector('.mobile-toc-close') : null;
        if (!tocBtn || !tocOverlay || !tocPanel || !tocContent) return;

        // 克隆桌面端 TOC 内容,强制展开所有子列表
        const tocInner = desktopToc.querySelector('#TableOfContents');
        if (!tocInner) return;
        const clonedToc = tocInner.cloneNode(true);
        clonedToc.id = 'MobileTOC';
        clonedToc.querySelectorAll('ul, ol').forEach(el => {
            el.style.display = 'block';
            el.classList.remove('open');
        });
        tocContent.appendChild(clonedToc);

        tocBtn.style.display = 'inline-flex';

        let isOpen = false;

        function openToc() {
            isOpen = true;
            tocOverlay.style.display = 'block';
            tocPanel.style.display = 'block';
            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    tocOverlay.classList.add('visible');
                    tocPanel.classList.add('open');
                });
            });
            tocBtn.classList.add('toc-active');
            document.body.style.overflow = 'hidden';
        }

        function closeToc() {
            isOpen = false;
            tocOverlay.classList.remove('visible');
            tocPanel.classList.remove('open');
            tocBtn.classList.remove('toc-active');
            document.body.style.overflow = '';
            setTimeout(() => {
                if (!isOpen) {
                    tocOverlay.style.display = 'none';
                    tocPanel.style.display = 'none';
                }
            }, 360);
        }

        tocBtn.addEventListener('click', () => { isOpen ? closeToc() : openToc(); });
        tocOverlay.addEventListener('click', closeToc);
        if (tocClose) tocClose.addEventListener('click', closeToc);

        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && isOpen) closeToc();
        });

        // 同步高亮活跃标题到手机弹窗
        const observer = new MutationObserver(() => {
            if (!isOpen) return;
            clonedToc.querySelectorAll('.active-class').forEach(el => el.classList.remove('active-class'));
            desktopToc.querySelectorAll('.active-class').forEach(el => {
                const href = el.querySelector('a') ? el.querySelector('a').getAttribute('href') : null;
                if (href) {
                    const match = clonedToc.querySelector(`a[href="${href}"]`);
                    if (match && match.parentElement) {
                        match.parentElement.classList.add('active-class');
                    }
                }
            });
        });
        observer.observe(desktopToc, { subtree: true, attributes: true, attributeFilter: ['class'] });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initMobileToc);
    } else {
        initMobileToc();
    }
})();
</script>

效果:手机端(≤1024px)文章页右下角出现紫色圆形悬浮按钮,点击从右侧滑入毛玻璃目录面板,完整展示所有标题层级,当前阅读位置同步高亮,点击遮罩/关闭按钮/ESC 关闭面板。

这一轮里改到的关键文件

后面如果我要继续沿着这篇记录往下写,最常回头看的应该还是这些文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
layouts/_default/baseof.html
layouts/index.html
layouts/partials/footer/footer.html
layouts/partials/footer/custom.html
layouts/partials/pagination.html
layouts/partials/widget/category_context.html
layouts/partials/article-list/compact.html
layouts/shortcodes/timeline.html
themes/hugo-theme-stack/layouts/page/search.html
themes/hugo-theme-stack/layouts/partials/sidebar/left.html
themes/hugo-theme-stack/layouts/partials/helper/image.html
themes/hugo-theme-stack/layouts/partials/article/components/header.html
themes/hugo-theme-stack/assets/ts/main.ts
themes/hugo-theme-stack/assets/ts/search.tsx
assets/scss/style.scss
assets/scss/custom.scss
assets/scss/partials/footer.scss
hugo.yaml
content/page/about/index.zh-cn.md

小结

这一次和最开始建站最大的区别,是我不再满足于“博客能跑起来”,而是开始把每一个细节收成更适合自己的东西。

有些地方看起来只是一个小改动,比如:

  • 默认封面回退
  • 搜索页表单绑定
  • 分类页相关标签去重
  • 移动端主题按钮位置
  • 欢迎语里光标跟随消息轨道

但真正动手改的时候,往往会同时牵动模板、SCSS、JS、配置文件和资源路径。
也正因为这样,这一轮优化虽然细碎,但整体上比最开始建站时更像一次真正的“站点个性化”。

后面大概还会继续写下去,尤其是首页欢迎区、分页细节和独立页面统一这几块,应该还会继续打磨。