Skip to content

个人博客空间

后续使用指南:博客使用指南

项目介绍

心痒痒:

还是对个人博客/作品网的搭建”贼心不死“

已经确定原始笔记采用Markdown形式,现在的问题是如何构建一个项目(网站),然后还有承载这个项目的服务器(要么给大型网站当孙子分一个服务器,要么自己买一个云服务器)

确认方案

我的需求

这是对AI说的重要Prompt,表达了我的初始需求与目标。

我有一个想法,做一个自己的博客网/作品网,因为我有用markdown写笔记和教程,包括编程,摄影,修图剪辑,office等等内容,还有我自己拍的照片和视频。我用的是typora,然后也有图床和阿里云oss存图片。我应该用什么方案我想做一个属于自己的网站,能够查看这些内容,尤其是好照片和markdown笔记博客。我以前也用hexo➕githubpages搞过博客网,但是随着内容的增加,每次上传都要更新所有文件太慢了,而且我的笔记在某个位置A,我为了上传git必须再搞一个目录B用来专门存放要上传的笔记,同样的内容我又复制了一份,对于内容管理来说太麻烦了。我想现在的情况大概是这样,一方面我需要搞定这个项目的网页显示,一方面我需要有一个挂载服务器。网页我大概率是用现有的漂亮的架构来做,服务器我暂时不太想自己买一个云服务器,可能还是挂载到github或者其他有保障的大平台。你见多识广,根据我的需求,你推荐有什么方法呢?

技术方案

这是一个非常“懂行”的配置组合。Typora (本地编辑体验天花板) + Aliyun OSS (解决了图床和宽带问题),这说明你已经解决了最麻烦的静态资源管理问题

现在的瓶颈纯粹在于**“内容同步”“构建效率”:既然你是计科专业的,你应该拒绝“手动复制粘贴”这种低效操作。结合你的需求(代码、Office教程、摄影作品展示),我为你推荐“VitePress + 软链接 (Symlink) + Cloudflare Pages”** 的方案

为什么选择这个方案

  1. **VitePress (架构层):**速度快、适配优、扩展好

    **速度够快:**基于 Vite 和 Vue 3,本地启动和热更新是毫秒级的。以前用过的Hexo框架,命令hexo g 随着文章变多会越来越慢,但是VitePress 不会

    气质契合: VitePress 也就是 Vue 官方文档的生成器。它的默认风格非常适合**“技术教程”**(你的编程和Office内容),左侧目录树,右侧内容,逻辑清晰

    Vue 扩展性: 你提到有摄影作品。在 Hexo 里改相册很难受,但在 VitePress 里,你可以直接写一个简单的 Vue 组件(或者找个现成的 Lightbox 插件),在 Markdown 里像写 HTML 一样插入相册,配合你的阿里云 OSS 链接,加载速度飞快

  2. **软链接 / Symbolic Link (解决复制痛点):**用文件链接实现笔记的双地址管理

    这是计算机系学生该用的方法。欺骗文件系统,而不是移动文件

    你可以保持你的笔记在 D:/MyKnowledge/ 不动,让博客项目的 docs 目录通过“软链接”直接指向你的笔记目录

    效果: 你在 Typora 里保存笔记,浏览器里的博客预览界面实时自动刷新。不需要复制,不需要点同步

  3. Cloudflare Pages (服务器层):

    免费且快: 比 GitHub Pages 快,且自带 CDN

    解决了 HTTPS 和 域名: 自动分配二级域名,支持绑定自定义域名

我的疑惑

与AI再次交流:对于这个方案,我的问题是:

  1. 既然我要放基础的md文档、Office文件、图片(甚至是视频),那么vite框架有足够丰富的插件实现这些内容的展示放映吗?以及我是直接下载vite纯净文件搭建博客还是用别人的基于vite的二次开发的框架,哪个好?
  2. 对于我的博客文件的管理,比如说我把原始笔记放在D:\PandaC\StudyNotes\MyKnowledge 目录之下,然后博客网的目录是D:\MyBlog ,你说的软链接是指在MyBlog目录下将笔记指向MyKnowledge目录吗?如果说我的MyKnowledge目录里面有很多文件夹和文件,我要怎么做网站上笔记类型的区分?比如说这个是旅游板块,那个是编程板块,另一个又是作品展示模块,各自的板块里面又有好多分类,最终可以导向若干个笔记或者作品。这样的分类逻辑是要我在MyBlog目录下的网站文件中写好,还是在我本地的原始文件目录下规划好?以及为了不上传私人笔记,需要搞一个Public目录在MyKnowledge目录里面是吗?那里面的笔记和作品需要我分类吗?
  3. 还有,当我今天新写了几份笔记或者新建了几份作品,我要如何上传到博客网?需要用到git对吗?那上传会不会很慢?我看到你还有提到自己写一个本地脚本来执行上传功能,这样到底是节省了时间还是浪费了时间?毕竟用的东西越多,这个工作流就越冗余。

方案定型

你问到了“工作流”和“架构”的核心,这正是很多技术博主最容易踩坑的地方。针对你的四个疑问拆解最实际的逻辑:

Q1: VitePress 能否胜任多种格式(Office, 视频)的展示?

答案是:Markdown 和 图片/视频完美支持,Office 文件建议“轻量化”处理

  • Markdown & 图片: 这是 VitePress 的本命技能。特别是你已经有了阿里云 OSS 图床,Markdown 里全是 URL,对 VitePress 来说渲染压力为零

  • 视频: 不需要任何插件。Markdown 支持原生 HTML,并且也可以导入现有插件ArtPlayer

  • Office 文件 (Word/PPT/PDF):

    展示: 网页原生是不支持直接渲染 .docx.pptx 的(任何框架都一样)。通常的做法是转成 PDF。浏览器原生支持 PDF 预览

    插件: 如果非要在线看 Word,有 vue-office 这样的组件,但体验通常不如直接下载或看 PDF

    建议: 你的 Office 教程如果是文字版,直接转 Markdown;如果是必须要展示原件,提供一个“点击下载”或“PDF 预览”体验最好

Q2: 纯净版 VitePress vs. 二次开发框架(主题)?

强烈建议:直接使用官方纯净版 VitePress(Default Theme)

  • 理由 1(稳定性): 很多第三方魔改的主题(类似 Hexo 的 Butterfly)虽然花哨,但作者维护不稳定。VitePress 官方版本更新极快,用纯净版能保证你永远跟得上最新特性,不会报错
  • 理由 2(你的身份): 你是计科学生,未来的从业者。VitePress 的默认主题已经非常简洁大气(Vue 官方文档同款)。如果你想要修改样式(比如加个背景图、改个字体),你自己写几行 CSS 或者 Vue 组件就能搞定,这比去读别人的文档要快得多,也更符合你的专业技能树
  • 结论: 不要去用那些臃肿的第三方主题,官方默认主题 + 少量自定义配置 是最高效的

Q3: 目录结构、软链接逻辑与分类管理

这部分是重中之重。你的理解基本正确,但需要微调一下以适应“Git 上传”。

1. 必须搞一个 Public 目录吗?

  • **必须。**你的 D:\PandaC\StudyNotes\MyKnowledge 里肯定有私人日记、账号密码备份等。
  • 操作: 在里面新建一个文件夹叫 Blog_Public(或者就叫 Public)。只有放进 Blog_Public 里的东西,才会出现在互联网上。

2. 分类逻辑在哪里做?(本地 vs 配置文件)

  • 最高效的方案:本地文件夹即分类

  • **目录结构建议:**示例如下(实际情况自行变动)

    Plaintext
    D:\PandaC\StudyNotes\MyKnowledge\Blog_Public\
    ├── 01_Coding           <-- 编程板块
    │   ├── Java笔记.md
    │   └── Python教程.md
    ├── 02_Photography      <-- 摄影板块
    │   ├── 重庆夜景.md
    │   └── 人像修图.md
    └── 03_Portfolio        <-- 作品展示
        └── 毕业设计.md
  • 网站映射: VitePress 不需要你手动去配置每一个文件的路径。你可以配置它的 Sidebar(侧边栏)自动读取文件夹结构,或者手动指定“编程板块”读取 /01_Coding/ 下的所有文件

  • 结论: 在你本地的原始文件目录下规划好。这样你写笔记的时候,把文件往对应文件夹一拖,分类就完成了

3. 软链接的具体指向

  • 是的,是指向 MyBlog 下的内容目录
  • 假设 VitePress 的源目录是 D:\MyBlog\docs。你应该把 D:\PandaC\StudyNotes\MyKnowledge\Blog_Public 软链接到 D:\MyBlog\docs。这样,VitePress 启动时,会以为那些笔记就在它的肚子(docs)里

Q4: 上传机制、Git 与“脚本”的必要性

这是你最担心的“冗余”问题。让我解释为什么脚本反而是最省事

痛点: Git 无法直接跟踪“软链接指向的外部文件”。 如果你只用软链接,你在 MyBlog 目录下 git add .,Git 只会存一个“快捷方式”,而不是你的笔记内容。Cloudflare 在云端拉取你的仓库时,找不到那个外部目录,网站就空了

所以,你需要一个“同步脚本” (Sync Script)。不是为了增加冗余,而是为了自动化“把笔记从私有库搬运到博客发布库”的过程

你的终极工作流(只需点击一次):

  1. 写笔记:D:\PandaC\...\Blog_Public 里写,Typroa 随便改,图片随便插
  2. 发布: 双击D:\MyBlog里的 deploy.bat(Windows 批处理脚本)

这个脚本里写什么?(核心逻辑)

这个脚本大概只有 5 行代码,它帮你在几秒钟内完成以下动作:

  1. Robocopy (镜像同步): 把你的 Blog_Public 文件夹的内容,物理复制(镜像)到 D:\MyBlog\docs

    注:Robocopy 是 Windows 自带的神器,速度极快,只复制有变动的文件

  2. Git Add/Commit/Push: 自动进入 D:\MyBlog,执行 git 命令推送到 GitHub

回答你的顾虑:

  • 上传会不会很慢?

    极快。 因为图片都在阿里云 OSS(图床),你上传到 GitHub 的只有纯文本 Markdown 代码。几百篇笔记也就几 MB,秒传

    Cloudflare 检测到 GitHub 变动,自动在云端构建,通常 1-2 分钟网站就更新了

  • 脚本是浪费时间吗?

    它是节省时间的神器。 它可以让你完全不用管 MyBlog 这个项目目录。你只需要关注你的笔记目录

    你想想,如果没有这个脚本,每次写完文章,你得:打开博客目录 -> 手动复制文件 -> 打开 Git 终端 -> 输入三行命令。这才是冗余

总结方案的可行性

这个方案对你(CS 大四学生)来说是性价比最高的:

  1. 内容层: 继续用 Typora + OSS,体验无敌
  2. 管理层: 本地建立 Blog_Public 文件夹分类,物理隔离隐私笔记
  3. 技术层: 纯净版 VitePress,不够用了再加 Vue 组件
  4. 自动化层: 写一个 .bat 脚本(用到 Robocopy 和 Git),实现一键发布

项目构建

这个方案的核心逻辑是:“本地双目录架构” + “脚本一键同步”

①初始化 VitePress

我们要先在本地把博客项目的“壳子”搭好

  1. 创建博客项目文件夹 不要在你的笔记目录里建,找一个干净的地方(比如 D:\MyBlog)。 打开终端(CMD 或 PowerShell),进入该目录:

    powershell
    mkdir D:\MyBlog
    cd D:\MyBlog
  2. 初始化项目 依次执行以下命令(全默认回车即可):

    powershell
    # 1. 初始化 package.json
    npm init -y
    
    # 2. 安装 VitePress (这步可能需要几十秒)
    npm add -D vitepress
    
    # 3. 启动安装向导
    npx vitepress init

    在安装向导中的选择建议:

    • Where should VitePress initialize the config? -> 直接回车 (默认 ./)
    • Site title: -> 输入你的博客名,比如 PandaC's Blog
    • Site description: -> A cool CS student's portfolio
    • Theme: -> Default Theme (选默认的,最稳)
    • Use TypeScript? -> No (除非你特别想用 TS,否则 JS 够用且简单)
    • Add VitePress npm scripts to package.json? -> Yes
  3. 第一次启动预览 安装完成后,输入:

    npm run docs:dev (本地调试的方式,不用像在线调试那样每次都去刷新网页)

    终端会出现一个 http://localhost:5173 的地址。按住 Ctrl 点击它,浏览器打开。 🎉 恭喜!你的博客雏形已经在他电脑上跑起来了。

②规范笔记目录

笔记管理流程

D:\PandaC\StudyNotes\MyKnowledge 目录下,新建一个文件夹,命名为 Blog_Public(或者 Public_Notes);然后在 Blog_Public 里随便建两个测试文件夹和文件,并且写点文字

以后的笔记管理方法:

  • ❌ 私人日记、账号密码 -> 放在 MyKnowledge 根目录下。

  • ✅ 要发的教程、作品 -> 放在 MyKnowledge\Blog_Public 里面。

搞好笔记原始地址博客项目地址的管理:笔记原始地址在D:\PandaC\StudyNotes\MyKnowledge\Blog_Public,都放在Blog_Public目录里面了;博客项目地址在D:\MyBlog\docs,用脚本会自动镜像上传

③编写“一键同步脚本” (deploy.bat)

我们要写一个脚本,它的作用是:Blog_Public 里的东西,“镜像”复制到 D:\MyBlog\docs 下,然后自动提交 Git

D:\MyBlog 根目录下,新建一个文本文档,重命名为 deploy.bat 右键 -> 编辑,把下面的代码粘贴进去(注意修改里面的路径为你自己的实际路径!):

bash
@echo off
echo =======================================================
echo          PandaC Blog Auto Deploy Script
echo =======================================================

:: 设置路径变量 (请修改这里!!!)
:: SOURCE_DIR: 你原本笔记存放的位置 (只同步公开的部分)
set SOURCE_DIR="D:\PandaC\StudyNotes\MyKnowledge\Blog_Public"

:: TARGET_DIR: 博客项目的文档存放位置 (通常是 docs 目录)
set TARGET_DIR="D:\MyBlog\docs"

echo [1/3] Syncing files from Knowledge Base to Blog...
:: 使用 robocopy 进行镜像复制
:: /MIR: 镜像模式 (源目录删了文件,目标目录也会删,保持完全一致)
:: /XD: 排除不需要同步的文件夹 (比如 .git)
robocopy %SOURCE_DIR% %TARGET_DIR% /MIR /XD .git .vitepress

echo.
echo [2/3] Adding changes to Git...
git add .

echo.
echo [3/3] Committing changes...
:: 获取当前日期时间作为 commit 信息
set d=%date:~0,10%
set t=%time:~0,8%
git commit -m "Auto update: %d% %t%"

echo.
echo [4/3] Pushing to GitHub...
:: 注意:你还没关联 GitHub,这一步第一次跑会报错,等会儿关联了就好了
git push origin main

echo.
echo =======================================================
echo             Deployment Complete!
echo =======================================================
pause

保存关闭即可

④准备Git仓库

准备流程

现在脚本写好了,但是你的 D:\MyBlog 还不是一个 Git 仓库,也没关联 GitHub

  1. 初始化 GitD:\MyBlog 目录下打开终端:git init

  2. 创建 .gitignore 文件D:\MyBlog 下新建一个文件叫 .gitignore (没有文件名,只有后缀),用记事本打开,填入以下内容(防止把垃圾文件传上去):

    node_modules
    .DS_Store
    dist
    cache
  3. 在 GitHub 上建仓,登录你的 GitHub,右上角 + -> New repository

    Repository name 填 my-blog (或者任何你喜欢的名字)

    Public (必须是 Public,Cloudflare 免费版才好用),其他都不勾选,点 Create repository

  4. 关联并推送 GitHub 会给你跳出一个页面,找到 "…or push an existing repository from the command line" 那一栏的代码。 在你的 D:\MyBlog 终端里执行那几行代码:

    powershell
    git remote add origin https://github.com/你的用户名/my-blog.git
    git branch -M main
    git push -u origin main

    (如果这一步报错,可能是你还没在本地登录 Git,需要配置 git config --global user.name 等)

报错解决

一番战

报错代码:

powershell
D:\MyBlog>git init
Initialized empty Git repository in D:/MyBlog/.git/

D:\MyBlog>git remote add origin https://github.com/你的用户名/my-blog.git

D:\MyBlog>git branch -M main

D:\MyBlog>git push -u origin main
error: src refspec main does not match any
error: failed to push some refs to 'https://github.com/你的用户名/my-blog.git'

解决方法:

  1. 暂存所有文件(装包):git add .

    (注意 add 后面有一个空格和一个点,意思是把当前目录下所有文件放入暂存区)

  2. 提交文件 (封口):git commit -m "首次提交:初始化博客"

    (这一步执行完,你会看到一大串提示 create mode...,说明文件被记录下来了,此时 main 分支才正式诞生)

  3. 再次推送(发货):git push -u origin main

然后登录github即可,如果看到 Branch 'main' set up to track remote branch 'main' from 'origin'.,那就是大功告成


二番战

报错代码:

powershell
D:\MyBlog>git add .
xxx

D:\MyBlog>git commit -m "首次提交:初始化博客"
[main (root-commit) 7a9790e] 首次提交:初始化博客
 8 files changed, 2717 insertions(+)
 create mode 100644 .gitignore
 xxx

D:\MyBlog>git push -u origin main
remote: Invalid username or token. Password authentication is not supported for Git operations.
fatal: Authentication failed for 'https://github.com/pandac-blog/my-blog.git/'

问题所在:

这又是一个经典的“新手墙”,每一个刚开始用 Git 的人都会撞一次

  • 原因很简单: 为了安全,GitHub 在 2021 年取消了“用账户密码直接在命令行登录”的功能。 报错信息 Password authentication is not supported 说的就是这个意思:你刚刚输入的那个密码(你的 GitHub 登录密码)是不被接受的

解决方法:

  1. 去github生成令牌(Token):打开浏览器,登录你的 GitHub。点击右上角头像 -> Settings (设置)。在左侧侧边栏拉到最下面 -> Developer settings (开发者设置)

    左侧点击 Personal access tokens -> Tokens (classic)

    • 注意:一定要选 Classic (经典版),不要选 Beta

    点击右边的 Generate new token (classic)

    • Note (备注): 随便填,比如 MyBlogToken
    • Expiration (过期时间): 建议选 No expiration (不过期),省得以后麻烦
    • Select scopes (选择权限): 这是重点! 务必勾选第一个 repo (把 repo 下面的子选项全选上)。这代表允许这个令牌控制你的仓库

    拉到最底,点击绿色按钮 Generate token

    ⚠️ 关键时刻: 你会看到一串以 ghp_ 开头的乱码字符串。 立刻、马上把它复制下来,保存在你的记事本里! (因为如果你刷新页面,这串代码就再也看不到了,只能重新生成)

  2. 清除旧的错误凭证 (防止电脑自作聪明)

    因为你刚才失败了一次,Windows 可能会傻傻地记住那个错误的密码,导致你下次不再弹窗询问。我们先把它清掉

    点击电脑左下角搜索框,搜 "凭据管理器""Credential Manager" 并打开

    点击 "Windows 凭据" (Windows Credentials)

    在列表里找有没有 git:https://github.com 这一项

    • 如果有: 点击它,选择 删除 (Remove)如果没有: 直接跳过这一步
  3. 再次推送 (见证奇迹)

    回到你的 CMD 黑框框(D:\MyBlog 目录下),再次输入:

    bash
    git push -u origin main

    这时候会出现两种情况:

    情况 A:弹出一个图形界面的登录框

    点击 "Sign in with your browser" (浏览器登录)。浏览器授权一下,立刻就会成功。

    情况 B:终端提示输入 Username 和 Password

    Username for 'https://github.com': 输入你的 GitHub 用户名 (不是邮箱)

    Password for 'https://github.com': 这里粘贴你刚才复制的那串 ghp_... 的 Token!

⑤连接Cloudflare Pages

然后进入最后的“高光时刻”:把这个仓库连接到 Cloudflare Pages,生成一个全世界都能访问的 HTTPS 网址

第一步:注册/登录 Cloudflare

  1. 打开 https://dash.cloudflare.com/
  2. 登录后,在左侧侧边栏找到并点击 "Workers & Pages"

第二步:创建 Pages 项目

  1. 点击页面上的蓝色按钮 "Create application" (创建应用)

  2. 在上方选项卡中,点击 "Pages"

  3. 点击 "Connect to Git" (连接到 Git)

  4. 选择 GitHub 页签,然后点击 "Connect to GitHub"

    *如果是第一次,它会弹窗让你授权,选 All repositories (所有仓库) 即可

  5. 在列表里选中你刚才推上去的仓库 my-blog,点击 "Begin setup"

第三步:配置构建环境 (关键!别填错)

Cloudflare 会自动识别你的仓库,但我们需要告诉它这是一个 VitePress 项目

"Set up builds and deployments" 页面:

  1. Project name: 保持默认(比如 my-blog),这决定了你的初始域名(例如 my-blog.pages.dev

  2. Production branch: 默认应该是 main,不用动

  3. Framework preset (框架预设):

    点击下拉菜单,找到并选择 VitePress

  4. Build command (构建命令):

    选了预设后,这里应该会自动变成 npm run docs:build

    如果没有变,请手动填入:* npm run docs:build

  5. Build output directory (构建输出目录):

    选了预设后,这里可能会自动变成 docs/.vitepress/dist

    ⚠️ 注意: 根据你刚才提交的文件结构(package.json 在根目录),你的输出目录大概率是在根目录下的。请把这里手动改为: .vitepress/dist

    (以此为准:因为你的 config.mjs 是在 .vitepress 文件夹里,而不是 docs/.vitepress)

  6. 点击底部的 "Save and Deploy" (保存并部署)

第四步:等待起飞

Cloudflare 现在会开始疯狂工作:拉取代码 -> 安装 Node 环境 -> 执行构建命令 -> 分发到全球 CDN。

你会看到一个控制台日志在跑,大概等待 1-2 分钟。当看到 "Success! Your site is deployed" 的绿色提示时,点击那个链接(通常是 https://my-blog-xxx.pages.dev

第五步:进阶优化(修复“最后更新时间”失效问题)

你可能会发现一个 BUG:当你查看网站时,所有文章的“最后更新时间”都显示为“刚刚”(即构建的那一刻),而不是你真正写文章的时间。

原因: Cloudflare 为了构建速度,默认只下载你仓库的最后一次提交记录(这叫浅克隆),导致它不知道文章的历史修改时间,以为所有文件都是新创建的。

解决方法:

  1. 在 Cloudflare 的项目页面,点击顶部的 Settings (设置) 选项卡。

  2. 在左侧菜单点击 Builds & deployments (构建和部署)

  3. 找到 Build configurations (构建配置) 区域,点击右侧的 "Edit" (编辑) 按钮。

  4. 修改 Build command (构建命令)

    • 将原来的 npm run docs:build
    • 修改为: git fetch --unshallow && npm run docs:build
    • (这段代码的意思是:先强行把所有历史记录扒下来,然后再开始构建)
  5. 点击 Save (保存)

  6. 手动触发一次部署

    你可以回到 Deployments (部署) 选项卡,点击最新的那次部署右边的三个点,选择 Retry deployment (重试部署)

    或者随便修改一下你本地的一个文件(比如加个空行),推送到 GitHub,Cloudflare 就会自动触发新的构建。

等待这次构建完成后,你文章底部的“最后更新时间”就会恢复正常,显示真实的修改日期了


验证成果

打开那个链接,你应该能看到一个简洁漂亮的 VitePress 默认页面,上面写着 "VitePress" 或者 "Hello World"。

image-20251216163759581

⑥实际笔记上传

现在的状态是:网站骨架已经搭好了,但内容还是默认的。

接下来的任务就是让电脑里的真实笔记能自动跑到这个网站上去,并且在菜单栏里能点出来。

第一步:验证“一键同步脚本” (测试你的自动化工作流)

我们先别急着改网页配置,先确保你的那个 .bat 脚本能正常工作

  1. 准备测试弹药:

    去你的“原始内容库”:D:\PandaC\StudyNotes\MyKnowledge\Blog_Public

    随便建个文件夹,比如叫 Coding。在里面新建一个 Markdown 文件,叫 Hello.md,随便写点什么(比如“这是我的第一篇自动同步笔记”)

    (如果你之前已经建了,就改几个字,确保有变动)

  2. 发射!

    找到你放在 D:\MyBlog 下的 deploy.bat 脚本。双击运行它

    观察黑框框:

    有没有显示 Files : X (复制了几个文件)?

    有没有显示 Pushing to GitHub

    最后有没有显示 Deployment Complete

  3. 去 GitHub 查岗:

    打开你的 GitHub my-blog 仓库页面。

    刷新一下,看看是不是多了一个 docs 文件夹?

    点进去看看你的 Hello.md 在不在里面。

如果这一步成功了,说明 原始笔记地址->博客项目地址->上传云端 已经打通了! Cloudflare 会自动感应到这次推送,并开始构建(大概 1 分钟后生效)

第二步:配置菜单栏 (让笔记能被看见)

虽然笔记传上去了,但网站导航栏(Home, Examples)里还没它的入口,访客找不到。我们需要修改配置文件

  1. 打开配置文件:

    用 VS Code 或 记事本 打开 D:\MyBlog\.vitepress\config.mjs

  2. 修改导航栏 (nav):

    找到 themeConfig 下面的 nav 部分。我们需要加一个指向你刚才上传内容的链接。

    把代码改成类似这样(注意格式):(下面的示例是一个例子,实际不必照抄!!)

    javascript
    import { defineConfig } from 'vitepress'
    
    // https://vitepress.dev/reference/site-config
    export default defineConfig({
      title: "PandaC's Blog",
      description: "A cool CS Student's portfolio",
      
      // 忽略死链检查(防止你还没传图片或链接导致报错)
      ignoreDeadLinks: true,
    
      themeConfig: {
        // https://vitepress.dev/reference/default-theme-config
        
        // 1. 顶部导航栏配置
        nav: [
          { text: '首页', link: '/' },
          // 这里链接到你的第一篇笔记
          // 注意:路径对应的是 docs 目录下的文件夹名/文件名
          { text: '编程笔记', link: '/docs/01.Coding/Hello' }, 
          { text: 'VitePress示例', link: '/markdown-examples' }
        ],
    
        // 2. 左侧侧边栏配置
        sidebar: {
          // 当用户进入 /docs/ 目录时显示这个侧边栏
          '/docs/': [
            {
              text: '我的笔记库',
              items: [
                // 这里要手动添加你的文章列表,或者以后用插件自动生成
                // 格式:{ text: '显示的标题', link: '/docs/文件夹/文件名' }
                { text: 'Java 基础', link: '/docs/01.Coding/Hello' },
                // 如果你有第二篇,就加在这里,比如:
                // { text: '我的猫', link: '/docs/Visuals/MyCat' }
                { text: '我的猫', link: '/docs/02.Visuals/中央公园' }
              ]
            }
          ],
    
          // 当用户看示例页面时显示这个侧边栏
          '/': [
            {
              text: '示例菜单',
              items: [
                { text: 'Markdown Examples', link: '/markdown-examples' },
                { text: 'Runtime API Examples', link: '/api-examples' }
              ]
            }
          ]
        },
    
        // 3. 社交链接 (改成你自己的 GitHub)
        socialLinks: [
          // 这里把链接换成你自己的 GitHub 仓库地址
          { icon: 'github', link: 'https://github.com/pandac-blog/my-blog' } 
        ]
      }
    })

    修改后的重要提示:

    关于路径的假设: 我在代码里写的链接是 /docs/01.Coding/Hello。这假设你的 Blog_Public 文件夹结构是这样的:

    Plaintext
    Blog_Public
    └── 01.Coding
        └── Hello.md

    *如果你建的文件夹不叫 Coding,或者文件名不叫 Hello.md,请务必把代码里的 /docs/Coding/Hello 改成你实际的路径!

  3. 然后保存文件,再次运行 deploy.bat,再去网页查看即可!

第三步:见证奇迹

等待 1-2 分钟后,刷新你的网站页面

  1. 看标题: 左上角是不是变成了 "PandaC's Blog"?
  2. 看菜单: 右上角是不是多了一个 “编程笔记” 的按钮?
  3. 点击它: 能不能跳转到你写的那个 Hello.md 页面?

到这里,一个基础的基于Vitepress框架的博客网已经搭建完成。接下来要做的,就是网站样式的装修,然后好好使用博客网O,o啦

网站装修

接下来我们要把这个“骨架”填上血肉,并把“门面”装修得漂亮一点。

修改首页 index.md

现在的首页还是 VitePress 默认的 "VitePress" 大标题。我们需要把它改成你的个人介绍,并把按钮指向你的真实内容。

  1. 打开文件: D:\MyBlog\index.md

  2. 全部替换: 用下面的代码替换里面的内容(我帮你设计了一个符合“程序员+摄影师”风格的首页)

    此为一个首页内容的例子文件,不必照抄

    ---
    layout: home
    
    hero:
      name: "PandaC's Space"
      text: "探索代码与光影的边界"
      tagline: CS Student / 摄影爱好者 / 终身学习者
      actions:
        - theme: brand
          text: 开始阅读笔记
          link: /docs/01.Coding/Hello
        - theme: alt
          text: 欣赏摄影作品
          link: /docs/02.Visuals/中央公园
    
    features:
      - title: 💻 技术栈
        details: Java, SpringBoot, AI, Python... 记录我的 CS 进阶之路。
      - title: 📷 摄影与生活
        details: 用镜头捕捉光影,用文字记录思考。
      - title: 📝 知识库
        details: 基于 Markdown 的数字化花园,包含编程、修图、剪辑等教程。
    ---
    
    <style>
    :root {
      --vp-home-hero-name-color: transparent;
      --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #bd34fe 30%, #41d1ff);
      --vp-home-hero-name-background-clip: text;
      -webkit-background-clip: text;
    }
    </style>
  3. 保存,然后运行 deploy.bat 去网页刷新首页会变成一个很酷的渐变色标题,按钮直接通往笔记

自动化侧边栏

现在的 config.mjs 里的 sidebar手动写死的:(例子:)

JavaScript
items: [
  { text: 'Java 基础', link: '/docs/01.Coding/Hello' },
  { text: '我的猫', link: '/docs/02.Photography/中央公园' }
]

这意味着:以后你每写一篇新笔记,除了要写文章,还得专门跑来改这个配置文件,加一行代码。 这太反人类了,也不符合“自动化”的初衷

解决方案:安装自动侧边栏插件 我们要让 VitePress 自动扫描你的文件夹,自动生成侧边栏。

  1. 安装插件:D:\MyBlog 目录下打开终端,输入:

    bash
    npm install vitepress-sidebar --save-dev
  2. 修改配置 (一劳永逸): 再次打开 D:\MyBlog\.vitepress\config.mjs,做最后一次大改:

    这一次,我们需要做两件事

    1. 在文件最顶端引入插件
    2. 把原来的手动 sidebar 删掉,换成自动配置

    请直接复制下面的完整代码,覆盖你原来的 config.mjs

    此为一个例子文件,不必照抄

    javascript
    import { defineConfig } from 'vitepress'
    import { generateSidebar } from 'vitepress-sidebar'
    
    export default defineConfig({
      title: "PandaC's Blog",
      description: "A cool CS Student's portfolio",
      
      ignoreDeadLinks: true,
    
      themeConfig: {
        // 1. 顶部导航
        nav: [
          { text: '首页', link: '/' },
          { text: '个人空间', link: '/docs/01.Coding/Hello' }
        ],
    
        // 2. 页面右侧大纲配置 (解决你的标题显示问题)
        outline: {
          level: [2, 4], // 关键点:显示 2级 到 4级 的标题 (H2 - H4)
          label: '页面导航' // 把顶部的英文 "On this page" 改成中文
        },
    
        // 3. 侧边栏配置 (站远扫描版)
        sidebar: generateSidebar({
          // 1. documentRootPath 设为 '/' (代表根目录)
          // 这样插件会看到完整的路径结构 'docs/01.Coding/...'
          documentRootPath: '/',
    
          // 2. scanStartPath 设为 null (不要钻进任何文件夹)
          scanStartPath: null,
    
          // 3. 关键:把根目录下的杂七杂八文件排除掉,只留 docs 里的东西
          // 这样侧边栏就不会显示 index.md, package.json 这些乱七八糟的了
          excludePattern: [
            'index.md', 
            'README.md', 
            'package.json', 
            'package-lock.json', 
            'deploy.bat',
            'node_modules', 
            '.vitepress', 
            '.git'
          ],
    
          useTitleFromFileHeading: true, 
          collapsed: false
        }),
    
        socialLinks: [
          { icon: 'github', link: 'https://github.com/pandac-blog/my-blog' } 
        ]
      }
    })
  3. 保存并运行 deploy.bat

三大板块直达目录

问题分析

你的需求: 导航栏放三个大按钮:技术、摄影、英语。点击直接进入该板块的“封面页/目录页”,而不是具体的某一篇文章

核心逻辑: VitePress(以及大多数 Web 服务器)的规则是:当你访问一个目录(例如 /docs/01.Coding/)时,它会自动寻找该目录下的 index.md 文件进行展示

行动方案: 我们需要为这三个板块,分别建立自己的“门户封面” (index.md)

解决方法

第一步:完善目录结构 (建立封面)

Blog_Public 文件夹(也就是 docs 对应的源目录),确保结构如下,并新建缺失的 index.md

Plaintext
示例:
Blog_Public/
├── 01.Coding/
│   ├── index.md       <-- (新建) 这是技术板块的封面
│   ├── Java基础.md
│   └── AI学习.md
├── 02.Photography/
│   ├── index.md       <-- (新建) 这是摄影板块的封面
│   └── 中央公园.md
└── 03.English/        <-- (新建) 英语文件夹
    ├── index.md       <-- (新建) 这是英语板块的封面
    └── Lesson1.md

第二步:设计“封面页”内容

现在这三个 index.md 是空的,点击去会是一片白。我们需要给它们加点内容,充当“目录导览”

举例:01.Coding/index.md 的内容建议:

# 💻 技术笔记

这里记录我的计算机科学进阶之路,包含 Java 后端、人工智能探索以及前端开发心得。

## 热门文章
* [Java 基础](/docs/01.Coding/Java基础)
* [AI 学习路线](/docs/01.Coding/AI学习)

> "Talk is cheap. Show me the code."

(摄影板块的 index.md 可以放一张精选大图,英语板块可以放学习计划表。)

第三步:修改导航栏配置 (config.mjs)

现在有了封面,我们就可以在导航栏直接链接到文件夹了(注意链接最后要加 /,或者直接指向文件夹名,VitePress 会自动解析到 index.md)

打开 config.mjs,修改 nav 部分:

javascript
nav: [
  { text: '首页', link: '/' },

  // 1. 技术博客 (直达封面)
  // 注意:这里 link 写到目录层级即可,VitePress 会自动找下面的 index.md
  { text: '💻 技术博客', link: '/docs/01.Coding/' },

  // 2. 摄影作品 (直达封面)
  { text: '📷 摄影作品', link: '/docs/02.Photography/' },

  // 3. 英语学习 (直达封面)
  { text: '🔤 英语学习', link: '/docs/03.English/' }
],

优化侧边栏配合三大板块

既然你分了三个大区,你肯定不希望点进“英语”的时候,侧边栏还显示“Java”的目录。我们需要让侧边栏根据当前所在的板块,自动显示对应的内容

vitepress-sidebar 插件支持这种**“分栏独立目录”。我们需要修改 config.mjs 里的 sidebar 配置,不再是一股脑全扫,而是分三次扫描**

请用下面这段代码覆盖原来的 sidebar 配置:

javascript
// 侧边栏配置:分板块扫描
    // generateSidebar 接受一个数组,数组里的每个对象对应一个板块
    sidebar: generateSidebar([
      // 1. 技术板块的侧边栏
      {
        documentRootPath: '/',
        scanStartPath: 'docs/01.Coding',
        resolvePath: '/docs/01.Coding/',
        useTitleFromFileHeading: true,
        collapsed: false,
        excludePattern: ['index.md'] // 只排除封面页,其他不需要排除
      },
      
      // 2. 摄影板块的侧边栏
      {
        documentRootPath: '/',
        scanStartPath: 'docs/02.Photography',
        resolvePath: '/docs/02.Photography/',
        useTitleFromFileHeading: true,
        collapsed: false,
        excludePattern: ['index.md']
      },
      
      // 3. 英语板块的侧边栏
      {
        documentRootPath: '/',
        scanStartPath: 'docs/03.English',
        resolvePath: '/docs/03.English/',
        useTitleFromFileHeading: true,
        collapsed: false,
        excludePattern: ['index.md']
      }
    ]),

(注意:generateSidebar 如果传入一个数组 [],它就会生成多套侧边栏,VitePress 会自动匹配当前 URL 显示哪一套。)

网站的四大板块

我的需求

你现在我有一个关于个人博客网站的新问题,我目前在docs下,除了about/me.md这个个人介绍专用目录,以及之前搞过的01.Coding、02.Photography、03.English目录之外,又搞了一个04.Share目录,记录了一些软件工具分享、建站指南、生活分享、网络资源配置的内容等等,现在为了把这一个内容新添加到网站右上角的导航栏,需要改动哪些内容?MyBlog目录下的index.md要改吧,Share目录下的index.md也要改吧,还有config.mjs文件也要改吧?刚好借此机会,你也帮我把网站首页的几个按钮改为四个,刚好对应我的4个目录内容,以及下方的4个框,也是对应我的四个目录内容

后来又更新了第五板块:05.Growth,步骤同下(#^.^#)

第一步:装修新分店 (创建文件)

先建立文件夹和封面页,不然配置好了也没东西显示。

  1. docs 下新建文件夹:04.Share
  2. 在里面新建 index.md (封面)。
  3. (可选) 再建一个 Tool.md (测试文章),方便看侧边栏效果。

docs/04.Share/index.md 内容示例:

# 💎 资源分享 (Share)

这里是我的百宝箱,记录好用的工具、避坑指南和生活碎片。

## 📂 目录导航
* [🛠 软件工具](/docs/04.Share/Tool)
* [🌐 建站指南](/docs/04.Share/建站)

> "Sharing is caring."

第二步:修改“总控中心” (config.mjs)

这里要改两处:顶部导航栏 (Nav)侧边栏 (Sidebar)

请打开 .vitepress/config.mjs

1. 修改 nav (添加第4个入口):

JavaScript

    nav: [
      { text: '首页', link: '/' },
      { text: '💻 技术笔记', link: '/docs/01.Coding/' },
      { text: '📷 摄影作品', link: '/docs/02.Photography/' },
      { text: '🔤 英语学习', link: '/docs/03.English/' },
      // 👇 新增这一行
      { text: '💎 资源分享', link: '/docs/04.Share/' }
    ],

2. 修改 sidebar (添加第4个扫描规则):

找到 generateSidebar([...]) 那个数组,在里面加一个对象。

JavaScript

    sidebar: generateSidebar([
      // ... 原有的 01.Coding 配置 ...
      // ... 原有的 02.Photography 配置 ...
      // ... 原有的 03.English 配置 ...
      
      // 👇 新增:04.Share 板块的侧边栏
      {
        documentRootPath: '/',
        scanStartPath: 'docs/04.Share',
        resolvePath: '/docs/04.Share/',
        useTitleFromFileHeading: true,
        collapsed: false,
        excludePattern: ['index.md']
      }
    ]),

第三步:修改“门户大门” (docs/index.md)

这一步就是你想要的**“4个按钮 + 4个方块”**。

请打开 docs/index.md,替换 herofeatures 部分:

yaml
---
layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界"
  tagline: CS Student / 摄影爱好者 / 终身学习者
  image:
    src: https://你的OSS链接/cover.jpg
    alt: Cover Image
  
  # 👇 这里配置 4 个按钮
  actions:
    - theme: brand
      text: 💻 技术笔记
      link: /docs/01.Coding/
    - theme: alt
      text: 📷 摄影作品
      link: /docs/02.Photography/
    - theme: alt
      text: 🔤 英语学习
      link: /docs/03.English/
    - theme: alt
      text: 💎 资源分享
      link: /docs/04.Share/

# 👇 这里配置 4 个大方块
features:
  - title: 💻 技术栈
    details: Java, SpringBoot, AI... 记录我的 CS 进阶之路。
    link: /docs/01.Coding/
    
  - title: 📷 摄影与生活
    details: 用镜头捕捉光影,用文字记录思考。
    link: /docs/02.Photograph/
    
  - title: 🔤 英语进阶
    details: New Concept English 学习笔记与打卡。
    link: /docs/03.English/
    
  - title: 💎 资源与分享
    details: 软件推荐、建站避坑、生活好物分享。
    link: /docs/04.Share/
---

(注意:Features 里的 link 属性需要 VitePress 较新版本支持,如果点击方块没反应,可以像我们之前那样在 details 里写 HTML 链接,或者保持现在的样子当纯展示。)

网站的五+四+一板块

26年1月11日更新

目前的五个板块是:docs/下的

  1. 01.Coding

  2. 02.Visuals(从Photography改为Visuals,更贴合“视觉工坊”)

    博客网相关内容,均由Photography改为Visuals!笔记中若有Photography,亦不影响阅读

  3. 03.English

  4. 04.Share

  5. 05.Growth

然后四个板块:docs/下的

  1. util:工具板块,比如说todolist、Heatmap、照片墙等等
  2. private:私密板块
  3. game:游戏成品展示板块
  4. blender:建模、动画成品展示板块

然后是一个总归档:docs/下的

  1. archive.md

增加搜索框

找到 themeConfig 下的 nav,替换为以下逻辑(注意路径要对应你真实的文件夹):

javascript
themeConfig: {
    // 开启本地搜索 (这是让网站变高级的第一步)
    search: {
      provider: 'local'
    },  provider: 'local'
}

解决“Logo 是空的”

没有 Logo 的网站就像没有头像的微信,由于你不想自己设计复杂的 SVG,我们有两个方案:

方案 A:使用 Emoji 或 文字 Logo (最快) VitePress 支持直接用文字或 Emoji 当 Logo

方案 B:使用图片 Logo (推荐) 既然你有阿里云 OSS,找一张你满意的照片(比如你的摄影作品截取一个局部,或者你的头像),上传到 OSS,拿到链接

操作:修改 config.mjs

javascript
export default defineConfig({
  // ...
  themeConfig: {
    // 在 themeConfig 的最上面添加
    logo: 'https://你的阿里云OSS链接/logo.png', // 支持图片
    // 或者用文字:
    // siteTitle: 'PandaC Space', // 左上角的文字
    // ...
  }
})

解决“首页太空”

首页默认的只有文字。加一张高大上的配图瞬间就不一样了

1. 给 Hero 区域加图 找一张你拍的最好的照片(或者找一张赛博朋克风格的代码图),上传 OSS。

操作:修改 index.md

yaml
---
layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界" 
  tagline: CS Student / 摄影爱好者
  # 👇 加上这个 image 配置
  image:
    src: https://你的OSS链接/cover.jpg
    alt: Cover Image
  actions:
    # ... 保持不变
---

效果:图片会显示在标题的右侧(PC端)或下方(手机端),视觉重心立马稳住了

2. 增加页脚 (Footer) 网站底部空荡荡的也不好,加上版权信息

操作:修改 config.mjs

javascript
// ... 在 themeConfig 里添加
footer: {
  message: '基于 VitePress 搭建 | 记录生活与代码',
  copyright: 'Copyright © 2024-present PandaC'
},
// ...

解决“作品展示不好看”

作品和文档都是 .md。对于代码笔记,默认的布局(左侧目录,右侧文章)很好。但对于摄影作品,左边有个目录栏会很干扰看图

优化技巧:为摄影作品关闭侧边栏

你可以在你的摄影作品 Markdown 文件(比如 中央公园.md)的最开头(Frontmatter 区域)

加上特定配置:

yaml
---
# 使用 'doc' 布局,但隐藏侧边栏,让图片显示区域更宽
layout: doc
aside: false  # 关闭右侧大纲
sidebar: false # 关闭左侧侧边栏 (可选,如果你想沉浸式展示图片)
---

# 中央公园摄影集

![](https://oss链接...)
![](https://oss链接...)

这样,摄影页面就会变得很宽敞,适合展示大图

增加“元数据” (显得像个活跃的博客)

在文章底部显示“最后更新时间”和“编辑此页”,是技术博客的标配。

操作:修改 config.mjs

javascript
export default defineConfig({
  // ...
  themeConfig: {
    // ...
    // 显示最后更新时间
    lastUpdated: {
      text: '最后更新于',
      formatOptions: {
        dateStyle: 'short',
        timeStyle: 'medium'
      }
    },
    
    // 显示“在 GitHub 上编辑此页” (非常有极客范儿)
    editLink: {
      pattern: 'https://github.com/你的用户名/my-blog/edit/main/docs/:path',
      text: '在 GitHub 上纠错'
    }
  }
})

增加“关于作者”页面(已弃用)

config.mjs文件中:nav部分添加关于作者

javascript
// 优化后的导航栏
    nav: [
      { text: '首页', link: '/' },

      // 1. 技术博客 (直达封面)
      // 注意:这里 link 写到目录层级即可,VitePress 会自动找下面的 index.md
      { text: '💻 技术博客', link: '/docs/01.Coding/' },

      // 2. 摄影作品 (直达封面)
      { text: '📷 视觉工坊', link: '/docs/02.Photography/' },

      // 3. 英语学习 (直达封面)
      { text: '🔤 英语学习', link: '/docs/03.English/' },

      // 增加一个“关于我” (显得专业)
      { text: '关于作者', link: '/docs/about/me' } 
    ],

docs/about/me.md文件:

javascript
---
layout: doc
outline: deep
---

# 关于作者 (About Me)

👋 Hi, I'm **PandaC**.

一名热爱光影的 **CS 大学生**,正在用代码构建世界,用快门记录瞬间。
我不只是代码的搬运工,更是生活的观察者。

## 🛠 技能栈 (Tech Stack)

我的武器库:

* **Core:** Java, Python, C++
* **Web:** SpringBoot, Vue, VitePress
* **AI:** Deep Learning (Learning...), LLM Prompting
* **Tools:** Git, Docker, Cloudflare

## 📷 摄影装备 (Gear)

* **Camera:** (比如: Sony A7M4 / Fuji XT5)
* **Lens:** (比如: 24-70mm f2.8 GM)
* **Editing:** Lightroom / Photoshop / Davinci Resolve

## 🚀 经历 (Timeline)

* **2025** 🎓 预计从某大学 CS 专业毕业
* **2024** 💻 独立搭建个人博客系统,开始系统性输出技术笔记
* **2023** 🏆 获得 XX 摄影比赛优秀奖 / 校园活动主力摄影师
* **2022** 👶 编写了第一行 Java 代码

## 📮 联系我

* **GitHub:** [pandac-blog](https://github.com/pandac-blog)
* **Email:** (你的邮箱)
* **WeChat:** (或者放个二维码图片)

---
> "Stay hungry, Stay foolish."

增加网页标签图标 (Favicon)

现状: 现在你浏览器标签页上(Tab)显示的可能是一个默认的 V 图标或者空白。 优化: 换成你自己的 Logo。

操作: 修改 config.mjs根配置区域(注意不是 themeConfig 里,而是最外层):

javascript
export default defineConfig({
  title: "PandaC's Blog",
  
  // 👇 添加这一行 head 配置
  head: [
    ['link', { rel: 'icon', href: 'https://你的OSS链接/favicon.ico' }]
  ],
  
  themeConfig: { ... }
})

**无底板图片:**网站的favicon图标也是logo的一种,但是是无底板图片

自定义 404 页面(已弃用)

总而言之、言而总之:这种方式有bug不适合。

现状: 现在的 404 页面是默认的。 优化: 搞个有趣的 404 页面。

操作: 直接在 docs 根目录下建一个 404.md

yaml
---
layout: page
---

# 404 Not Found 🚧

**哎呀,迷路了!**

你寻找的页面可能去**中央公园**拍照了,或者正在**修 Bug**。

[👉 返回首页](/)

开启代码块行号 (Code Line Numbers)

博客里肯定会有很多代码。默认的 VitePress 代码块是没有行号的。开启行号后,代码看起来会更像 IDE(VS Code),非常专业,方便别人引用(比如:“看第 5 行代码...”)

操作步骤: 打开 config.mjs,在 themeConfig外面(也就是和 titledescription 平级的地方),添加 markdown 配置:

javascript
export default defineConfig({
  title: "PandaC's Blog",
  // ... 其他配置
  
  // 👇 添加这一块
  markdown: {
    lineNumbers: true // 开启行号显示
  },

  themeConfig: { 
    // ... 你的主题配置
  }
})

视频播放解决方案 (HLS + ArtPlayer)

视频资源在线配置:详见 Cloudflare R2

我的需求

身份背景:CS 专业学生 + 摄影爱好者。

画质追求:视频素材多为 4K/高码率的大文件(几百兆至数 G),拒绝“座机画质”。

成本控制:拒绝阿里云 OSS 高昂的下行流量费,利用 Cloudflare R2 的 0 流量费 特性。

体验要求

  • 必须秒开,拒绝长缓冲。
  • 支持倍速、截图、画中画、网页全屏等 B 站级体验。
  • 兼容性好,电脑/手机均可播放。

功能概述

为什么选择 HLS (m3u8) 而非 MP4?

对于大体积视频(>50MB),MP4 是“下载完头部才能看”,起步慢且拖拽卡顿。 HLS (HTTP Live Streaming) 技术将视频切分为无数个小碎片 (.ts),并由一个索引文件 (.m3u8) 管理。

  • 秒开:浏览器只需加载几 KB 的索引,即可立即播放。
  • 省流:用户看多少下多少,不会因为刚点开就关掉而浪费几十兆流量。
  • 流畅:任意拖拽进度条,直接定位到对应的切片,无需缓冲中间内容。

组件升级逻辑

原生浏览器(Chrome/Edge)不支持直接播放 HLS。我们需要升级 MyPlayer 组件,引入 hls.js 引擎,充当“翻译官”,让所有设备都能完美播放高清切片视频。升级之后组件还是兼容mp4格式,并增加了m3u8格式的播放。


实现流程

这是一个从本地生产云端存储再到前端展示的完整闭环:

  1. 后期导出:PR/达芬奇导出高画质母版(MP4),无需过度压缩,保全画质。

  2. 格式转换 (FFmpeg):使用 FFmpeg 将母版转码并切片为 HLS (.m3u8 + .ts 碎片)。

    详见:插入钩子

  3. 上传云端 (R2):使用 Cyberduck 或 S3 Browser 将切片后的整个文件夹上传至 Cloudflare R2 存储桶。

    详见:插入钩子

  4. 博客展示:在 Markdown 中调用 <MyPlayer /> 组件,填入 .m3u8 链接


配置详情

Step 1: 安装依赖 (NPM)

在项目根目录运行,安装播放器核心库及 HLS 解码引擎:

bash
npm install artplayer
npm install artplayer hls.js -D

Step 2: 创建组件 (Vue)

新建/更新 docs/.vitepress/theme/components/MyPlayer.vue,代码支持自动识别 m3u8:

vue
<template>
  <div ref="artRef" class="artplayer-app"></div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
import Artplayer from 'artplayer';
import Hls from 'hls.js';

const props = defineProps({
  url: { type: String, required: true },
  poster: { type: String, default: '' },
  title: { type: String, default: '' }
})

const artRef = ref(null);
let instance = null;

// HLS 播放逻辑
function playM3u8(video, url, art) {
  if (Hls.isSupported()) {
    if (art.hls) art.hls.destroy();
    const hls = new Hls();
    hls.loadSource(url);
    hls.attachMedia(video);
    art.hls = hls;
    art.on('destroy', () => hls.destroy());
  } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = url;
  } else {
    art.notice.show = 'Unsupported playback format: m3u8';
  }
}

// 🛡️ 核心逻辑:JS 暴力隐藏侧边栏和导航栏
// 不再依赖 CSS 类名,直接操作 DOM,稳准狠
const toggleUI = (hide) => {
  // 获取 VitePress 的核心布局元素
  const sidebar = document.querySelector('.VPSidebar');
  const nav = document.querySelector('.VPNavBar');
  const navScreen = document.querySelector('.VPNavScreen'); // 移动端菜单
  const footer = document.querySelector('.magic-footer'); // 你的页脚
  
  const elements = [sidebar, nav, navScreen, footer];
  
  elements.forEach(el => {
    if (el) {
      // 如果需要隐藏,直接 display: none;否则清空样式恢复原状
      el.style.display = hide ? 'none' : '';
      // 顺便把层级也降下去,双重保险
      el.style.zIndex = hide ? '-1' : '';
    }
  });
  
  // 额外处理:提升内容区层级,防止被背景遮挡
  const content = document.querySelector('.VPContent');
  if (content) {
    content.style.zIndex = hide ? '2147483647' : '';
    content.style.position = hide ? 'relative' : '';
  }
};

onMounted(() => {
  instance = new Artplayer({
    container: artRef.value,
    url: props.url,
    poster: props.poster,
    title: props.title,
    volume: 0.5,
    isLive: false,
    muted: false,
    autoplay: false,
    pip: true,
    autoSize: true,
    screenshot: true,
    setting: true,
    loop: false,
    flip: true,
    playbackRate: true,
    aspectRatio: true,
    fullscreen: true,
    fullscreenWeb: true,
    miniProgressBar: true,
    theme: '#bd34fe',
    lang: 'zh-cn',
    customType: {
      m3u8: playM3u8,
    },
  });

  // 监听全屏变化
  instance.on('fullscreen', (state) => {
    toggleUI(state);
    // 强制刷新一下布局,防止残影
    if(state) instance.notice.show = '全屏模式';
  });

  // 监听网页全屏变化
  instance.on('fullscreenWeb', (state) => {
    toggleUI(state);
    if(state) instance.notice.show = '网页全屏模式';
  });
  
  // 双重保险:监听 resize,防止某些情况下状态丢失
  instance.on('resize', () => {
    if (instance.fullscreen || instance.fullscreenWeb) {
      toggleUI(true);
    }
  });
});

onBeforeUnmount(() => {
  // 销毁时必须把 UI 恢复出来,否则侧边栏就永久消失了
  toggleUI(false);

  if (instance && instance.destroy) {
    instance.destroy(false);
  }
});
</script>

<style scoped>
.artplayer-app {
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: 8px;
}
</style>

<style>
.artplayer-app.artplayer-web-fullscreen {
  z-index: 2147483647 !important;
  position: fixed !important;
  top: 0 !important;
  left: 0 !important;
  width: 100vw !important;
  height: 100vh !important;
  background-color: #000 !important;
}
</style>

Step 3: 全局注册

确认 docs/.vitepress/theme/index.mjs 中已注册:

javascript
import DefaultTheme from 'vitepress/theme'
import MyPlayer from './components/MyPlayer.vue'

export default {
  ...DefaultTheme,
  enhanceApp({ app }) {
    app.component('MyPlayer', MyPlayer)
  }
}

Step 4: 在 Markdown 中使用

场景 A:短视频/演示 (MP4) 直接使用 MP4 链接,组件会自动使用原生模式播放

html
<MyPlayer 
  title="简单的演示" 
  url="https://files.pandac-blog.com/demo.mp4" 
  poster="https://files.pandac-blog.com/img/cover.jpg"
/>

场景 B:Vlog/大视频 (M3U8) - 推荐 使用 FFmpeg 转码后的索引链接,享受秒开体验

html
<MyPlayer 
  title="4K 毕业旅行 Vlog" 
  url="https://files.pandac-blog.com/videos/vlog2026/index.m3u8" 
  poster="https://files.pandac-blog.com/img/vlog_cover.jpg"
/>

极其重要的一步:配置 R2 的 CORS (防翻车指南)

因为你的博客域名是 pandac-blog.com,而视频域名是 video.pandac-blog.com。 浏览器出于安全考虑,可能会阻止“跨域”播放视频(尤其是用 ArtPlayer 截图功能时,或者加载字幕时)

必须去 Cloudflare 后台设置一下 R2 的 CORS 规则:

  1. 登录 Cloudflare -> R2 -> 点击你的存储桶 (Bucket)。
  2. 点击 Settings (设置) 选项卡。
  3. 往下拉找到 CORS Policy (CORS 策略)
  4. 点击 Add/Edit CORS Policy,粘贴以下 JSON 代码:
json
[
  {
    "AllowedOrigins": [
      "http://localhost:5173",
      "https://pandac-blog.com",
      "https://www.pandac-blog.com",
      "https://my-blog-7iw.pages.dev"
    ],
    "AllowedMethods": [
      "GET",
      "HEAD"
    ],
    "AllowedHeaders": [
      "*"
    ],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 3000
  }
]

解释:这段代码的意思是,允许“你的本地开发环境”和“你的正式博客域名”,去请求 R2 里的资源。

修改项目域名

遇到问题

①为什么不能用 video.my-blog-7iw.pages.dev

  • 简单说:因为 pages.dev 这个域名是 Cloudflare 的,不是你的。

②现在的网址 my-blog-7iw.pages.dev 相当于 Cloudflare 免费分给了你“7iw号房间”,我觉得太长了不好看

**解决办法:**买一个域名!详见链接:网站域名

购买域名

Cloudflare 支持自定义域名吗?(如何换掉长域名)

  • 答案:必须支持!而且强烈建议你买一个。

拥有一个独立的域名(比如 pandac-blog.com),是你的博客从“玩具”变成“正规军”的最重要一步。

买域名的好处:

  1. 解决 R2 问题: 一旦你有了 xxx.com,你就可以轻松设置 video.xxx.com 给 R2 用。
  2. 专业且好记: 告别 my-blog-7iw... 这种乱码,别人一眼就能记住。
  3. 完全掌控: 以后就算你不用 Cloudflare Pages 了,换成别的服务器,你的域名依然不用变,粉丝不迷路。

配置域名

详见链接:网站域名

个人博客嵌入式待办清单

我的需求

核心痛点: 日常生活中,我经常需要在手机和电脑之间频繁切换待办管理软件,导致数据分散,且需要安装额外的 APP

创意构思: 作为一个拥有个人博客的开发者,我萌生了一个创造性的想法:直接实现“待办清单在线化”。 利用博客网作为载体,嵌入一个轻量级的 TodoList 模块。这样无论身处何地,只要能打开网页(手机/电脑),就能点击按钮跳转到我的专属空间,对任务进行增删改查。这不仅避免了单独开发/安装软件的成本,也让我的博客从单纯的“展示站”升级为“工具站”

产品功能需求

核心架构: VitePress 个人博客(Vue 3 驱动)+ Serverless 架构(LeanCloud)实现无需维护服务器的云端数据存储

  • VitePress: 负责画出输入框、按钮、列表,以及“密码拦截”的逻辑
  • LeanCloud (或 Supabase): 提供免费的云端 JSON 存储。它的开发版免费额度足够你一个人写一辈子待办事项

利用 VitePress 的 Vue 组件化能力 + Serverless(无服务器)数据库 来实现轻量级应用。这完全可行,而且是目前最流行的“JAMstack”开发模式。Vue 不仅仅是展示静态文字的页面,它完全可以运行复杂的单页应用(SPA)逻辑

权限与安全机制:

为了保护个人隐私,系统设计了轻量级的权限控制:

  • 前端鉴权(第一道防线)

    进入页面时强制锁定,需输入预设密码(如 123456

    密码匹配成功后,才初始化数据库连接并加载 UI 界面

    若无密码或密码错误,仅提示“非此权限用户可访问”,不展示任何数据

  • 数据安全(第二道防线)

    针对静态网站无法隐藏 API ID/Key 的问题,采用 Web 安全域名白名单 策略

    在 LeanCloud 后台配置仅允许 pandac-blog.com 调用 API,防止 Key 被非法盗用

任务管理模块:

待办事项采用 分栏管理 + 线性增长 模式,适应不同时间维度的规划:

  • A. 短期计划 (Short-term)

    定位:记录每日或每周的琐碎待办

    功能:支持快速添加、勾选完成、删除;时间正序(Created Ascending)。旧任务在顶端,新任务自动追加至列表底部,保证序号(1. 2. 3...)稳定不变。

  • B. 长期计划 (Long-term)

    定位:位于页面下方,记录月度目标或年度愿景

    交互:与短期计划保持一致的操作体验

辅助功能细节:

  • 板块备注系统 (Memo)

    长/短期计划区各设有一个可编辑备注栏

    用途:标注当前冲刺阶段(如“2025年3月冲刺”)或写下激励语

    同步:备注内容支持云端自动保存与同步

  • 视觉交互

    序号标注:任务前自动生成 1. 2. 3. 序号,条理清晰

    Checkbox:标准的勾选框交互,点击即可标记完成/取消完成

实现步骤 (保姆级教程)

我们将创建一个名为 Todo.vue 的组件,然后把它像插积木一样插进你的 Markdown 文章里

第一步:准备云端数据库 (LeanCloud)

详见:LeanCloud云数据库

第二步:安装 SDK

在博客项目根目录下运行:

bash
npm install leancloud-storage

第三步:编写 Todo 组件

.vitepress/theme/components/ 下新建 TodoList.vue

这个代码实现了:

  1. 密码验证: 只有输入正确密码,isAuth 才会变 true
  2. 云端同步: 增删改查直接操作 LeanCloud,手机电脑秒同步
vue
<template>
  <div class="todo-wrapper">
    <div v-if="!isAuth" class="lock-screen">
      <div class="auth-card">
        <h3>🔐 身份验证</h3>
        <p class="hint">请输入访问密码以管理您的计划</p>
        <div class="input-group">
          <input 
            type="password" 
            v-model="inputPass" 
            @keyup.enter="checkPass"
            placeholder="Password" 
          />
          <button class="btn-primary" @click="checkPass">解锁</button>
        </div>
        <p v-if="errMsg" class="error-msg">{{ errMsg }}</p>
      </div>
    </div>

    <div v-else class="dashboard">
      <div class="header">
        <h2>🚀 任务控制台</h2>
        <button class="btn-text" @click="logout">🔒 锁定</button>
      </div>

      <div class="section short-term">
        <div class="section-header">
          <h3>🌞 短期计划 (Short-term)</h3>
          <input 
            class="memo-input"
            v-model="shortMemo" 
            @blur="saveMemo('short')"
            placeholder="在此输入今日/本周备注..."
          />
        </div>
        
        <div class="add-bar">
          <input 
            v-model="newShortItem" 
            @keyup.enter="addItem('short')"
            placeholder="添加一条短期任务..." 
          />
          <button class="btn-add" @click="addItem('short')">+</button>
        </div>

        <ul class="task-list">
          <li v-for="(item, index) in shortList" :key="item.objectId" :class="{ done: item.done }">
            <span class="index">{{ index + 1 }}.</span>
            <input type="checkbox" :checked="item.done" @change="toggleItem(item)" />
            <span class="content">{{ item.content }}</span>
            <span class="del-btn" @click="deleteItem(item)">×</span>
          </li>
          <li v-if="shortList.length === 0" class="empty-tip">暂无短期计划</li>
        </ul>
      </div>

      <div class="section long-term">
        <div class="section-header">
          <h3>🪐 长期计划 (Long-term)</h3>
          <input 
            class="memo-input"
            v-model="longMemo" 
            @blur="saveMemo('long')"
            placeholder="在此输入本月/年度备注..."
          />
        </div>

        <div class="add-bar">
          <input 
            v-model="newLongItem" 
            @keyup.enter="addItem('long')"
            placeholder="添加一条长期目标..." 
          />
          <button class="btn-add" @click="addItem('long')">+</button>
        </div>

        <ul class="task-list">
          <li v-for="(item, index) in longList" :key="item.objectId" :class="{ done: item.done }">
            <span class="index">{{ index + 1 }}.</span>
            <input type="checkbox" :checked="item.done" @change="toggleItem(item)" />
            <span class="content">{{ item.content }}</span>
            <span class="del-btn" @click="deleteItem(item)">×</span>
          </li>
          <li v-if="longList.length === 0" class="empty-tip">暂无长期计划</li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import AV from 'leancloud-storage'

// --- ⚙️ 配置中心 ---
// 注意:前端代码无法真正隐藏 Key,请务必在 LeanCloud 后台设置「Web 安全域名」
const APP_ID = '?' 
const APP_KEY = '?'
// ⚠️ 重点:请去 LeanCloud 后台复制正确的 ServerURL,别用假地址!
const SERVER_URL = 'https://12345678.api.lncldglobal.com' 
const MY_PASSWORD = '123456' 

// --- 🔐 鉴权与初始化 ---
const isAuth = ref(false)
const inputPass = ref('')
const errMsg = ref('')

// 初始化 LeanCloud (懒加载模式,只有还没初始化过才运行)
const initLeanCloud = () => {
  if (!AV.applicationId) {
    AV.init({
      appId: APP_ID,
      appKey: APP_KEY,
      serverURL: SERVER_URL
    });
  }
}

const checkPass = () => {
  if (inputPass.value === MY_PASSWORD) {
    try {
      initLeanCloud() // 密码正确才连接数据库
      isAuth.value = true
      errMsg.value = ''
      fetchAllData()
    } catch (e) {
      errMsg.value = '数据库连接失败,请检查配置'
      console.error(e)
    }
  } else {
    errMsg.value = '🚫 密码错误,访问被拒绝'
  }
}

const logout = () => {
  isAuth.value = false
  inputPass.value = ''
}

// --- 📝 数据逻辑 ---
const allTodos = ref([])
const shortMemo = ref('')
const longMemo = ref('')
const newShortItem = ref('')
const newLongItem = ref('')

// 计算属性:自动分类
const shortList = computed(() => allTodos.value.filter(t => t.type === 'short'))
const longList = computed(() => allTodos.value.filter(t => t.type === 'long'))

// 拉取所有数据
const fetchAllData = async () => {
  // 1. 拉取待办
  const query = new AV.Query('Todo')
  query.limit(100)
  query.ascending('createdAt')
  const results = await query.find()
  allTodos.value = results.map(r => ({
    objectId: r.id,
    content: r.get('content'),
    done: r.get('done'),
    type: r.get('type') || 'short' // 兼容旧数据,默认为 short
  }))

  // 2. 拉取备注 (Memo)
  const memoQuery = new AV.Query('Memo')
  const memos = await memoQuery.find()
  memos.forEach(m => {
    if (m.get('type') === 'short') shortMemo.value = m.get('content')
    if (m.get('type') === 'long') longMemo.value = m.get('content')
  })
}

// 添加任务
const addItem = async (type) => {
  const text = type === 'short' ? newShortItem.value : newLongItem.value
  if (!text.trim()) return

  const Todo = AV.Object.extend('Todo')
  const todo = new Todo()
  todo.set('content', text)
  todo.set('done', false)
  todo.set('type', type) // 标记是长期还是短期
  
  const saved = await todo.save()
  
  // 本地手动推入,减少一次网络请求,体验更丝滑
  allTodos.value.push({
    objectId: saved.id,
    content: text,
    done: false,
    type: type
  })
  
  
  // 清空输入框
  if (type === 'short') newShortItem.value = ''
  else newLongItem.value = ''
}

// 切换状态
const toggleItem = async (item) => {
  // 本地先变,UI响应快
  item.done = !item.done
  const todo = AV.Object.createWithoutData('Todo', item.objectId)
  todo.set('done', item.done)
  await todo.save()
}

// 删除任务
const deleteItem = async (item) => {
  if (!confirm('确认删除这条任务吗?')) return
  
  // 本地先删
  allTodos.value = allTodos.value.filter(t => t.objectId !== item.objectId)
  
  const todo = AV.Object.createWithoutData('Todo', item.objectId)
  await todo.destroy()
}

// 保存备注 (失焦时触发)
const saveMemo = async (type) => {
  const content = type === 'short' ? shortMemo.value : longMemo.value
  
  // 查找云端是否已有该类型的备注
  const query = new AV.Query('Memo')
  query.equalTo('type', type)
  const existMemo = await query.first()

  if (existMemo) {
    // 更新
    existMemo.set('content', content)
    await existMemo.save()
  } else {
    // 新建
    const Memo = AV.Object.extend('Memo')
    const memo = new Memo()
    memo.set('type', type)
    memo.set('content', content)
    await memo.save()
  }
}
</script>

<style scoped>
.todo-wrapper { max-width: 800px; margin: 0 auto; padding: 20px 0; }
.lock-screen { text-align: center; padding: 60px 20px; }
.auth-card { background: var(--vp-c-bg-soft); padding: 40px; border-radius: 12px; display: inline-block; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
.hint { color: var(--vp-c-text-2); margin-bottom: 20px; }
.input-group { display: flex; gap: 10px; justify-content: center; }
.input-group input { padding: 10px; border: 1px solid var(--vp-c-divider); border-radius: 6px; background: var(--vp-c-bg); color: var(--vp-c-text-1); }
.error-msg { color: #f43f5e; margin-top: 15px; font-size: 0.9em; }

.dashboard { animation: fadeIn 0.5s ease; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; border-bottom: 1px solid var(--vp-c-divider); padding-bottom: 10px; }
.btn-text { background: none; border: none; color: var(--vp-c-text-2); cursor: pointer; font-size: 0.9em; }
.btn-text:hover { color: var(--vp-c-brand); }

.section { margin-bottom: 40px; background: var(--vp-c-bg-soft); padding: 20px; border-radius: 12px; }
.section-header { margin-bottom: 15px; }
.section-header h3 { margin: 0 0 10px 0; font-size: 1.2em; display: flex; align-items: center; gap: 8px; }
.memo-input { width: 100%; border: none; background: transparent; border-bottom: 1px dashed var(--vp-c-divider); padding: 5px 0; font-size: 0.9em; color: var(--vp-c-text-2); transition: all 0.3s; }
.memo-input:focus { outline: none; border-bottom-color: var(--vp-c-brand); color: var(--vp-c-text-1); }

.add-bar { display: flex; gap: 10px; margin-bottom: 15px; }
.add-bar input { flex: 1; padding: 10px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); }
.btn-primary, .btn-add { background: var(--vp-c-brand); color: white; border: none; padding: 0 20px; border-radius: 6px; cursor: pointer; font-weight: bold; }
.btn-add { font-size: 1.2em; padding: 0 15px; }

.task-list { list-style: none; padding: 0; margin: 0; }
.task-list li { display: flex; align-items: center; gap: 10px; padding: 12px 10px; border-bottom: 1px solid var(--vp-c-divider); transition: background 0.2s; }
.task-list li:hover { background: var(--vp-c-bg); }
.task-list li.done .content { text-decoration: line-through; color: var(--vp-c-text-3); }
.index { color: var(--vp-c-text-3); font-family: monospace; font-size: 0.9em; min-width: 20px; }
.content { flex: 1; line-height: 1.4; }
.del-btn { color: var(--vp-c-text-3); cursor: pointer; font-size: 1.2em; padding: 0 5px; }
.del-btn:hover { color: #f43f5e; }
.empty-tip { text-align: center; color: var(--vp-c-text-3); padding: 20px; font-style: italic; }

@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>

第四步:注册并使用

  1. 全局注册组件: 在 .vitepress/theme/index.js 中注册 TodoList(你应该已经很熟练了)

  2. 创建入口页面: 创建一个专门的页面,比如 docs/about/todolist.md

    yaml
    ---
    layout: doc
    ---
    
    # 📅 个人待办事项
    <TodoList />
  3. 添加到导航栏(可选): 如果你不想让别人太容易找到,可以不加到导航栏,只把它作为一个“隐藏关卡”,你需要用的时候手动输入网址 pandac-blog.com/docs/04.Share/todolist


安全性特别说明(必看)

1. 前端密码(UI 层防御)

  • 当前机制:我们在代码中定义了 const MY_PASSWORD = '123456'
  • 存在问题:这只是一个“视觉窗帘”。密码是透明地写在 JavaScript 源码里的。任何懂一点技术的人(按 F12 打开控制台 -> Sources)都能直接看到密码
  • 适用场景:仅适用于防范“君子”(普通访客),不让陌生人一眼看到你的私人清单。对于待办事项这种非机密数据,这种程度的保护通常足够

2. AppID 与 AppKey(连接层防御)

  • 存在问题:在纯静态前端(Pure Client-Side)应用中,“隐藏 Key”是一个伪命题

    原理:浏览器必须拿着 Key 才能向 LeanCloud 发起请求。只要代码运行在用户的浏览器里,用户按 F12 在 Network 面板就能拦截到请求头中的 AppID 和 AppKey

  • 改进方案(Web 安全域名)不要纠结于隐藏 Key,而是限制 Key 的使用范围

    操作:去 LeanCloud 后台 -> 设置 -> 安全中心 -> Web 安全域名

    配置:填入你的博客域名 https://pandac-blog.com (及本地调试用的 http://localhost:5173)

    效果:这是最有效的防御。即使别人偷了你的 ID 和 Key,但如果他是用 Postman 或者自己的服务器发请求(Referer 来源不是你的博客),LeanCloud 会直接拒绝连接(403 Forbidden)

3. 数据权限 ACL(数据层防御)

  • 当前机制:LeanCloud 的每一张表(Class)和每一行数据都有权限设置

  • 配置建议

    由于本项目是免登录模式(没有做用户注册/登录功能),我们主要依赖“Web 安全域名”来拦截非法请求。所以在此轻量级方案下,依赖白名单即可,表权限可保持相对开放:即所有用户 (All users);将ACL 权限修改为:read和write所有用户

    注意:如果设置了“仅创建者可写”,可能会导致你在手机上创建的任务,在电脑上无法删除(因为LeanCloud把它们视作两个不同的匿名访客)

当前方案的密钥逻辑:

概念类比作用层级代码里的情况
前端密码 (123456)卧室的门帘视觉遮挡防君子不防小人,掀开帘子(F12)就能看见里面
AppID / AppKey家庭住址 / 钥匙基础连接把钥匙放在了门口地毯下(前端源码里)谁都能拿
Web 安全域名小区门禁 (人脸识别)来源验证这是真正的防线! 只有长得像你(来自 pandac-blog.com)的人拿着钥匙,保安才放行。小偷拿着钥匙来,保安不认识脸,直接轰走
ACL 权限保险箱密码最终读写权决定进屋的人能动哪些东西

进阶:打造“10天冲刺”生产力系统 (TodoList v2.0)

1. 核心设计理念

基于 “一年是 36 个 10 天” 的时间管理思维,将简单的待办列表升级为符合 敏捷开发 (Agile) 逻辑的 “当前冲刺 (Sprint) + 积压池 (Backlog)” 系统。利用可视化的时间流逝感倒逼执行力,同时增加交互反馈提升使用愉悦感。

2. 核心升级内容

  • 🔄 自定义周期 (Sprint Cycle)
    • 不再依赖自然年,支持手动设置“周期开始日”。
    • 系统自动计算 10 天倒计时,并以 “时间进度条” 直观展示(例如:Day 3/10 = 30%)。
    • 超期警报:超过 10 天进度条变红,提示开启新周期。
  • 🖱️ 拖拽排序 (Drag & Drop)
    • 引入 vuedraggable,支持鼠标任意拖动调整任务优先级。
    • 排序结果自动同步至云端,刷新页面不乱序。
  • 🎉 多巴胺反馈 (Gamification)
    • 引入 canvas-confetti,完成任务时屏幕炸开礼花特效,提供即时正向反馈。
  • 👁️ 沉浸模式 (Focus Mode)
    • 一键隐藏右侧“积压区”和“设置栏”,只保留当前冲刺列表,减少视觉噪点,专注当下。
  • 🔁 双向流转
    • 捞取 (Pull):将积压任务移入当前冲刺(自动排到底部)。
    • 推迟 (Push):将当前做不完的任务踢回积压区。

3. 部署操作流程

第一步:安装依赖插件

在博客项目根目录下打开终端,安装 Vue 拖拽组件和礼花特效库:

Bash

npm install vuedraggable@next canvas-confetti

第二步:代码更新

直接使用 v2.0 终极版 代码覆盖原有的 TodoList.vue 文件。

vue
<template>
  <div class="todo-wrapper" :class="{ 'focus-mode': isFocusMode }">
    <div v-if="!isAuth" class="lock-screen">
      <div class="auth-card">
        <h3>🔐 身份验证</h3>
        <p class="hint">请输入访问密码以进入周期管理</p>
        <div class="input-group">
          <input 
            type="password" 
            v-model="inputPass" 
            @keyup.enter="checkPass"
            placeholder="Password" 
          />
          <button class="btn-primary" @click="checkPass">解锁</button>
        </div>
        <p v-if="errMsg" class="error-msg">{{ errMsg }}</p>
      </div>
    </div>

    <div v-else class="dashboard">
      
      <div class="cycle-dashboard" v-show="!isFocusMode">
        <div class="cycle-header-row">
          <div class="cycle-title-group">
            <h2 class="cycle-title">🏁 10-Day Sprint</h2>
            <span class="day-badge" :class="cycleStatus.statusClass">
              {{ cycleStatus.text }}
            </span>
          </div>
          
          <div class="cycle-actions">
            <button class="btn-icon" @click="toggleFocusMode" title="沉浸模式:只看当下">
              👁️ 专注
            </button>
            <button class="btn-icon" @click="showDatePicker = !showDatePicker" title="重设周期">
              📅 周期
            </button>
            <button class="btn-text" @click="logout">🔒</button>
          </div>
        </div>

        <div v-if="showDatePicker" class="date-setter">
          <label>选择本周期开始日期:</label>
          <input type="date" v-model="inputDate" @change="updateCycleStart" />
          <span class="date-hint">周期将自动持续10天 ({{ cycleStatus.range }})</span>
        </div>

        <div class="time-bar-container">
          <div class="bar-label">
            <span>⏳ 时间进度 (Day {{ cycleStatus.currentDay }} / 10)</span>
            <span>{{ cycleStatus.percentage }}%</span>
          </div>
          <div class="progress-track time-track">
            <div 
              class="progress-fill time-fill" 
              :class="{ 'overdue': cycleStatus.currentDay > 10 }"
              :style="{ width: Math.min(cycleStatus.percentage, 100) + '%' }"
            ></div>
          </div>
        </div>
      </div>

      <div class="task-progress-container">
        <div class="bar-label">
          <span>✅ 任务完成度</span>
          <span>{{ doneCount }} / {{ shortList.length }}</span>
        </div>
        <div class="progress-track task-track">
          <div class="progress-fill task-fill" :style="{ width: completionRate + '%' }"></div>
        </div>
      </div>

      <div class="main-grid">
        <div class="section current-sprint">
          <div class="section-header">
            <h3>🏃 本旬目标 (Sprint Goal)</h3>
            <div class="header-actions" v-if="isFocusMode">
              <button class="btn-icon-small" @click="toggleFocusMode">退出专注</button>
            </div>
            <div class="memo-box" v-show="!isFocusMode">
              <textarea 
                class="memo-input"
                v-model="shortMemo" 
                @blur="saveMemo('short')"
                placeholder="在这10天里,我要聚焦完成..."
                rows="2"
              ></textarea>
            </div>
          </div>
          
          <div class="add-bar">
            <input 
              v-model="newShortItem" 
              @keyup.enter="addItem('short')"
              placeholder="添加冲刺任务..." 
            />
            <button class="btn-add" @click="addItem('short')">+</button>
          </div>

          <draggable 
            v-model="shortList" 
            group="tasks" 
            item-key="objectId"
            @change="onDragChange('short')"
            class="task-list"
            ghost-class="ghost"
          >
            <template #item="{element}">
              <li :class="{ done: element.done }">
                <div class="drag-handle">⋮⋮</div>
                <label class="custom-checkbox">
                  <input type="checkbox" :checked="element.done" @change="toggleItem(element)" />
                  <span class="checkmark"></span>
                </label>
                <span class="content">{{ element.content }}</span>
                <span class="move-btn" @click="moveToBacklog(element)" title="推迟到积压区">📦</span>
                <span class="del-btn" @click="deleteItem(element)">×</span>
              </li>
            </template>
          </draggable>
          
          <div v-if="shortList.length === 0" class="empty-tip">
            新周期开始,规划你的关键任务吧!
          </div>
        </div>

        <div class="section backlog" v-show="!isFocusMode">
          <div class="section-header">
            <h3>📦 想法/积压 (Backlog)</h3>
            <div class="memo-box">
               <textarea 
                class="memo-input"
                v-model="longMemo" 
                @blur="saveMemo('long')"
                placeholder="未来的计划、灵感、待办..."
                rows="2"
              ></textarea>
            </div>
          </div>

          <div class="add-bar">
            <input 
              v-model="newLongItem" 
              @keyup.enter="addItem('long')"
              placeholder="以后要做的事..." 
            />
            <button class="btn-add" @click="addItem('long')">+</button>
          </div>

          <draggable 
            v-model="longList" 
            group="tasks" 
            item-key="objectId"
            @change="onDragChange('long')"
            class="task-list"
            ghost-class="ghost"
          >
            <template #item="{element}">
              <li>
                <div class="drag-handle">⋮⋮</div>
                <span class="content">{{ element.content }}</span>
                <span class="move-btn" @click="moveToSprint(element)" title="移入本周期">🏃</span>
                <span class="del-btn" @click="deleteItem(element)">×</span>
              </li>
            </template>
          </draggable>
          
          <div v-if="longList.length === 0" class="empty-tip">暂无积压事项</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import AV from 'leancloud-storage'
import confetti from 'canvas-confetti'
import draggable from 'vuedraggable'

// --- ⚙️ 配置中心 ---
const APP_ID = 'ZU8LmarfZJYwA0BGQBupHceh-MdYXbMMI' 
const APP_KEY = 'jSa38QwDpPsB5c6ONftkWWLE'
const SERVER_URL = 'https://zu8lmarf.api.lncldglobal.com' 
const MY_PASSWORD = 'pandac' 

// --- 🔐 鉴权逻辑 ---
const isAuth = ref(false)
const inputPass = ref('')
const errMsg = ref('')
const isFocusMode = ref(false)

const initLeanCloud = () => {
  if (!AV.applicationId) {
    AV.init({ appId: APP_ID, appKey: APP_KEY, serverURL: SERVER_URL });
  }
}

const checkPass = () => {
  if (inputPass.value === MY_PASSWORD) {
    try {
      initLeanCloud()
      isAuth.value = true
      errMsg.value = ''
      fetchAllData()
    } catch (e) {
      errMsg.value = '数据库连接失败'
      console.error(e)
    }
  } else {
    errMsg.value = '🚫 密码错误'
  }
}
const logout = () => { isAuth.value = false; inputPass.value = '' }
const toggleFocusMode = () => { isFocusMode.value = !isFocusMode.value }

// --- 📅 自定义周期逻辑 ---
const cycleStartDateStr = ref(new Date().toISOString().split('T')[0])
const showDatePicker = ref(false)
const inputDate = ref('')

const cycleStatus = computed(() => {
  const start = new Date(cycleStartDateStr.value); start.setHours(0,0,0,0)
  const now = new Date(); now.setHours(0,0,0,0)
  const end = new Date(start); end.setDate(start.getDate() + 9)
  const diffTime = now - start
  const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) 
  const currentDay = diffDays + 1 
  let percentage = (currentDay / 10) * 100
  if (percentage < 0) percentage = 0

  let text = '', statusClass = ''
  if (currentDay < 1) { text = '未开始'; statusClass = 'future' }
  else if (currentDay <= 10) { text = `Day ${currentDay}`; statusClass = 'active' }
  else { text = `已超期 ${currentDay - 10} 天`; statusClass = 'overdue' }

  const fmt = d => `${d.getMonth() + 1}/${d.getDate()}`
  return { currentDay, percentage: percentage > 100 ? 100 : percentage, range: `${fmt(start)} ~ ${fmt(end)}`, text, statusClass }
})

const updateCycleStart = async () => {
  if (!inputDate.value) return
  cycleStartDateStr.value = inputDate.value
  await saveSetting('setting_cycle_start', inputDate.value)
}

// --- 📝 任务数据逻辑 ---
// 我们使用两个独立的 ref 来支持拖拽,因为 vuedraggable 需要可写的数组
const shortList = ref([])
const longList = ref([])
const shortMemo = ref('')
const longMemo = ref('')
const newShortItem = ref('')
const newLongItem = ref('')

const doneCount = computed(() => shortList.value.filter(t => t.done).length)
const completionRate = computed(() => {
  if (shortList.value.length === 0) return 0
  return Math.round((doneCount.value / shortList.value.length) * 100)
})

// 保存排序顺序的数组
const shortOrder = ref([])
const longOrder = ref([])

const fetchAllData = async () => {
  // 1. 获取任务
  const query = new AV.Query('Todo')
  query.limit(100)
  // 不再依赖 createdAt 排序,而是依赖我们自定义的顺序
  const results = await query.find()
  
  const allTasks = results.map(r => ({
    objectId: r.id,
    content: r.get('content'),
    done: r.get('done'),
    type: r.get('type') || 'short'
  }))

  // 2. 获取备注和设置(包括排序信息)
  const memoQuery = new AV.Query('Memo')
  const memos = await memoQuery.find()
  memos.forEach(m => {
    const type = m.get('type')
    const content = m.get('content')
    if (type === 'short') shortMemo.value = content
    if (type === 'long') longMemo.value = content
    if (type === 'setting_cycle_start') { cycleStartDateStr.value = content; inputDate.value = content }
    // 加载排序信息 (存储为 JSON 字符串)
    if (type === 'order_short') try { shortOrder.value = JSON.parse(content) } catch(e){}
    if (type === 'order_long') try { longOrder.value = JSON.parse(content) } catch(e){}
  })

  // 3. 根据排序数组重组列表
  shortList.value = sortTasks(allTasks.filter(t => t.type === 'short'), shortOrder.value)
  longList.value = sortTasks(allTasks.filter(t => t.type === 'long'), longOrder.value)
}

// 辅助排序函数
const sortTasks = (tasks, orderIdList) => {
  if (!orderIdList || orderIdList.length === 0) return tasks
  // 创建一个 ID -> Task 的映射
  const map = new Map(tasks.map(t => [t.objectId, t]))
  const sorted = []
  // 按顺序添加
  orderIdList.forEach(id => {
    if (map.has(id)) {
      sorted.push(map.get(id))
      map.delete(id)
    }
  })
  // 把剩下的(新加的或未排序的)追加到后面
  return [...sorted, ...map.values()]
}

// 拖拽变动处理
const onDragChange = async (type) => {
  // 保存新的 ID 顺序到 Memo 表
  const list = type === 'short' ? shortList.value : longList.value
  const newOrder = list.map(t => t.objectId)
  
  // 更新本地 ref,防止下次加载跳变
  if (type === 'short') shortOrder.value = newOrder
  else longOrder.value = newOrder

  await saveSetting(`order_${type}`, JSON.stringify(newOrder))
}

const addItem = async (type) => {
  const text = type === 'short' ? newShortItem.value : newLongItem.value
  if (!text.trim()) return
  const Todo = AV.Object.extend('Todo')
  const todo = new Todo()
  todo.set('content', text)
  todo.set('done', false)
  todo.set('type', type)
  const saved = await todo.save()
  
  const newItem = { objectId: saved.id, content: text, done: false, type: type }
  
  if (type === 'short') {
    shortList.value.push(newItem)
    newShortItem.value = ''
    onDragChange('short') // 保存新顺序
  } else {
    longList.value.push(newItem)
    newLongItem.value = ''
    onDragChange('long')
  }
}

const toggleItem = async (item) => {
  item.done = !item.done
  const todo = AV.Object.createWithoutData('Todo', item.objectId)
  todo.set('done', item.done)
  await todo.save()

  // 🎉 礼花特效:只在完成时触发
  if (item.done) {
    confetti({
      particleCount: 100,
      spread: 70,
      origin: { y: 0.6 },
      colors: ['#bd34fe', '#41d1ff', '#ffffff'] // 你的主题色
    })
  }
}

const deleteItem = async (item) => {
  if (!confirm('确认删除?')) return
  if (item.type === 'short') {
    shortList.value = shortList.value.filter(t => t.objectId !== item.objectId)
    onDragChange('short')
  } else {
    longList.value = longList.value.filter(t => t.objectId !== item.objectId)
    onDragChange('long')
  }
  const todo = AV.Object.createWithoutData('Todo', item.objectId)
  await todo.destroy()
}

// 🔄 任务迁移逻辑 (双向)
const moveToSprint = async (item) => {
  // 从 long 移出
  longList.value = longList.value.filter(t => t.objectId !== item.objectId)
  onDragChange('long')

  // 加入 short (加入到底部)
  item.type = 'short'
  shortList.value.push(item)
  onDragChange('short')

  // 更新数据库
  const todo = AV.Object.createWithoutData('Todo', item.objectId)
  todo.set('type', 'short')
  await todo.save()
}

const moveToBacklog = async (item) => {
  // 从 short 移出
  shortList.value = shortList.value.filter(t => t.objectId !== item.objectId)
  onDragChange('short')

  // 加入 long
  item.type = 'long'
  longList.value.push(item)
  onDragChange('long')

  // 更新数据库
  const todo = AV.Object.createWithoutData('Todo', item.objectId)
  todo.set('type', 'long')
  await todo.save()
}

// 通用设置保存 (Memo表)
const saveSetting = async (key, value) => {
  const query = new AV.Query('Memo')
  query.equalTo('type', key)
  const exist = await query.first()
  if (exist) {
    exist.set('content', value)
    await exist.save()
  } else {
    const Memo = AV.Object.extend('Memo')
    const memo = new Memo()
    memo.set('type', key)
    memo.set('content', value)
    await memo.save()
  }
}
const saveMemo = (type) => saveSetting(type, type === 'short' ? shortMemo.value : longMemo.value)

</script>

<style scoped>
.todo-wrapper { max-width: 900px; margin: 0 auto; padding: 20px 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; transition: all 0.5s ease; }
/* 专注模式:背景变暗,居中 */
.todo-wrapper.focus-mode { max-width: 600px; padding-top: 50px; }
.todo-wrapper.focus-mode .dashboard { box-shadow: 0 20px 60px rgba(0,0,0,0.3); }

.lock-screen { text-align: center; padding: 80px 20px; }
.auth-card { background: var(--vp-c-bg-soft); padding: 40px; border-radius: 16px; display: inline-block; box-shadow: 0 10px 40px rgba(0,0,0,0.1); border: 1px solid var(--vp-c-divider); }

/* 🌟 周期控制台样式 */
.cycle-dashboard { background: var(--vp-c-bg-soft); padding: 20px; border-radius: 16px; border: 1px solid var(--vp-c-bg-soft-up); margin-bottom: 20px; }

.cycle-header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.cycle-title-group { display: flex; align-items: center; gap: 12px; }
.cycle-title { font-size: 1.5em; font-weight: 800; margin: 0; color: var(--vp-c-text-1); }

.day-badge { padding: 4px 10px; border-radius: 20px; font-size: 0.85em; font-weight: bold; border: 1px solid transparent; }
.day-badge.active { background: rgba(59, 130, 246, 0.1); color: #3b82f6; border-color: rgba(59, 130, 246, 0.2); }
.day-badge.future { background: var(--vp-c-bg-mute); color: var(--vp-c-text-2); }
.day-badge.overdue { background: rgba(244, 63, 94, 0.1); color: #f43f5e; border-color: rgba(244, 63, 94, 0.2); animation: pulse 2s infinite; }

.btn-icon, .btn-icon-small { background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.9em; color: var(--vp-c-text-1); transition: all 0.2s; margin-left: 8px; }
.btn-icon:hover, .btn-icon-small:hover { border-color: var(--vp-c-brand); color: var(--vp-c-brand); }
.btn-icon-small { padding: 4px 8px; font-size: 0.8em; margin: 0; }
.btn-text { background: none; border: none; font-size: 1.2em; cursor: pointer; padding: 0 5px; opacity: 0.5; }

.date-setter { background: var(--vp-c-bg); padding: 15px; border-radius: 8px; margin-bottom: 15px; border: 1px dashed var(--vp-c-divider); display: flex; align-items: center; gap: 10px; }
.date-setter input { padding: 5px 10px; border-radius: 4px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg-soft); color: var(--vp-c-text-1); }
.date-hint { font-size: 0.85em; color: var(--vp-c-text-2); }

/* 进度条 */
.bar-label { display: flex; justify-content: space-between; font-size: 0.85em; color: var(--vp-c-text-2); margin-bottom: 6px; font-weight: 600; }
.progress-track { height: 8px; background: var(--vp-c-divider); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
.time-bar-container { margin-bottom: 5px; }
.time-track { height: 10px; background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider); }
.time-fill { background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%); }
.time-fill.overdue { background: #f43f5e; }
.task-progress-container { padding: 0 5px 20px 5px; }
.task-fill { background: var(--vp-c-brand); opacity: 0.8; }

/* 主布局 */
.main-grid { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 20px; transition: all 0.3s; }
.focus-mode .main-grid { grid-template-columns: 1fr; } /* 专注模式下单列 */

@media (max-width: 768px) { .main-grid { grid-template-columns: 1fr; } }
.section { background: var(--vp-c-bg-soft); padding: 20px; border-radius: 16px; border: 1px solid var(--vp-c-bg-soft-up); display: flex; flex-direction: column; transition: all 0.3s; }
.current-sprint { border-left: 4px solid var(--vp-c-brand); }
.backlog { border-left: 4px solid var(--vp-c-text-3); opacity: 0.9; }

.header-actions { margin-left: auto; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px; flex-wrap: wrap; }
.section-header h3 { margin: 0; font-size: 1.1em; }
.memo-box { width: 100%; margin-top: 10px; }
.memo-input { width: 100%; border: 1px solid transparent; background: var(--vp-c-bg); border-radius: 8px; padding: 10px; font-size: 0.9em; color: var(--vp-c-text-2); resize: none; font-family: inherit; }
.memo-input:focus { outline: none; border-color: var(--vp-c-brand); color: var(--vp-c-text-1); }

.add-bar { display: flex; gap: 8px; margin-bottom: 15px; }
.add-bar input { flex: 1; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); }
.btn-add { background: var(--vp-c-brand); color: white; border: none; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; font-size: 1.2em; display: flex; align-items: center; justify-content: center; }

/* 列表与拖拽样式 */
.task-list { list-style: none; padding: 0; margin: 0; flex: 1; min-height: 50px; }
.task-list li { display: flex; align-items: flex-start; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--vp-c-divider); background: var(--vp-c-bg-soft); }
/* 拖拽时的幽灵样式 */
.ghost { opacity: 0.5; background: var(--vp-c-bg-mute); border: 2px dashed var(--vp-c-brand); }
/* 拖拽把手 */
.drag-handle { cursor: move; color: var(--vp-c-text-3); opacity: 0.5; padding-top: 2px; font-size: 0.9em; }
.task-list li:hover .drag-handle { opacity: 1; color: var(--vp-c-text-1); }

.custom-checkbox { position: relative; width: 20px; height: 20px; flex-shrink: 0; cursor: pointer; margin-top: 2px; }
.custom-checkbox input { opacity: 0; height: 0; width: 0; }
.checkmark { position: absolute; top: 0; left: 0; height: 20px; width: 20px; background-color: var(--vp-c-bg); border: 2px solid var(--vp-c-text-3); border-radius: 6px; }
.custom-checkbox input:checked ~ .checkmark { background-color: var(--vp-c-brand); border-color: var(--vp-c-brand); }
.checkmark:after { content: ""; position: absolute; display: none; left: 6px; top: 2px; width: 5px; height: 10px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); }
.custom-checkbox input:checked ~ .checkmark:after { display: block; }

.content { flex: 1; line-height: 1.5; font-size: 0.95em; color: var(--vp-c-text-1); }
.task-list li.done .content { text-decoration: line-through; color: var(--vp-c-text-3); opacity: 0.6; }
.move-btn, .del-btn { cursor: pointer; opacity: 0; transition: opacity 0.2s; font-size: 1.1em; padding: 0 5px; filter: grayscale(1); }
.task-list li:hover .move-btn, .task-list li:hover .del-btn { opacity: 1; }
.move-btn:hover { filter: grayscale(0); transform: scale(1.1); }
.del-btn:hover { color: #f43f5e; transform: scale(1.1); }

.empty-tip { text-align: center; color: var(--vp-c-text-3); padding: 30px 0; font-style: italic; font-size: 0.9em; }
.btn-primary { background: var(--vp-c-brand); color: white; padding: 10px 24px; border-radius: 8px; border: none; cursor: pointer; }
.error-msg { color: #f43f5e; margin-top: 15px; }

@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.6; } 100% { opacity: 1; } }
</style>

第三步:数据库适配(自动)

  • 后端零配置:LeanCloud 数据结构无需手动修改。
  • 数据映射
    • type: 'short' 自动对应 当前冲刺 (Sprint)
    • type: 'long' 自动对应 积压池 (Backlog)
    • 排序数据和周期设置会自动存储在 Memo 表中。

4. 使用建议

  • 晨间仪式:每天早上打开沉浸模式,将今天要做的 3 件事拖拽到列表最顶部。
  • 周期复盘:当时间进度条(顶部蓝紫条)跑满 100% 时,检查未完成任务,果断删除或扔回积压区,然后点击 📅 周期 开启下一个 10 天。

集成高性能流体交互页脚

我的需求

仿照https://www.trae.ai/ trae网页的最底部那个UI样式,我想把它也弄到我的博客网最底部,文字内容是PandaC,然后鼠标或者手指悬停在上面然后滑动,就能有马赛克在流动一样的效果

功能概述

在博客所有页面的最底部,添加一个基于 Canvas 的鼠标/触摸交互特效区域

  • 视觉效果:黑色背景,中间显示 "PandaC" Logo,鼠标移动产生类似霓虹流体/马赛克的拖尾效果
  • 性能策略:使用 CSS 硬件加速滤镜替代 Canvas 滤镜;引入 IntersectionObserver 实现“可见时才渲染”,离开视口自动停止 JS 计算,彻底解决发热卡顿问题
  • 布局策略:通过全局 Layout 插槽注入,利用 CSS 强制提升层级 (z-index) 解决侧边栏遮挡问题

核心代码实现

文件 1:交互组件

路径.vitepress/theme/components/MagicFooter.vue

vue
<template>
  <div class="magic-footer" ref="containerRef">
    <canvas ref="canvasRef"></canvas>
    
    <div class="content-layer">
      <h1 class="logo-text">PandaC</h1>
      <p class="sub-text"></p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const canvasRef = ref(null)
const containerRef = ref(null)

// --- 🔧【关键配置区】可自行修改此处参数 ---
const config = {
  particleCount: 55,    // 粒子数量:越少越流畅,越多越细腻 (建议 30-60)
  trailLength: 0.9,     // 拖尾长度(0~1):数值越大,尾巴消失得越慢
  sizeBase: 250,         // 粒子基础大小 (像素)
  sizeRange: 10,        // 粒子大小随机波动范围
  force: 0.4,           // 鼠标斥力/引力强度:数值大则粒子跑得快
  colorSpeed: 0.3         // 颜色变幻速度
}

// --- 状态变量 ---
let ctx = null
let width = 0
let height = 0
let particles = []
let mouse = { x: -1000, y: -1000 } // 初始鼠标放在屏幕外
let hue = 0
let animationId = null
let isVisible = false // 标记:页脚是否在屏幕内
let observer = null   // 观察者实例

// --- 粒子类定义 ---
class Particle {
  constructor() {
    this.x = Math.random() * width
    this.y = Math.random() * height
    // 初始速度
    this.vx = (Math.random() - 0.5) * 1.5 
    this.vy = (Math.random() - 0.5) * 1.5
    this.size = Math.random() * config.sizeRange + config.sizeBase
    this.color = `hsl(${hue}, 100%, 50%)`
  }

  update() {
    // 1. 基础位移
    this.x += this.vx
    this.y += this.vy

    // 2. 鼠标交互逻辑 (核心流体感)
    const dx = mouse.x - this.x
    const dy = mouse.y - this.y
    const distance = Math.sqrt(dx * dx + dy * dy)
    const maxDist = 200 // 鼠标影响半径

    if (distance < maxDist) {
      const force = (maxDist - distance) / maxDist
      // 计算斥力方向
      const directionX = (dx / distance) * force * config.force * 5
      const directionY = (dy / distance) * force * config.force * 5
      this.vx -= directionX
      this.vy -= directionY
    }

    // 3. 边界反弹
    if (this.x < 0 || this.x > width) this.vx *= -1
    if (this.y < 0 || this.y > height) this.vy *= -1

    // 4. 摩擦力 (防止速度无限增加)
    this.vx *= 0.96
    this.vy *= 0.96

    // 5. 颜色随位置和时间变化
    this.color = `hsl(${hue + this.x * 0.05 + this.y * 0.05}, 70%, 60%)`
  }

  draw() {
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
    ctx.fillStyle = this.color
    ctx.fill()
  }
}

// --- 初始化 ---
const init = () => {
  if (!canvasRef.value) return
  ctx = canvasRef.value.getContext('2d')
  resize()
  // 创建粒子群
  for (let i = 0; i < config.particleCount; i++) {
    particles.push(new Particle())
  }
}

// --- 窗口适配 ---
const resize = () => {
  if (!containerRef.value || !canvasRef.value) return
  width = containerRef.value.offsetWidth
  height = containerRef.value.offsetHeight
  // 设置 Canvas 内部分辨率等于 DOM 尺寸
  canvasRef.value.width = width
  canvasRef.value.height = height
}

// --- 🚀 动画循环 (核心渲染) ---
const animate = () => {
  // 性能优化 1:如果不可见,直接停止计算
  if (!isVisible) return

  // 视觉魔法:每一帧覆盖一层半透明黑色,形成拖尾
  ctx.fillStyle = `rgba(0, 0, 0, ${1 - config.trailLength})`
  ctx.fillRect(0, 0, width, height)

  hue += config.colorSpeed

  particles.forEach(p => {
    p.update()
    p.draw()
  })

  animationId = requestAnimationFrame(animate)
}

// --- 鼠标事件处理 ---
const handleMouseMove = (e) => {
  if (!isVisible) return
  const rect = containerRef.value.getBoundingClientRect()
  mouse.x = e.clientX - rect.left
  mouse.y = e.clientY - rect.top
}

const handleTouchMove = (e) => {
  if (!isVisible) return
  const rect = containerRef.value.getBoundingClientRect()
  mouse.x = e.touches[0].clientX - rect.left
  mouse.y = e.touches[0].clientY - rect.top
}

// --- 生命周期钩子 ---
onMounted(() => {
  init()
  window.addEventListener('resize', resize)
  
  if (containerRef.value) {
    containerRef.value.addEventListener('mousemove', handleMouseMove)
    containerRef.value.addEventListener('touchmove', handleTouchMove)
    
    // 性能优化 2:IntersectionObserver
    // 只有当页脚进入屏幕时才启动动画,离开时自动暂停
    observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        isVisible = true
        animate() // 开始跑
      } else {
        isVisible = false
        cancelAnimationFrame(animationId) // 停止跑,省电
      }
    })
    observer.observe(containerRef.value)
  }
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', resize)
  cancelAnimationFrame(animationId)
  if (observer) observer.disconnect()
})
</script>

<style scoped>
.magic-footer {
  position: relative;
  width: 100%;
  height: 300px; /* 页脚高度,可调整 */
  background: #000;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
  user-select: none;
  
  /* 修复:提高层级,防止被侧边栏遮挡 */
  z-index: 100; 
}

canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  /* 性能优化:使用 CSS 滤镜实现模糊融合效果,比 Canvas 滤镜快 10 倍 */
  filter: blur(20px) contrast(150%);
}

.content-layer {
  position: relative;
  z-index: 2; /* 确保文字在粒子上面 */
  text-align: center;
  pointer-events: none; /* 关键:让鼠标能穿透文字触发 Canvas 事件 */
  mix-blend-mode: overlay; /* 混合模式,让文字与背景融合 */
}

.logo-text {
  font-family: system-ui, -apple-system, sans-serif;
  font-size: 16rem;
  font-weight: 800;
  color: #fff;
  margin: 0;
  text-transform: uppercase;
}

.sub-text {
  font-size: 1rem;
  color: rgba(255,255,255,0.6);
  margin-top: 10px;
}

/* 移动端适配 */
@media (max-width: 768px) {
  .magic-footer {
    height: 200px;
  }
  .logo-text {
    font-size: 3rem;
  }
}
</style>

文件 2:全局布局封装

路径.vitepress/theme/Layout.vue

作用:使用插槽将 Footer 注入到所有页面的最底部。

vue
<script setup>
import DefaultTheme from 'vitepress/theme'
// 注意路径:这里引用的是 components 目录下的组件
import MagicFooter from './components/MagicFooter.vue'

const { Layout } = DefaultTheme
</script>

<template>
  <Layout>
    <template #layout-bottom>
      <MagicFooter />
    </template>
  </Layout>
</template>

文件 3:全局样式修复

路径.vitepress/theme/style.css

作用:解决侧边栏遮挡和主页内容高度塌陷问题

css
/* 1. 强制撑开主内容区域高度 */
/* 避免内容太少时,页脚直接挂在屏幕中间,确保页脚永远在底部 */
.VPContent {
  min-height: 100vh;
}

/* 2. 侧边栏层级修复 */
/* 确保 MagicFooter 的层级高于 VitePress 默认侧边栏,实现覆盖效果 */
.magic-footer {
  position: relative;
  z-index: 200 !important;
}

/* 3. (可选) 优化移动端阅读体验,防止左右晃动 */
body {
  overflow-x: hidden;
}

文件 4:主题入口注册

路径.vitepress/theme/index.js

作用:引入新布局和新样式文件。

javascript
import DefaultTheme from 'vitepress/theme'
// 引入自定义的 Layout
import MyLayout from './Layout.vue'
// 引入自定义的样式 (修复布局用)
import './style.css'

export default {
  ...DefaultTheme,
  // 替换默认 Layout
  Layout: MyLayout,
  enhanceApp({ app }) {
    // 其他组件注册...
  }
}

部署检查清单

  1. 文件位置:确认 Layout.vuetheme 根目录下,MagicFooter.vuetheme/components 下。
  2. 样式引入:确认 index.js 里写了 import './style.css'
  3. 性能验证:本地运行 npm run docs:dev,滚动到最底部查看效果,移开视口确认风扇是否安静。

博客 UI 视觉与交互重构

需求文档

项目名称:博客 UI 视觉与交互重构 (Visual & Interactive Overhaul) 参考风格Trae.ai (暗黑科技风 / 沉浸式网格)

  1. **双模态适配 **:
    • **暗黑模式 **:复刻 Trae 的深邃感,黑底 + 微光网格 + 动态光斑
    • 明亮模式:保持干净整洁,使用浅色底 + 灰色网格,确保文字可读性,不强制使用黑色背景
  2. 全站沉浸式覆盖
    • 动态光影和网格背景必须覆盖整个网页(包括首页和文章页)
    • 不能出现滚动页面时背景断层(截断)的情况
  3. UI 组件透明化
    • 顶部导航栏 (Navbar)左侧侧边栏 (Sidebar)底部页脚 (Footer) 不能是实色块,必须进行磨砂/半透明处理,让背景的网格和光效能透出来,形成一体感
  4. 动态效果优化
    • 这部分注释掉代码,影响观看
    • 背景的光斑(光影)移动速度需加快,拒绝“死图感”,要呈现明显的流动呼吸效果
  5. 保留搜索框原有的背景底板,优化使用体验

设计思路与操作流程

为了实现“双模态 + 全覆盖”,我们不能简单暴力地给 div 加颜色,而要利用 VitePress 的CSS 变量系统类名选择器html.dark vs html:not(.dark))。

核心策略:

  1. 挂载点上移:将背景和网格挂载到 body 标签上(通过 CSS 选择器),而不是里面的某个 div,确保 100% 全屏覆盖。
  2. 变量控制:分别定义白天和黑夜两套背景变量。
  3. 透视处理:强制重写 VitePress 默认的导航栏和侧边栏背景变量,改为 rgba 半透明色 + backdrop-filter 高斯模糊。

实施代码

请打开 .vitepress/theme/style.css。复制以下代码(不删除原有的MagicFooter内容)

css
/* =======================================================
   PandaC Blog MagicFooter
   ======================================================= */
/* 1. 强制撑开主内容区域高度 */
/* 避免内容太少时,页脚直接挂在屏幕中间,确保页脚永远在底部 */
.VPContent {
  min-height: 100vh;
}

/* 2. 侧边栏层级修复 */
/* 确保 MagicFooter 的层级高于 VitePress 默认侧边栏,实现覆盖效果 */
.magic-footer {
  position: relative;
  z-index: 200 !important;
}

/* 3. (可选) 优化移动端阅读体验,防止左右晃动 */
body {
  overflow-x: hidden;
}

/* =======================================================
   PandaC Blog UI 重构 v3.0 - 双模态沉浸式系统
   ======================================================= */

/* -------------------------------------------------------
   1. 全局背景系统 (Global Background System)
   原理:利用伪元素 ::before/::after 在 body 上创建固定背景
------------------------------------------------------- */

/* --- 通用基础设置 --- */
body {
  margin: 0;
  width: 100%;
  min-height: 100vh;
  /* 开启平滑过渡,切换黑白模式时更丝滑 */
  transition: background-color 0.5s ease; 
}

/* --- 🌙 暗黑模式 (Dark Mode) --- */
html.dark body {
  /* 1. 背景色:从纯黑 #050505 改为深灰 #121212 (护眼标准色) */
  background-color: #121212; 
  
  /* 2. 网格线:把透明度从 0.03 降到 0.015 (若隐若现才是高级) */
  background-image: 
    linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px);
  
  background-size: 80px 80px;
  background-position: center top;
  background-attachment: fixed;
}

/* --- ☀️ 明亮模式 (Light Mode) --- */
html:not(.dark) body {
  background-color: #ffffff; /* 纯净白 */
  /* 绘制浅灰色网格,保持清爽 */
  background-image: 
    linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
  background-size: 80px 80px;
  background-attachment: fixed;
}


/* -------------------------------------------------------
   2. 动态光影氛围 (Dynamic Atmosphere)
   只在暗黑模式下启用光斑,白天模式保持干净
------------------------------------------------------- */

/* -------------------------------------------------------
   2. 动态光影氛围 (Dynamic Atmosphere)
   ↓↓↓ 把这下面的一大坨全部删掉或者注释掉 ↓↓↓
------------------------------------------------------- */

/* html.dark body::before,
html.dark body::after {
  content: '';
  position: fixed; 
  z-index: -1;     
  filter: blur(100px); 
  pointer-events: none; 
  opacity: 0.6;    
}

html.dark body::before {
  top: -10%;
  left: -10%;
  width: 60vw;
  height: 60vh;
  background: radial-gradient(circle, rgba(140, 69, 255, 0.3) 0%, rgba(0,0,0,0) 70%);
  animation: floatFast 10s infinite alternate ease-in-out;
}

html.dark body::after {
  bottom: -10%;
  right: -10%;
  width: 70vw;
  height: 70vh;
  background: radial-gradient(circle, rgba(46, 144, 255, 0.25) 0%, rgba(0,0,0,0) 70%);
  animation: floatFast 12s infinite alternate-reverse ease-in-out;
}

@keyframes floatFast {
  0% { transform: translate(0, 0) scale(1); }
  50% { transform: translate(15%, 10%) scale(1.2); } 
  100% { transform: translate(-10%, 15%) scale(0.9); }
}
*/


/* -------------------------------------------------------
   3. 组件透明化适配 (Component Transparency)
   覆盖 VitePress 默认变量,让导航栏、侧边栏、页脚变透明
------------------------------------------------------- */

:root {
  /* 强制将内容区背景设为透明,让 body 的背景透出来 */
  --vp-c-bg: transparent !important;
  --vp-c-bg-alt: transparent !important; 
}

/* --- 顶部导航栏 (Navbar) --- */
.VPNavBar {
  /* 使用半透明背景 + 毛玻璃特效 */
  background-color: rgba(var(--vp-c-bg-rgb), 0.1) !important;
  backdrop-filter: blur(12px) !important;
  -webkit-backdrop-filter: blur(12px) !important;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
}

/* --- 左侧侧边栏 (Sidebar) --- */
.VPSidebar {
  background-color: rgba(var(--vp-c-bg-rgb), 0.1) !important;
  backdrop-filter: blur(12px) !important;
  -webkit-backdrop-filter: blur(12px) !important;
  border-right: 1px solid rgba(255, 255, 255, 0.05) !important;
}

/* --- 底部页脚 (Footer) --- */
.VPFooter {
  background-color: transparent !important;
  border-top: 1px solid rgba(255, 255, 255, 0.05) !important;
}
/* 如果你有 MagicFooter,这里可以微调 */
.magic-footer {
  position: relative;
  z-index: 200 !important;
  /* 如果希望 Footer 背景也透一点,可以调这里,但建议保持全黑以突出粒子 */
}

/* 适配白天模式下的透明度颜色 (让磨砂更白一点) */
html:not(.dark) .VPNavBar,
html:not(.dark) .VPSidebar {
  background-color: rgba(255, 255, 255, 0.6) !important;
  border-color: rgba(0, 0, 0, 0.05) !important;
}

html.dark .VPNavBar,
html.dark .VPSidebar {
  background-color: rgba(0, 0, 0, 0.3) !important; /* 暗黑模式稍微黑一点 */
}


/* -------------------------------------------------------
   4. 细节微调 (Details)
------------------------------------------------------- */

/* 修复首页 Hero 文字在网格上的显示 */
.VPHero .name {
  /* 渐变色文字 */
  background: -webkit-linear-gradient(120deg, #bd34fe 30%, #41d1ff);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  /* 增加投影,防止背景太花看不清字 */
  filter: drop-shadow(0 0 20px rgba(189, 52, 254, 0.5));
}

/* 修复文章页内容区的层级,防止被光斑覆盖无法点击 */
.VPContent {
  position: relative;
  z-index: 10;
}

/* 首页 Bento 卡片适配双模式 */
.VPFeature {
  transition: all 0.3s ease !important;
  border-radius: 16px !important;
}

/* 暗黑模式卡片:深色玻璃 */
html.dark .VPFeature {
  background-color: rgba(20, 20, 20, 0.6) !important;
  border: 1px solid rgba(255, 255, 255, 0.1) !important;
  backdrop-filter: blur(10px);
}

/* 白天模式卡片:浅色清爽 */
html:not(.dark) .VPFeature {
  background-color: rgba(255, 255, 255, 0.8) !important;
  border: 1px solid rgba(0, 0, 0, 0.05) !important;
  box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}

/* 卡片悬浮特效 (通用) */
.VPFeature:hover {
  transform: translateY(-5px);
}

/* =======================================================
   5. 搜索框修复补丁 (Search Box Fix)
   修复因全局透明化导致的搜索框文字看不清的问题
   ======================================================= */
/* =======================================================
   🛡️ 最终核弹级修复:搜索框实体化 (Solid Search Shell)
   ======================================================= */

/* 1. 锁定搜索框的核心容器 (Shell) */
/* 这是搜索框真正的“身体”,必须给它刷上不透明的漆 */
.VPLocalSearchBox .shell {
  background-color: #ffffff !important; /* 白天模式强制纯白 */
  border-radius: 8px; /* 稍微圆角一点 */
  border: 1px solid #e2e2e3 !important;
  box-shadow: 0 10px 60px rgba(0, 0, 0, 0.5) !important; /* 加上重阴影,把它从背景里“抬”起来 */
}

/* 2. 暗黑模式下的“身体”颜色 */
html.dark .VPLocalSearchBox .shell {
  background-color: #1b1b1f !important; /* 强制深灰,绝不透明 */
  border: 1px solid #333 !important;
}

/* 3. 修复搜索结果列表区 */
/* 无论父级怎么设置,这里必须有实心背景 */
.VPLocalSearchBox .results {
  background-color: #ffffff !important; /* 白天白底 */
}
html.dark .VPLocalSearchBox .results {
  background-color: #1b1b1f !important; /* 黑天黑底 */
}

/* 4. 修复顶部输入框区域 */
.VPLocalSearchBox .search-bar {
  background-color: #f6f6f7 !important; /* 白天浅灰 */
}
html.dark .VPLocalSearchBox .search-bar {
  background-color: #202024 !important; /* 黑天深灰 */
}

/* 5. 修复底部的 Footer (如果有) */
.VPLocalSearchBox .footer {
   background-color: #ffffff !important;
}
html.dark .VPLocalSearchBox .footer {
   background-color: #1b1b1f !important;
}

/* --- 细节修正:文字颜色确保可见 --- */
/* 暗黑模式文字 */
html.dark .VPLocalSearchBox .result-title { color: #fff !important; }
html.dark .VPLocalSearchBox .excerpt-wrapper { color: #aaa !important; }

/* 白天模式文字 */
html:not(.dark) .VPLocalSearchBox .result-title { color: #333 !important; }

验证

保存文件后,请进行以下操作来验收效果:

  1. 切换黑白模式:点击右上角的太阳/月亮图标。

    期待效果

    • 黑模式:深黑背景,紫色蓝色光斑快速流动,白色网格清晰。
    • 白模式:纯白背景,无光斑(或极淡),灰色网格,干净整洁。
  2. 滚动页面:在首页和文章页上下滚动。

    期待效果:背景的光影是固定在屏幕上的(类似看窗外风景),不会随着文字滚动而断裂。

  3. 检查侧边栏:进入任意一篇文章。

    期待效果:左侧目录栏不再是实心的灰色块,而是半透明的磨砂玻璃,你可以隐约看到背后的网格在滑动。

  4. 检查动画速度

    期待效果:背景的光晕现在应该有明显的“呼吸感”和位移,不再像一张静态图片。

这套代码实现了全站视觉语言的统一,既保留了 Trae 的酷炫,又照顾了日常阅读的舒适度。

首页粒子雪样式

我的需求

嗯,这次你说的内容就算是我的博客网UI重构了,然后我想单独再对首页进行样式的添加,和刚才的功能算区分开来吧,这次的首页添加样式,我希望像“雪”或者“雨”一样有粒子或者像素的移动,因为现在的首页看起来还是比较单调。然后对应两种观看模式,只在暗黑模式下,添加首页的效果,白天模式就不变动。在黑暗模式下添加粒子变化效果,也不修改刚才已经调教好的UI

这次的需求非常明确:仅在首页 + 暗黑模式下,增加一层**“数字雪/像素雨”的动态粒子效果。这属于“锦上添花”的视觉层,不变动刚才辛苦调好的 CSS 背景,而是通过新增一个 Vue 组件挂载到首页来实现。这样的好处是解耦**:如果你不喜欢了,删掉这个组件就行,完全不会破坏之前的样式

核心思路

  1. 组件化:创建一个名为 HomePixelSnow.vue 的组件

  2. 智能判断:利用 VitePress 的 useData() API,实时监听两个状态:

    frontmatter.layout === 'home' (是否在首页?)

    isDark === true (是否是暗黑模式?)

    只有当两者同时满足时,才启动 Canvas 动画;否则直接销毁或隐藏,节省性能

  3. 层级策略

    Previous Background (光斑/网格) z-index: -1

    New Pixel Snow (本组件) z-index: 1 (位于背景之上,文字之下)

    Content (文字/卡片) z-index: 10

操作流程

步骤 1:新建粒子组件

.vitepress/theme/components/ 目录下,新建文件 HomePixelSnow.vue

粘贴以下代码(包含完整的“数字雪”逻辑):

vue
<template>
  <canvas 
    v-show="shouldRender" 
    ref="canvasRef" 
    class="home-pixel-snow"
  ></canvas>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { useData } from 'vitepress'

const { isDark, frontmatter } = useData()
const canvasRef = ref(null)

// --- 🎯 智能判断:只在“暗黑模式”且“位于首页”时渲染 ---
const shouldRender = computed(() => {
  return isDark.value && frontmatter.value.layout === 'home'
})

// --- ⚙️ 参数配置 (可自行修改) ---
const config = {
  count: 80,          // 粒子数量 (雪量)
  speedBase: 0.5,     // 基础下落速度
  speedRange: 1.0,    // 速度随机范围
  sizeBase: 1,        // 粒子大小
  color: 'rgba(140, 200, 255, 0.4)' // 淡淡的蓝白色,配合Trae风格
}

// --- 内部变量 ---
let ctx = null
let width = 0
let height = 0
let particles = []
let animationId = null

class Particle {
  constructor() {
    this.reset(true)
  }

  reset(initial = false) {
    this.x = Math.random() * width
    // 如果是初始化,随机分布在全屏;如果是重置,从顶部落下
    this.y = initial ? Math.random() * height : -10 
    this.speed = config.speedBase + Math.random() * config.speedRange
    this.size = Math.random() * 2 + config.sizeBase
    // 偶尔出现几个大一点的“像素块”,增加科技感
    if (Math.random() < 0.1) this.size += 2 
    this.opacity = Math.random() * 0.5 + 0.2
  }

  update() {
    this.y += this.speed
    // 如果掉出屏幕底部,重置到顶部
    if (this.y > height) {
      this.reset()
    }
  }

  draw() {
    ctx.fillStyle = config.color
    ctx.globalAlpha = this.opacity
    // 绘制矩形而不是圆形,更符合“像素/科技”感
    ctx.fillRect(this.x, this.y, this.size, this.size)
    ctx.globalAlpha = 1.0
  }
}

const init = () => {
  if (!canvasRef.value) return
  ctx = canvasRef.value.getContext('2d')
  resize()
  particles = []
  for (let i = 0; i < config.count; i++) {
    particles.push(new Particle())
  }
  animate()
}

const resize = () => {
  if (!canvasRef.value) return
  width = window.innerWidth
  height = window.innerHeight
  canvasRef.value.width = width
  canvasRef.value.height = height
}

const animate = () => {
  if (!shouldRender.value) return // 如果不该渲染,直接停止循环

  ctx.clearRect(0, 0, width, height)
  particles.forEach(p => {
    p.update()
    p.draw()
  })
  animationId = requestAnimationFrame(animate)
}

// --- 监听状态变化 ---
watch(shouldRender, (newVal) => {
  if (newVal) {
    // 刚切回暗黑模式或首页 -> 启动
    if (particles.length === 0) init()
    else animate()
  } else {
    // 切走 -> 暂停
    cancelAnimationFrame(animationId)
    if (ctx) ctx.clearRect(0, 0, width, height)
  }
})

onMounted(() => {
  window.addEventListener('resize', resize)
  if (shouldRender.value) {
    init()
  }
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', resize)
  cancelAnimationFrame(animationId)
})
</script>

<style scoped>
.home-pixel-snow {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none; /* 让鼠标穿透,不影响点击 */
  
  /* 层级控制:背景(-1) < 粒子(1) < 文字(10) */
  z-index: 1; 
}
</style>

步骤 2:注册到布局中

我们要把这个组件放到 Layout.vue 里,让它作为全局布局的一部分加载

打开 .vitepress/theme/Layout.vue,修改为:

vue
<script setup>
import DefaultTheme from 'vitepress/theme'
// 1. 引入刚才写的新组件
import MagicFooter from './components/MagicFooter.vue'
import HomePixelSnow from './components/HomePixelSnow.vue' 

const { Layout } = DefaultTheme
</script>

<template>
  <Layout>
    <template #layout-top>
      <HomePixelSnow />
    </template>

    <template #layout-bottom>
      <MagicFooter />
    </template>
  </Layout>
</template>

效果验收

现在保存所有文件,查看页面:

  1. 场景 A:首页 + 暗黑模式

    你会看到刚才设置好的深邃背景之上,有一些细小的、正方形的**“像素点”**在缓缓飘落

    就像《黑客帝国》或者是赛博朋克城市里的电子雨,非常有科技感

    它位于文字下方,不会挡住你的标题

  2. 场景 B:首页 + 白天模式

    粒子瞬间消失,背景变回干净的浅灰网格

  3. 场景 C:文章页

    无论黑白,都没有粒子干扰,保持阅读纯净

怎么自定义?(笔记重点)

如果你觉得“雨”太大了或者太快了,直接去 HomePixelSnow.vue 里的 config 对象修改:

  • count: 80 -> 改小(如 40)会变稀疏,改大(如 150)会变成暴风雪。
  • speedBase: 0.5 -> 改大(如 2.0)会变成下大雨,改小(如 0.1)会变成悬浮的星尘。
  • color: 想换成 Trae 的紫色?改成 'rgba(189, 52, 254, 0.4)'

这样你的首页就真正拥有了独一无二的动态灵魂

Layout组件 - 总结样式与结构

结构与样式

你的需求是什么?需要动哪里?例子
纯视觉,不交互颜色、渐变、图片、简单的位移CSS (样式表)黑色背景、网格、模糊光斑
有逻辑,有交互需要 JS 计算、需要监听鼠标、需要 HTML 标签Layout (组件化)粒子雪(JS算坐标)、页脚(JS算流体)、评论区

为什么 Layout.vue 是“总指挥”?

Layout.vue 就是你博客的母版 (Master Template)

VitePress 的运行逻辑是这样的:

  1. 用户访问网页。
  2. VitePress 读取 Layout.vue
  3. 它看到 <HomePixelSnow /> —— “哦,这里要挂个投影仪。”
  4. 它看到 <Content /> —— “哦,这里显示文章内容。”
  5. 它看到 <MagicFooter /> —— “哦,这里放个跑步机。”
  6. 最后,它读取 style.css —— “哦,把墙全刷成黑的。”

所以:

  • 加东西(组件),改 Layout
  • 改颜色(皮肤),改 CSS

首页核心内容橱窗搭建

我的需求

在网站首页,映入眼帘的就是最上方的导航栏、然后到hero区(网站的一些介绍、banner图等)、然后是vitepress特有的features区域(写一点内容介绍、内容特点等),接下来下面的一大块就是空白,然后到最底下的版权信息和页脚。

我想要的就是,将那一大块空白填补上,扩充一些内容,实现个人开发项目的展示与推荐(不同于开发笔记,这里展示的列表中,每一条都是对应项目的获取链接与使用指南);以及最近发布章的列表展示,这个根据时间自动获取并展示。

在首页 Banner 下方搭建一个高信息密度的内容橱窗,包含:

  • 精选项目 (Projects):左侧 2/3 区域,手动配置的彩色卡片,展示核心作品
  • 最近发布 (Recent):右侧 1/3 区域,自动抓取最新的 Markdown 文章列表
  • 全站归档 (Archive):一个独立的时间轴页面,按年份展示所有文章(这个在下一个小章节-“全站归档页”实现)
  • 视觉优化:实现与上方 Hero 区域的像素级对齐

模块一:数据层

功能概述: 创建一个数据加载器,自动扫描 docs 目录下所有的 Markdown 文件,提取文章标题、日期和分类信息,并按时间倒序排列。解决手动更新文章列表的痛点

部署流程:在 docs/.vitepress/theme/utils/ 下新建文件

  • 注意:为了兼容 ESM 模块规范,文件后缀必须命名为 .mjs

代码详情 (docs/.vitepress/theme/utils/posts.data.mjs)

javascript
import { createContentLoader } from 'vitepress'

// 使用 **/*.md 递归扫描所有子目录下的文章
export default createContentLoader('**/*.md', {
  includeSrc: false,
  render: true,
  transform(rawData) {
    return rawData
      .filter(page => {
        // 过滤首页、About页、以及刚才新建的 Archive 页
        if (page.url === '/' || page.url === '/index.html') return false;
        if (page.url.startsWith('/about/')) return false; 
        if (page.url.includes('/archive')) return false; // 不把归档页也算进文章里
        return true;
      })
      .sort((a, b) => {
        const dateA = a.frontmatter.date ? +new Date(a.frontmatter.date) : 0;
        const dateB = b.frontmatter.date ? +new Date(b.frontmatter.date) : 0;
        return dateB - dateA;
      })
      .map(page => {
        // 🔥 核心修改:提取完整路径作为分类
        // page.url 格式通常是 /01.Coding/Java/MyPost.html
        
        // 1. 去掉开头和结尾的 /,并按 / 分割
        const parts = page.url.split('/').filter(p => p.length > 0);
        
        // 2. 去掉最后一个元素(因为那是文件名,如 MyPost.html)
        parts.pop();
        
        // 3. 将剩余部分用 ' / ' 连接起来
        // 结果示例: "01.Coding / Java"
        const category = parts.join(' / ');

        return {
          title: page.frontmatter.title || '无标题',
          url: page.url,
          date: page.frontmatter.date ? { time: +new Date(page.frontmatter.date) } : null,
          category: category || '未分类' // 如果是在根目录,显示未分类
        }
      })
  }
})

模块二:组件层

功能概述: 首页的核心展示组件

  • 左侧 (2/3):精选项目展示卡片(静态数据,支持渐变背景和图标)
  • 右侧 (1/3):最近更新文章列表(动态数据,读取自模块一)
  • 样式优化:使用了负边距和最大宽度控制,以对齐上方的 Hero/Features 区域

部署流程

docs/.vitepress/theme/components/ 下新建 HomeContent.vue

  • 注意:引用数据文件时要用 .mjs 后缀

代码详情 (docs/.vitepress/theme/components/HomeContent.vue)

vue
<script setup>
import { computed } from 'vue'
// 引入数据加载器
import { data as posts } from '../utils/posts.data.mjs'

// --- 1. 项目配置 (在此处手动管理精选项目) ---
const projects = [
  {
    title: 'Interest E-Commerce',
    desc: '基于 SpringBoot + Vue 的兴趣电商平台,集成了 AI 个性化推荐算法。',
    icon: '🛍️',
    color: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%)',
    tags: ['Java', 'Vue3', 'AI Rec'],
    link: '/docs/01.Coding/' 
  },
  {
    title: 'Social Export Helper',
    desc: '油猴脚本,一键导出社交平台收藏夹数据,解决数据迁移痛点。',
    icon: '🛠️',
    color: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
    tags: ['Tampermonkey', 'JS', 'CSV'],
    link: 'https://github.com/'
  },
  {
    title: 'My Knowledge Base',
    desc: '基于 VitePress 构建的个人全栈知识库,自动化部署。',
    icon: '📚',
    color: 'linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%)',
    tags: ['VitePress', 'CI/CD'],
    link: '/docs/01.Coding/'
  }
]

// --- 2. 文章数据处理 ---
const recentPosts = computed(() => {
  return posts ? posts.slice(0, 6) : [] // 取最新的 6 篇
})

const formatDate = (date) => {
  if (!date) return ''
  return new Date(date.time).toLocaleDateString('zh-CN', {
    month: '2-digit',
    day: '2-digit'
  })
}
</script>

<template>
  <div class="home-content-wrapper">
    <div class="content-grid">
      <div class="col-left">
        <div class="section-header">
          <h2 class="section-title">🚀 推荐项目</h2>
          <a href="/docs/04.Share/个人开发产品分享/" class="more-link">View All →</a>
        </div>
        <div class="project-grid">
          <a v-for="(item, index) in projects" :key="index" :href="item.link" class="project-card">
            <div class="card-cover" :style="{ background: item.color }">
              <span class="card-icon">{{ item.icon }}</span>
            </div>
            <div class="card-body">
              <h3 class="card-title">{{ item.title }}</h3>
              <p class="card-desc">{{ item.desc }}</p>
              <div class="card-tags">
                <span v-for="tag in item.tags" :key="tag" class="tag">{{ tag }}</span>
              </div>
            </div>
          </a>
        </div>
      </div>

      <div class="col-right">
        <div class="section-header">
          <h2 class="section-title">📅 最近发布</h2>
          <a href="/docs/archive" class="more-link">Archive</a>
        </div>
        <div class="updates-container">
          <ul class="post-list">
            <li v-for="(post, index) in recentPosts" :key="index" class="post-item">
              <a :href="post.url" class="post-link">
                <div class="post-meta">
                  <span class="post-date">{{ formatDate(post.date) }}</span>
                  <span class="post-cat" v-if="post.category">{{ post.category }}</span>
                </div>
                <span class="post-title">{{ post.title }}</span>
              </a>
            </li>
          </ul>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
/* --- 布局核心变量 --- */
.home-content-wrapper {
  --content-max-width: 1370px;
  --grid-negative-margin: -64px;
}

/* 外层容器 */
.home-content-wrapper {
  width: 100%;
  margin-top: 0;
  display: flex;
  justify-content: center;
  padding: 0 24px;
}

@media (min-width: 640px) { .home-content-wrapper { padding: 0 48px; } }
@media (min-width: 960px) { .home-content-wrapper { padding: 0 64px; } }

/* 核心网格 */
.content-grid {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 32px;
  max-width: var(--content-max-width);
  margin: 0 auto;
}

/* 响应式布局 */
@media (min-width: 640px) {
  .content-grid {
    grid-template-columns: repeat(2, 1fr);
    width: calc(100% + (var(--grid-negative-margin) * -2));
    margin-left: var(--grid-negative-margin);
    margin-right: var(--grid-negative-margin);
  }
}

@media (min-width: 768px) {
  .content-grid {
    flex-direction: row;
    align-items: flex-start;
  }
  .col-left { flex: 1 1 68%; }
  .col-right { flex: 1 1 32%; margin-left: 32px; }
}

/* --- 通用排版 --- */
.section-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 16px; padding: 0 8px; }
.section-title { font-size: 1.2rem; font-weight: 700; margin: 0; border: none; }
.more-link { font-size: 0.9rem; color: var(--vp-c-brand); text-decoration: none; font-weight: 500; }
.more-link:hover { text-decoration: underline; }

.project-grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 640px) { .project-grid { grid-template-columns: repeat(2, 1fr); } }

/* ✨ 核心修改区:磨砂玻璃卡片样式 ✨ 
  替换了原本的 background-color: var(--vp-c-bg-soft)
*/
.project-card { 
  display: flex; 
  flex-direction: column; 
  
  /* 磨砂玻璃特效 */
  background: rgba(255, 255, 255, 0.03); 
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);

  border-radius: 12px; 
  overflow: hidden; 
  transition: all 0.3s ease; 
  text-decoration: none !important; 
  height: 100%; 
}

/* 悬停特效:上浮 + 背景稍微变亮 */
.project-card:hover { 
  transform: translateY(-4px); 
  box-shadow: 0 12px 40px rgba(0,0,0,0.4); 
  border-color: var(--vp-c-brand); 
  background: rgba(255, 255, 255, 0.08); /* 悬停时稍微亮一点 */
}

.card-cover { height: 80px; display: flex; align-items: center; justify-content: center; }
.card-icon { font-size: 2.5rem; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); }
.card-body { padding: 16px; display: flex; flex-direction: column; flex-grow: 1; }
.card-title { margin: 0; font-size: 1rem; font-weight: 600; color: var(--vp-c-text-1); }
.card-desc { margin: 8px 0 12px; font-size: 0.85rem; color: var(--vp-c-text-2); line-height: 1.5; flex-grow: 1; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag { font-size: 0.7rem; padding: 2px 6px; background-color: rgba(255, 255, 255, 0.1); color: var(--vp-c-text-2); border-radius: 4px; }

/* ✨ 核心修改区:磨砂玻璃列表样式 ✨ 
*/
.updates-container { 
  /* 磨砂玻璃特效 */
  background: rgba(255, 255, 255, 0.03); 
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);

  border-radius: 12px; 
  padding: 16px; 
}

.post-list { list-style: none; padding: 0; margin: 0; }
.post-item { position: relative; padding-left: 16px; margin-bottom: 16px; border-left: 2px solid var(--vp-c-divider); }
.post-item:last-child { margin-bottom: 0; }
.post-link { display: block; text-decoration: none !important; color: var(--vp-c-text-1) !important; transition: all 0.2s; padding-left: 8px; }
.post-link:hover { transform: translateX(4px); }
.post-link:hover .post-title { color: var(--vp-c-brand); }
.post-meta { display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: var(--vp-c-text-3); margin-bottom: 4px; }
.post-date { font-family: monospace; }
/* 分类标签也改成半透明,更协调 */
.post-cat { background: rgba(255, 255, 255, 0.1); padding: 0 4px; border-radius: 3px; font-size: 0.7rem; }
.post-title { font-size: 0.95rem; line-height: 1.4; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>

模块三:全局注册

功能概述: 将自定义组件注册到 VitePress 主题中,使其可以在 Markdown 文件中直接使用

部署流程: 修改 docs/.vitepress/theme/index.js

代码详情

javascript
import DefaultTheme from 'vitepress/theme'
import HomeContent from './components/HomeContent.vue' // 引入组件

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    // 注册组件
    app.component('HomeContent', HomeContent)
  }
}

模块四:首页布局组装

功能概述: 配置首页的 Hero 区域、Features 区域(蓝框),并在下方插入我们自定义的 HomeContent关键修改:通过 CSS 强制减少了 Hero 和 Features 区域底部的留白,使页面更紧凑

代码详情 (docs/index.md)

html
---
layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界"
  tagline: CS Student / 摄影爱好者 / 终身学习者
  image:
    src: https://pandac-new-begin.oss-cn-chengdu.aliyuncs.com/undefined202512162142393.png
    alt: Cover Image
  actions:
    - theme: brand
      text: 💻 查看建站指南
      link: /docs/04.Share/网站建设指南/个人博客网.md
    - theme: alt
      text: 📷 欣赏摄影作品
      link: /docs/02.Photography/摄影作品/中央公园.md
    - theme: alt
      text: 📅 个人待办事项
      link: /docs/about/todolist.md

features:
  - title: 💻 技术栈
    details: Java, SpringBoot, AI, Python... 记录我的 CS 进阶之路。
    link: /docs/01.Coding/
  - title: 📷 摄影与生活
    details: 用镜头捕捉光影,用文字记录思考。
    link: /docs/02.Photography/
  - title: 🔤 英语进阶
    details: New Concept English 学习笔记与打卡。
    link: /docs/03.English/
  - title: 💎 资源与分享
    details: 软件推荐、建站避坑、生活好物分享。
    link: /docs/04.Share/
---

<HomeContent />

<style>
/* 渐变标题样式 */
:root {
  --vp-home-hero-name-color: transparent;
  --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #bd34fe 30%, #41d1ff);
  --vp-home-hero-name-background-clip: text;
  -webkit-background-clip: text;
}

/* 🔥 关键调整:强制消除 Hero 区域底部多余的空白,缩小间距 */
.VPHomeHero {
  padding-bottom: 20px !important; 
}

/* 针对不同屏幕尺寸的微调 */
@media (min-width: 640px) {
  .VPHomeHero {
    padding-bottom: 32px !important;
  }
}
@media (min-width: 960px) {
  .VPHomeHero {
    padding-bottom: 48px !important;
  }
}
</style>

全站归档页 (Archives Page)

现在的目标是:点击 Archive(首页的最近更新栏右上角按钮),展示一个按年份/时间轴排列的全站文章列表(不仅仅是前6篇,而是全部)

实现这个功能只需要三步,全是复用现有的逻辑

1. 功能概述

  • 核心功能:创建一个独立页面(URL 为 /docs/archive),以**“时间轴”**的形式展示博客内所有文章
  • 数据逻辑:复用数据层 (posts.data.mjs),自动读取所有 Markdown 文章的 date (创建时间) 字段
  • 展示形式
    • 按年份分组:自动将文章归类到 2025、2024 等年份下
    • 倒序排列:年份越新越靠前,文章日期越新越靠前
    • 防错机制:自动过滤掉没有配置日期的文章,防止页面报错

2. 部署流程

  1. 创建组件:编写 Vue 组件 Archives.vue,负责数据清洗、分组和渲染
  2. 全局注册:在 VitePress 主题入口注册该组件
  3. 创建页面:新建 archive.md,作为归档组件的容器页
  4. 关联入口:(已在首页完成) 确保首页的 "Archive" 按钮指向该页面

3. 代码详情

第一步:创建归档组件 docs/.vitepress/theme/components/Archives.vue

(这段代码会自动把文章按年份归类,非常优雅)

vue
<script setup>
import { computed } from 'vue'
// 引用现有的数据源
import { data as posts } from '../utils/posts.data.mjs'

const postsByYear = computed(() => {
  const groups = {}
  posts.forEach(post => {
    // 🔥 核心逻辑:只有当文章配置了 date 时才处理
    if (post.date && post.date.time) {
      const date = new Date(post.date.time)
      const year = date.getFullYear()
      
      if (!groups[year]) {
        groups[year] = []
      }
      groups[year].push(post)
    }
  })
  
  // 1. Object.keys 获取所有年份 ["2024", "2025"]
  // 2. sort((a, b) => b - a) 年份倒序排列
  // 3. map 重新组装成数组
  return Object.keys(groups).sort((a, b) => b - a).map(year => ({
    year,
    posts: groups[year]
  }))
})

// 简单的日期格式化 (MM-DD)
const formatDate = (time) => {
  if (!time) return ''
  const date = new Date(time)
  return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
}
</script>

<template>
  <div class="archive-container">
    <div v-for="group in postsByYear" :key="group.year" class="year-section">
      <div class="year-label">{{ group.year }}</div>
      <div class="year-list">
        <a v-for="(post, index) in group.posts" :key="index" :href="post.url" class="post-item">
          <div class="post-date">{{ formatDate(post.date.time) }}</div>
          <div class="post-title">{{ post.title }}</div>
          <div class="post-cat" v-if="post.category">{{ post.category }}</div>
        </a>
      </div>
    </div>
    
    <div v-if="postsByYear.length === 0" class="empty-tip">
      暂无归档内容,请检查文章 Frontmatter 的 date 字段。
    </div>
  </div>
</template>

<style scoped>
.archive-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 40px 20px;
}

.year-section {
  margin-bottom: 40px;
  position: relative;
}

.year-label {
  font-size: 1.5rem;
  font-weight: 700;
  margin-bottom: 20px;
  color: var(--vp-c-brand);
  /* 这是一个很棒的交互细节:年份吸顶 */
  position: sticky;
  top: 80px; 
  background: var(--vp-c-bg); /* 防止文字重叠 */
  padding: 10px 0;
  z-index: 10;
}

.year-list {
  border-left: 2px solid var(--vp-c-divider);
  padding-left: 20px;
  margin-left: 8px;
}

.post-item {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 10px 0;
  text-decoration: none !important;
  color: var(--vp-c-text-1) !important;
  transition: all 0.2s;
}

.post-item:hover {
  transform: translateX(6px);
  color: var(--vp-c-brand) !important;
}

.post-date {
  font-family: monospace;
  color: var(--vp-c-text-3);
  font-size: 0.9rem;
  flex-shrink: 0;
}

.post-title {
  flex: 1;
  font-size: 1rem;
  font-weight: 500;
}

.post-cat {
  font-size: 0.75rem;
  background-color: var(--vp-c-bg-mute);
  padding: 2px 8px;
  border-radius: 4px;
  color: var(--vp-c-text-2);
  white-space: nowrap;
}

.empty-tip {
  text-align: center;
  color: var(--vp-c-text-3);
  padding: 40px;
}

/* 移动端隐藏分类标签,保持整洁 */
@media (max-width: 640px) {
  .post-cat { display: none; }
}
</style>

第二步:注册组件 docs/.vitepress/theme/index.js

JavaScript

javascript
import DefaultTheme from 'vitepress/theme'
import HomeContent from './components/HomeContent.vue'
import HomeTechStack from './components/HomeTechStack.vue'
import Archives from './components/Archives.vue' // 1. 引入

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    app.component('HomeContent', HomeContent)
    app.component('HomeTechStack', HomeTechStack)
    app.component('Archives', Archives) // 2. 注册
  }
}

第三步:创建归档页面 docs/archive.md

文件路径docs/archive.md

---
layout: page
title: 全站归档
sidebar: false
---

<Archives />

现在的效果

  1. 首页红框:依然只显示“最近发布的 6 篇”,保持页面简洁。
  2. 点击 "Archive":会跳转到 /docs/archive 页面。
  3. 归档页内
    • 你会看到类似 20252024 的大标题。
    • 标题下面列出了那一年你写的所有文章,按日期排好了。
    • 右边还贴心地显示了我们之前做好的 01.Coding / Java 这种分类小标签。

首页两侧悬浮挂件开发

我的需求

空间利用:在大屏显示器上,首页两侧留白太多,希望增加内容填充,但在笔记本等小屏上不要遮挡核心内容

左侧内容:需要一个科技新闻列表。起初尝试自动抓取(Hacker News/36Kr),但发现免费 API 极不稳定。最终决定:采用“Tech Radar”方案,优先尝试获取热榜,若失败则自动降级为静态的高价值技术导航(GitHub Trending 等),保证永远不显示空白

右侧内容(已更新) 打造一张 “全息身份卡 (Holo Identity Card)”

  • 核心展示:包含个人头像(带动态光环)、在线状态指示器、ID 信息、以及悬浮的 3D 技术栈图标。
  • 减法策略删除了原定的“纵向胶卷相册”,避免视觉喧宾夺主,保持侧边栏的清爽与聚焦。

视觉风格(已升级) 进化为 “双模态 3D 悬浮” 风格。

  • 黑夜模式:深空灰磨砂玻璃 + 纯白镭射光,呈现赛博朋克科技感。
  • 白天模式:通透的白水晶磨砂 + 电光蓝/极光银光晕,呈现高级通透质感。
  • 交互:支持鼠标跟随的 3D 倾斜 (Tilt) 与流动的反光 (Glare)。

功能概述

  • 响应式布局:通过 CSS 媒体查询,仅在屏幕宽度大于 1680px 或 1800px 时显示,完美适配大屏,避免笔记本屏幕显示不全

    Tech Radar (左):具备容错机制的新闻/导航面板,支持折叠

    Holo Profile (右)(已重构) 纯 CSS/JS 实现的 3D 物理感名片

    • 视差悬浮:头像与图标在 Z 轴上层级分离,转动时有真实的立体纵深感。
    • 高清渲染:强制开启硬件加速与抗锯齿,字体与图标在 3D 变换下依然锐利清晰。
    • 智能光感:根据鼠标位置实时计算光斑坐标,模拟真实材质的反光效果。

部署流程

  1. 创建组件

    docs/.vitepress/theme/components/HomeNews.vue (左侧新闻)

    docs/.vitepress/theme/components/HomeProfile.vue (右侧名片)

    docs/.vitepress/theme/components/HomeSidebars.vue (总容器)

  2. 全局注册:在 theme/index.js 中注册 HomeSidebars

  3. 引入页面:在 docs/index.md 底部添加 <HomeSidebars />

    html
    ---
    layout: home
    
    hero:
      name: "PandaC's Space"
      text: "探索代码与光影的边界"
      tagline: 
      image:
        src: https://pandac-new-begin.oss-cn-chengdu.aliyuncs.com/undefined202512162142393.png
        alt: Cover Image
      # 👇 这里配置 4 个按钮
      actions:
        - theme: brand
          text: 💻 查看建站指南
          link: /docs/04.Share/网站建设指南/个人博客网.md
        - theme: alt
          text: 📷 欣赏摄影作品
          link: /docs/02.Photography/摄影作品/中央公园.md
        - theme: alt
          text: 📅 个人待办事项
          link: /docs/about/todolist.md
    features:
      - title: 💻 技术栈
        details: Java, SpringBoot, AI, Python... 记录我的 CS 进阶之路。
        link: /docs/01.Coding/
      - title: 📷 摄影与生活
        details: 用镜头捕捉光影,用文字记录思考。
        link: /docs/02.Photography/
      - title: 🔤 英语进阶
        details: New Concept English 学习笔记与打卡。
        link: /docs/03.English/
      - title: 💎 资源与分享
        details: 软件推荐、建站避坑、生活好物分享。
        link: /docs/04.Share/
    
    ---
    
    <HomeContent />
    
    <HomeSidebars />
    
    <style> 
        /* 1. 也是原来的渐变字样式 */ 
        :root {   
            --vp-home-hero-name-color: transparent;  
            --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #bd34fe 30%, #41d1ff);   
            --vp-home-hero-name-background-clip: text;   
            -webkit-background-clip: text; 
        }  
        /* 🔥 关键点2:强制消除 Hero 底部多余的空白 */ 
        .VPHomeHero {   
            padding-bottom: 20px !important; /* 原本可能是 96px,改小 */ 
        }  
        /* 🔥 关键点3:如果是移动端,可能需要更紧凑 */ 
        @media (min-width: 640px) {   
            .VPHomeHero {     
                padding-bottom: 32px !important;   
            } 
        } 
        @media (min-width: 960px) {   
            .VPHomeHero {     
                padding-bottom: 48px !important;   
            } 
        } 
    </style>

代码详情

组件一:左侧科技雷达 (HomeNews.vue)

核心逻辑:双保险策略。尝试请求 API,失败则显示静态的高价值技术链接

vue
<script setup>
import { ref, onMounted } from 'vue'

const newsList = ref([])
const loading = ref(true)
const isExpanded = ref(false)

// --- Plan B: 静态兜底数据 (Tech Radar) ---
// 当接口挂了,显示这些“永远不会过时”的高价值入口
const fallbackNews = [
  { title: '📈 GitHub Trending · 今日热榜', url: 'https://github.com/trending' },
  { title: '⚡ 36Kr · 科技前沿资讯', url: 'https://36kr.com/information/web_news' },
  { title: '🧠 V2EX · 程序员集散地', url: 'https://www.v2ex.com/?tab=hot' },
  { title: '🍊 Hacker News · 极客头条', url: 'https://news.ycombinator.com/' },
  { title: '掘 Juejin · 掘金热榜', url: 'https://juejin.cn/hot/articles' },
  { title: 'InfoQ · 架构师必读', url: 'https://www.infoq.cn/' },
  { title: 'Product Hunt · 新奇产品', url: 'https://www.producthunt.com/' },
  { title: 'Papers with Code · AI 论文', url: 'https://paperswithcode.com/' },
]

// --- Plan A: 尝试获取实时 36Kr 热榜 ---
const fetchNews = async () => {
  try {
    // 换一个目前最稳的接口 (韩小韩 API)
    const res = await fetch('https://api.vvhan.com/api/hotlist?type=36Kr')
    
    // 如果网络本身就挂了,直接抛出异常进入 catch
    if (!res.ok) throw new Error('Network response was not ok')
    
    const data = await res.json()
    
    if (data.success && data.data && data.data.length > 0) {
      // 接口成功,取前 8 条
      newsList.value = data.data.slice(0, 8).map(item => ({
        title: item.title,
        url: item.url
      }))
    } else {
      // 接口通了但没数据,使用兜底
      newsList.value = fallbackNews
    }
  } catch (e) {
    // 🤫 悄悄在控制台记录错误,不要弹窗吓用户
    console.warn('News API failed, switching to static mode.', e)
    // 🔥 关键:发生任何错误,立即切换到静态兜底数据
    newsList.value = fallbackNews
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  // 先初始化为兜底数据,防止加载时短暂空白(可选)
  // newsList.value = fallbackNews 
  fetchNews()
})
</script>

<template>
  <div class="news-dock glass-effect" :class="{ collapsed: !isExpanded }">
    <div class="dock-header" @click="isExpanded = !isExpanded">
      <span class="dock-title">📡 Tech Radar</span>
      <span class="dock-toggle">{{ isExpanded ? '−' : '+' }}</span>
    </div>
    
    <div class="dock-content" v-if="isExpanded">
      <div v-if="loading" class="loading">正在扫描信号...</div>
      <ul v-else class="news-list">
        <li v-for="(item, index) in newsList" :key="index" class="news-item">
          <a :href="item.url" target="_blank" rel="noopener noreferrer">
            <span class="news-index">{{ index + 1 }}</span>
            <span class="news-text">{{ item.title }}</span>
          </a>
        </li>
      </ul>
    </div>
  </div>
</template>

<style scoped>
/* --- 磨砂玻璃风格 (保持你喜欢的样式) --- */
.glass-effect {
  background: rgba(255, 255, 255, 0.03); 
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
}

.news-dock {
  width: 260px;
  border-radius: 16px;
  overflow: hidden;
  transition: all 0.3s;
  color: var(--vp-c-text-1);
}

.dock-header {
  padding: 14px 18px;
  background: rgba(0, 0, 0, 0.2); 
  display: flex;
  justify-content: space-between;
  align-items: center;
  cursor: pointer;
  user-select: none;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

.dock-title {
  font-size: 0.95rem;
  font-weight: 700;
  color: #36a3f7;
  display: flex;
  align-items: center;
  gap: 6px;
}

.dock-content {
  padding: 0;
  max-height: 420px;
  overflow-y: auto;
}

.news-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.news-item {
  border-bottom: 1px dashed rgba(255, 255, 255, 0.1);
}

.news-item a {
  display: flex;
  gap: 10px;
  padding: 12px 16px;
  text-decoration: none;
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--vp-c-text-2);
  transition: all 0.2s;
  align-items: flex-start; /* 防止多行文字时图标对不齐 */
}

.news-item a:hover {
  background: rgba(255, 255, 255, 0.05);
  color: var(--vp-c-brand);
}

.news-index {
  font-family: monospace;
  font-weight: bold;
  opacity: 0.5;
  min-width: 14px;
  margin-top: 2px; /* 微调对齐 */
}

.loading {
  padding: 30px;
  text-align: center;
  color: var(--vp-c-text-3);
  font-size: 0.8rem;
}

/* 隐藏滚动条但保留功能 */
.dock-content::-webkit-scrollbar { width: 0px; }
</style>

右侧个人名片 (HomeProfile.vue)

核心逻辑:展示头像、身份、技术栈徽章(Java/Vue/Python/Photo)

vue
<script setup>
import { ref, computed } from 'vue'
import { useData } from 'vitepress'

const { isDark } = useData()

// --- 🎮 3D 悬浮卡片逻辑 ---
const cardRef = ref(null)
const rotateX = ref(0)
const rotateY = ref(0)
const glareOpacity = ref(0)
const glarePos = ref({ x: 50, y: 50 })
const isHovering = ref(false)

const handleMouseMove = (e) => {
  if (!cardRef.value) return
  isHovering.value = true
  
  const rect = cardRef.value.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  
  const centerX = rect.width / 2
  const centerY = rect.height / 2
  
  // 角度计算
  const rotateXValue = ((y - centerY) / centerY) * -12 
  const rotateYValue = ((x - centerX) / centerX) * 12

  rotateX.value = rotateXValue
  rotateY.value = rotateYValue
  
  glarePos.value = {
    x: (x / rect.width) * 100,
    y: (y / rect.height) * 100
  }
  glareOpacity.value = 1
}

const handleMouseLeave = () => {
  isHovering.value = false
  rotateX.value = 0
  rotateY.value = 0
  glareOpacity.value = 0
}

const cardStyle = computed(() => {
  return {
    transform: `perspective(1000px) rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg) scale3d(${isHovering.value ? 1.02 : 1}, ${isHovering.value ? 1.02 : 1}, 1)`,
  }
})

// ✨✨✨ 光晕核心优化 ✨✨✨
const glareStyle = computed(() => {
  const color = isDark.value ? '255, 255, 255' : '60, 140, 255'
  const opacityMax = isDark.value ? 0.4 : 0.75
  
  return {
    background: `radial-gradient(circle at ${glarePos.value.x}% ${glarePos.value.y}%, rgba(${color},${opacityMax}) 0%, rgba(${color},0) 80%)`,
    opacity: glareOpacity.value,
    mixBlendMode: isDark.value ? 'overlay' : 'hard-light' 
  }
})
</script>

<template>
  <div class="profile-dock">
    
    <div 
      ref="cardRef"
      class="holo-card"
      :style="cardStyle"
      @mousemove="handleMouseMove"
      @mouseleave="handleMouseLeave"
    >
      <div class="holo-glare" :style="glareStyle"></div>
      
      <div class="corner top-left"></div>
      <div class="corner top-right"></div>
      <div class="corner bottom-left"></div>
      <div class="corner bottom-right"></div>

      <div class="card-content">
        <div class="avatar-group">
          <div class="avatar-ring"></div>
          <img src="https://pandac-new-begin.oss-cn-chengdu.aliyuncs.com/undefined202512241547050.jpg" alt="Avatar" class="avatar" />
          <div class="status-indicator">
            <span class="pulse-dot"></span>
            ONLINE
          </div>
        </div>

        <h3 class="name">PandaC</h3>
        <p class="role">LEVEL 100 • DEVELOPER</p>
        <p class="bio">CS Student / 摄影爱好者 / 终身学习者</p>

        <div class="tech-stack-3d">
          <span class="tech-icon java" title="Java">☕</span>
          <span class="tech-icon vue" title="Vue">🟩</span>
          <span class="tech-icon python" title="Python">🐍</span>
          <span class="tech-icon photo" title="Photography">📸</span>
        </div>
      </div>
    </div>

  </div>
</template>

<style scoped>
/* 变量系统 - 默认白天模式 */
.profile-dock {
  --hp-bg: rgba(255, 255, 255, 0.75);
  --hp-border: rgba(0, 0, 0, 0.08); 
  --hp-shadow: 0 15px 35px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(255, 255, 255, 0.8);
  
  --hp-text-main: #1a1a1a; 
  --hp-text-sub: rgba(60, 60, 67, 0.8); 
  --hp-text-shadow: none; 
  
  --hp-icon-color: #333; 
  --hp-icon-bg: rgba(255,255,255,0.6);
  --hp-icon-border: rgba(0,0,0,0.08);
  
  --hp-corner: rgba(0,0,0,0.2); 
  --hp-avatar-border: #fff;
  --hp-avatar-ring: rgba(0,0,0,0.1);
  
  --hp-status-bg: #fff;
  --hp-status-text: #10b981;

  width: 260px;
  perspective: 1000px;
  margin: 0 auto;
}

/* 🌙 关键修复:使用 :root.dark 选择器替代 Vue 的 class 绑定 */
/* 这样即使 JS 还没水合,或者在 Reader Mode 下,只要 HTML 标签有 dark 类,样式就正确 */
:root.dark .profile-dock {
  --hp-bg: rgba(20, 20, 24, 0.8);
  --hp-border: rgba(255, 255, 255, 0.1);
  --hp-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
  
  --hp-text-main: #fff;
  --hp-text-sub: rgba(255, 255, 255, 0.75);
  --hp-text-shadow: 0 5px 15px rgba(0,0,0,0.5);
  
  --hp-icon-color: #fff;
  --hp-icon-bg: rgba(255,255,255,0.05);
  --hp-icon-border: rgba(255,255,255,0.1);
  
  --hp-corner: rgba(255,255,255,0.4);
  --hp-avatar-border: rgba(255, 255, 255, 0.9);
  --hp-avatar-ring: rgba(255,255,255,0.15);
  
  --hp-status-bg: #000;
  --hp-status-text: #00ff88;
}

/* --- 卡片核心 --- */
.holo-card {
  position: relative;
  width: 100%;
  padding: 35px 20px;
  border-radius: 16px;
  
  background: var(--hp-bg); 
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  
  border: 1px solid var(--hp-border);
  box-shadow: var(--hp-shadow);
  
  will-change: transform;
  backface-visibility: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  transform-style: preserve-3d;
  
  transition: transform 0.1s cubic-bezier(0.2, 0.8, 0.2, 1), background 0.3s;
  overflow: hidden;
  user-select: none;
  cursor: default;
}

.holo-glare {
  position: absolute; top: 0; left: 0; right: 0; bottom: 0;
  z-index: 2; pointer-events: none;
  transition: opacity 0.3s;
}

/* 内容层 */
.card-content {
  position: relative; z-index: 10;
  display: flex; flex-direction: column; align-items: center;
  transform: translateZ(20px); 
  backface-visibility: hidden;
}

/* 头像 */
.avatar-group {
  position: relative; margin-bottom: 20px; transform-style: preserve-3d;
}
.avatar {
  width: 90px; height: 90px; border-radius: 50%;
  border: 3px solid var(--hp-avatar-border);
  box-shadow: 0 10px 25px rgba(0,0,0,0.15);
  display: block;
  image-rendering: -webkit-optimize-contrast;
}
.avatar-ring {
  position: absolute; top: -6px; left: -6px; right: -6px; bottom: -6px;
  border-radius: 50%;
  border: 2px dashed var(--hp-avatar-ring);
  animation: spin 10s linear infinite; pointer-events: none;
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

.status-indicator {
  position: absolute; bottom: 0; right: -15px;
  background: var(--hp-status-bg);
  color: var(--hp-status-text);
  font-size: 0.6rem; font-family: monospace; font-weight: bold;
  padding: 4px 8px; border-radius: 12px;
  border: 1px solid var(--hp-status-text);
  display: flex; align-items: center; gap: 5px;
  transform: translateZ(10px);
  box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.pulse-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--hp-status-text);
  box-shadow: 0 0 5px var(--hp-status-text);
  animation: pulse 1.5s infinite;
}
@keyframes pulse { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } }

/* 文本 */
.name {
  font-size: 1.6rem; font-weight: 800; margin: 5px 0;
  color: var(--hp-text-main);
  letter-spacing: 0.5px;
  text-shadow: var(--hp-text-shadow);
}
.role {
  font-family: monospace; font-size: 0.7rem;
  color: var(--vp-c-brand); margin-bottom: 15px; letter-spacing: 2px;
  background: rgba(var(--vp-c-brand-rgb), 0.1);
  padding: 4px 10px; border-radius: 4px;
  border: 1px solid rgba(var(--vp-c-brand-rgb), 0.2);
}
.bio {
  font-size: 0.85rem;
  color: var(--hp-text-sub);
  text-align: center; margin-bottom: 25px; line-height: 1.5; font-weight: 500;
}

/* 图标 */
.tech-stack-3d { display: flex; gap: 15px; transform: translateZ(15px); }
.tech-icon {
  font-size: 1.3rem; width: 42px; height: 42px;
  display: flex; align-items: center; justify-content: center;
  background: var(--hp-icon-bg);
  border: 1px solid var(--hp-icon-border);
  border-radius: 12px; transition: all 0.3s; cursor: help;
  color: var(--hp-icon-color);
  box-shadow: 0 4px 10px rgba(0,0,0,0.05);
}
.tech-icon:hover {
  background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand);
  transform: translateY(-5px) scale(1.1);
  box-shadow: 0 8px 20px rgba(var(--vp-c-brand-rgb), 0.3);
}

/* 装饰 */
.corner {
  position: absolute; width: 12px; height: 12px;
  border: 2px solid var(--hp-corner); z-index: 5; transition: all 0.3s;
}
.holo-card:hover .corner {
  border-color: var(--vp-c-brand);
  width: 15px; height: 15px;
}
.top-left { top: 12px; left: 12px; border-right: 0; border-bottom: 0; }
.top-right { top: 12px; right: 12px; border-left: 0; border-bottom: 0; }
.bottom-left { bottom: 12px; left: 12px; border-right: 0; border-top: 0; }
.bottom-right { bottom: 12px; right: 12px; border-left: 0; border-top: 0; }
</style>

组件三:布局总容器 (HomeSidebars.vue)

核心逻辑:精准计算位置,且只在超大屏(大于 1680px 或 1800px)显示,解决笔记本屏幕显示不全的问题

vue
<script setup>
import HomeNews from './HomeNews.vue'
import HomeProfile from './HomeProfile.vue'
</script>

<template>
  <div class="sidebars-wrapper">
    <div class="sidebar-left">
      <HomeNews />
    </div>

    <div class="sidebar-right">
      <HomeProfile />
    </div>
  </div>
</template>

<style scoped>
/* 默认隐藏 (手机/平板/笔记本统统不显示) */
.sidebars-wrapper {
  display: none; 
}

/* 🔥 关键修改:把断点从 1440px 提高到 1680px 或 1800px */
/* 只有真正的大宽屏才显示侧边栏,防止在笔记本上被截断 */
@media (min-width: 1680px) {
  .sidebars-wrapper {
    display: block;
    pointer-events: none;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100vh;
    z-index: 20;
  }

  /* 左侧栏位置计算 */
  .sidebar-left {
    position: absolute;
    /* (屏幕宽度 - 中间内容宽度 1370px) / 2 = 每一边的留白 */
    /* 我们把侧边栏放在这个留白里,再往左偏一点 */
    /* 这里的计算逻辑是:从中线(50%)向左偏移 (中间内容的一半 + 侧边栏宽度 + 间距) */
    /* 1370/2 = 685。  685 + 280(侧边栏宽) = 965 */
    left: calc(50% - 980px); 
    top: 100px;
    pointer-events: auto;
  }

  /* 右侧栏位置计算 */
  .sidebar-right {
    position: absolute;
    right: calc(50% - 980px);
    top: 100px;
    pointer-events: auto;
  }
}

/* 如果屏幕超级宽,可以固定在屏幕边缘,而不是跟随中心内容 */
@media (min-width: 1800px) {
  .sidebar-left {
    left: 40px;
  }
  .sidebar-right {
    right: 40px;
  }
}
</style>

⚠️首页 3D 土星背景组件(弃用)

我的需求

视觉目标:在博客首页(Home Layout)的 Hero 区域右侧,悬浮一个 3D 土星粒子模型

交互要求:土星具备自转动画

性能要求(关键)

  • PC 端:正常加载并渲染,增强视觉冲击力
  • 移动端完全不加载。为了保证手机浏览的流畅度,移动端连 Three.js 引擎都不初始化,实现零性能损耗

(弃用原因:性能不足、加载卡顿、刷新闪烁;弃用流程:删除Satrun3D.vue组件,修改Layout.vue组件内容,清理index.js注册内容,删除three.js项目依赖)

部署流程

  1. 环境准备:安装 Three.js 库 (npm install three)
  2. 组件封装:创建 Saturn3D.vue,在 onMounted 中初始化 WebGL 渲染器,绘制土星本体、光环及星空背景,并实现动画循环
  3. 布局集成:在 Layout.vue 中引入该组件,并通过 Vue 的插槽 (#home-hero-before) 将其放置在首页 Hero 区域
  4. 性能优化(最终方案):在 Layout.vue 中增加 JS 判断,仅当屏幕宽度大于 960px 时才将 showSaturn 设为 true,否则组件压根不挂载

代码详情

(1) Saturn3D.vue (Three.js 核心组件)

vue
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue';
import * as THREE from 'three';

const containerRef = ref(null);
let scene, camera, renderer, particles;
let animationId;

// --- Shader 代码 (保持完美状态) ---
const vertexShader = `
  attribute float size; attribute vec3 customColor; attribute float opacityAttr;
  attribute float orbitSpeed; attribute float isRing; attribute float aRandomId;
  varying vec3 vColor; varying float vDist; varying float vOpacity;
  varying float vScaleFactor; varying float vIsRing;
  uniform float uTime; uniform float uScale; uniform float uRotationX;
  mat2 rotate2d(float _angle){ return mat2(cos(_angle),-sin(_angle), sin(_angle),cos(_angle)); }
  void main() {
    float normScaleLOD = clamp((uScale - 0.15) / 2.35, 0.0, 1.0);
    float visibilityThreshold = 0.9 + pow(normScaleLOD, 1.2) * 0.1; 
    if (aRandomId > visibilityThreshold) { gl_Position = vec4(0.0); gl_PointSize = 0.0; return; }
    vec3 pos = position;
    if (isRing > 0.5) {
        vec2 rotatedXZ = rotate2d(uTime * orbitSpeed * 0.05) * pos.xz;
        pos.x = rotatedXZ.x; pos.z = rotatedXZ.y;
    } else {
        vec2 rotatedXZ = rotate2d(uTime * 0.01) * pos.xz;
        pos.x = rotatedXZ.x; pos.z = rotatedXZ.y;
    }
    float cx = cos(uRotationX); float sx = sin(uRotationX);
    float ry = pos.y * cx - pos.z * sx; float rz = pos.y * sx + pos.z * cx;
    pos.y = ry; pos.z = rz;
    
    vec4 mvPosition = modelViewMatrix * vec4(pos * uScale, 1.0);
    vDist = -mvPosition.z; 
    gl_Position = projectionMatrix * mvPosition;
    
    // 还原点大小计算,保持柔和
    float pointSize = size * (380.0 / vDist) * 0.6; 
    if (isRing < 0.5 && vDist < 50.0) pointSize *= 0.8;
    gl_PointSize = clamp(pointSize, 0.0, 350.0);
    vColor = customColor; vOpacity = opacityAttr; vScaleFactor = uScale; vIsRing = isRing;
  }
`;

const fragmentShader = `
  varying vec3 vColor; varying float vDist; varying float vOpacity;
  varying float vScaleFactor; varying float vIsRing;
  void main() {
    vec2 cxy = 2.0 * gl_PointCoord - 1.0;
    if (dot(cxy, cxy) > 1.0) discard;
    float t = clamp((vScaleFactor - 0.15) / 2.35, 0.0, 1.0);
    vec3 finalColor = mix(vec3(0.4, 0.25, 0.05), vColor, smoothstep(0.1, 0.9, t)) * (0.15 + 0.6 * t);
    
    if (vDist < 160.0) {
        float closeMix = 1.0 - (vDist / 160.0);
        if (vIsRing < 0.5) {
            vec3 deepTexture = pow(vColor, vec3(1.4)) * 0.5; 
            finalColor = mix(finalColor, deepTexture, closeMix * 0.9);
        } else {
            finalColor += vec3(0.2, 0.15, 0.1) * closeMix * 0.5;
        }
    }
    gl_FragColor = vec4(finalColor, smoothstep(1.0, 0.4, dot(cxy, cxy)) * vOpacity * (0.3 + 0.45 * smoothstep(0.0, 0.5, t)));
  }
`;

const initThree = () => {
  if (!containerRef.value) return;
  const w = containerRef.value.clientWidth;
  const h = containerRef.value.clientHeight;
  
  scene = new THREE.Scene();
  camera = new THREE.PerspectiveCamera(50, w / h, 1, 10000);
  
  // 【核心还原】固定为 130,不再根据手机调整
  // 因为我们会用 CSS 强行把画布撑大到 550px,所以这里要配合大画布的参数
  camera.position.z = 130; 
  camera.lookAt(0, 0, 0);

  renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
  renderer.setSize(w, h);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setClearColor(0x000000, 0); 
  containerRef.value.appendChild(renderer.domElement);

  const pCount = 550000; 
  const geo = new THREE.BufferGeometry();
  const pos=[], cols=[], sizes=[], opacities=[], speeds=[], isRings=[], rands=[];
  const bodyCols = [new THREE.Color('#F0E6D0'), new THREE.Color('#DAB080'), new THREE.Color('#C09D65')];
  const R = 18;

  for(let i=0; i<pCount; i++) {
    let x,y,z,r,g,b,s,o,sp,isR;
    rands.push(Math.random());
    if(i < pCount * 0.3) {
        isR=0; sp=0;
        const theta = Math.random()*Math.PI*2, phi = Math.acos(2*Math.random()-1);
        x = R * Math.sin(phi) * Math.cos(theta); z = R * Math.sin(phi) * Math.sin(theta);
        y = R * Math.cos(phi) * 0.9;
        let c = bodyCols[Math.floor(Math.random()*3)];
        r=c.r; g=c.g; b=c.b; s=1.5; o=0.8;
    } else {
        isR=1;
        const rad = R * (1.2 + Math.random() * 1.2);
        const theta = Math.random() * Math.PI * 2;
        x = rad * Math.cos(theta); z = rad * Math.sin(theta);
        y = (Math.random()-0.5) * 0.25;
        let c = (rad > R*1.9 && rad < R*2.0) ? new THREE.Color(0,0,0) : new THREE.Color('#E6D5C4');
        r=c.r; g=c.g; b=c.b; s=0.8; o=(c.r===0)?0.1:0.7;
        sp = 8.0 / Math.sqrt(rad);
    }
    pos.push(x,y,z); cols.push(r,g,b); sizes.push(s); opacities.push(o); speeds.push(sp); isRings.push(isR);
  }
  geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  geo.setAttribute('customColor', new THREE.Float32BufferAttribute(cols, 3));
  geo.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
  geo.setAttribute('opacityAttr', new THREE.Float32BufferAttribute(opacities, 1));
  geo.setAttribute('orbitSpeed', new THREE.Float32BufferAttribute(speeds, 1));
  geo.setAttribute('isRing', new THREE.Float32BufferAttribute(isRings, 1));
  geo.setAttribute('aRandomId', new THREE.Float32BufferAttribute(rands, 1));

  const mat = new THREE.ShaderMaterial({
    uniforms: { uTime: {value:0}, uScale: {value:1}, uRotationX: {value:0.4} },
    vertexShader, fragmentShader,
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending
  });
  particles = new THREE.Points(geo, mat);
  particles.rotation.z = 26.73 * (Math.PI / 180);
  scene.add(particles);
};

const clock = new THREE.Clock();
const animate = () => {
  animationId = requestAnimationFrame(animate);
  const t = clock.getElapsedTime();
  if(particles) {
    particles.material.uniforms.uTime.value = t;
    const breathScale = 1.0 + Math.sin(t * 0.8) * 0.08;
    particles.material.uniforms.uScale.value = breathScale;
    const baseTilt = 0.4; 
    const breathTilt = baseTilt + Math.sin(t * 0.6) * 0.03;
    particles.material.uniforms.uRotationX.value = breathTilt;
  }
  renderer.render(scene, camera);
};

const onResize = () => {
    if(!containerRef.value || !camera || !renderer) return;
    const w = containerRef.value.clientWidth;
    const h = containerRef.value.clientHeight;
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    renderer.setSize(w, h);
}

onMounted(() => {
    initThree();
    animate();
    window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
    window.removeEventListener('resize', onResize);
    cancelAnimationFrame(animationId);
    if(renderer) { renderer.dispose(); containerRef.value.removeChild(renderer.domElement); }
    if(scene) scene.clear();
});
</script>

<template>
  <div class="saturn-wrapper" ref="containerRef"></div>
</template>

<style scoped>
.saturn-wrapper {
  position: relative; width: 100%; height: 100%; overflow: hidden; pointer-events: none; 
}
</style>

(2) Layout.vue (集成与性能控制)

核心逻辑:通过 onMounted 检测窗口宽度,小于 960px 时 showSaturnfalse,彻底阻断组件加载。

vue
<script setup>
import DefaultTheme from 'vitepress/theme'
import { useData } from 'vitepress'
import { ref, onMounted } from 'vue' // 1. 引入 ref 和 onMounted
import MagicFooter from './components/MagicFooter.vue'
import HomePixelSnow from './components/HomePixelSnow.vue'
import Saturn3D from './components/Saturn3D.vue'

const { Layout } = DefaultTheme
const { frontmatter } = useData()

// 2. 定义一个开关,默认关闭
const showSaturn = ref(false)

// 3. 只有在组件挂载后(客户端),且屏幕宽度大于 960px(电脑平板)时,才开启土星
// 这样手机端连 Three.js 的初始化过程都不会跑,性能零负担
onMounted(() => {
  if (window.innerWidth >= 960) {
    showSaturn.value = true
  }
})
</script>

<template>
  <Layout>
    <template #layout-top>
      <HomePixelSnow />
    </template>

    <template #home-hero-before>
      <ClientOnly>
        <div v-if="frontmatter.layout === 'home' && showSaturn" class="saturn-box-container">
          <Saturn3D />
        </div>
      </ClientOnly>
    </template>

    <template #layout-bottom>
      <MagicFooter />
    </template>
  </Layout>
</template>

<style>
/* =========================================================
   1. 电脑端样式 (Min-Width: 960px)
   只保留这一部分,保持你的悬浮效果
   ========================================================= */
@media (min-width: 960px) {
  .saturn-box-container {
    position: absolute;
    top: -40px; 
    right: 0;
    width: 67%; 
    height: 500px;
    z-index: 0; 
    pointer-events: auto;
  }
}

/* =========================================================
   2. 移动端样式 
   直接删除了!既然不显示,就不需要写那些复杂的定位和负边距代码了。
   ========================================================= */

/* =========================================================
   3. 通用修正
   ========================================================= */
.VPHomeHero .text {
  position: relative;
  z-index: 10;
}
.VPHomeHero .container {
  position: relative;
  z-index: 10;
}
</style>

图片查看器升级 (Viewer.js)

需求分析

核心痛点:原 medium-zoom 功能过于简陋,仅支持点击放大,不支持缩放细节、拖拽移动或旋转

功能目标

  • 实现类似专业摄影网站的图片查看体验
  • 支持鼠标滚轮/手势缩放自由拖拽图片旋转
  • 支持画廊模式(底部显示缩略图,可切换上一张/下一张)
  • UI 定制:右上角必须有明显的关闭按钮,底部显示图片像素尺寸(去除文件名)

Bug 修复目标

  • 解决打开图片时右侧滚动条消失导致的页面抖动 (Layout Shift)
  • 解决关闭按钮图标在不同缩放比下消失或变歪的问题
  • 解决 Z-Index 层级问题,确保图片查看器永远在最顶层

涉及文件

文件路径说明修改内容
docs/.vitepress/theme/index.js核心逻辑初始化 Viewer.js,配置工具栏、标题回调、Z-Index
docs/.vitepress/theme/style.css样式美化强制重绘关闭按钮(SVG)、修复滚动条抖动、定制磨砂背景
package.json依赖管理卸载 medium-zoom,安装 viewerjs

搭建流程

第一步:依赖更替

在终端执行以下命令,清理旧库并安装新库:

bash
npm uninstall medium-zoom
npm install viewerjs

第二步:核心逻辑配置 (index.js)

文件路径docs/.vitepress/theme/index.js

javascript
// .vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import { onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vitepress'

// 1. 引入 Viewer.js 及其样式
import Viewer from 'viewerjs'
import 'viewerjs/dist/viewer.css'

import MyPlayer from './components/MyPlayer.vue' 
import TodoList from './components/TodoList.vue' 
import MyLayout from './Layout.vue' 
import './style.css'
import HomeContent from './components/HomeContent.vue' 
import Archives from './components/Archives.vue' 
import HomeSidebars from './components/HomeSidebars.vue' 

export default {
  ...DefaultTheme,
  Layout: MyLayout,
  setup() {
    const route = useRoute()
    
    // 定义 viewer 实例变量
    let viewer = null

    const initImageViewer = () => {
      // 2. 销毁旧实例(防止路由切换后内存泄漏或重复绑定)
      if (viewer) {
        viewer.destroy()
      }

      // 3. 找到文章内容区域(只对文章里的图片生效,忽略头像等)
      const container = document.querySelector('.vp-doc')
      
      // 如果当前页面没有文章内容(比如首页),直接返回
      if (!container) return

      // 4. 初始化 Viewer.js
      viewer = new Viewer(container, {
        // --- 核心交互配置 ---
        button: true,      // 右上角显示关闭按钮
        navbar: true,      // 底部显示缩略图导航(画廊模式),如果你不喜欢可以设为 false
        // image 是图片 DOM 元素,imageData 包含图片的宽高等信息
        title: (image, imageData) => {
           // 只返回 "宽 x 高",去掉前面的文件名
          return `${imageData.naturalWidth} × ${imageData.naturalHeight}`;
        },
        toolbar: {         // 自定义底部工具栏
          zoomIn: 1,
          zoomOut: 1,
          oneToOne: 1,
          reset: 1,
          prev: 1,
          play: 0,         // 不显示幻灯片播放按钮
          next: 1,
          rotateLeft: 1,   // 允许旋转(对摄影作品很有用)
          rotateRight: 1,
          flipHorizontal: 0,
          flipVertical: 0,
        },
        tooltip: true,     // 显示缩放百分比
        movable: true,     // 允许拖拽图片
        zoomable: true,    // 允许缩放
        rotatable: true,   // 允许旋转
        scalable: true,    // 允许翻转
        
        // --- 样式微调 ---
        className: 'v-viewer-theme', // 给个类名方便 CSS 修改
        
        // 5. 解决 Z-Index 问题:确保它在最顶层
        zIndex: 9999999,
        
        // 点击背景关闭 (这也是你想要的)
        backdrop: true, 
      })
    }

    onMounted(() => {
      initImageViewer()
    })

    watch(
      () => route.path,
      () => nextTick(() => initImageViewer())
    )
  },
  enhanceApp({ app }) {
    app.component('MyPlayer', MyPlayer)
    app.component('TodoList', TodoList)
    app.component('HomeContent', HomeContent)
    app.component('Archives', Archives)
    app.component('HomeSidebars', HomeSidebars)
  }
}

第三步:样式定制与 Bug 修复 (style.css)

文件路径docs/.vitepress/theme/style.css

css
/* =======================================================
   Viewer.js 图片查看器深度定制
   ======================================================= */

/* 1. 背景遮罩:深色磨砂质感 */
.viewer-backdrop {
  background-color: rgba(0, 0, 0, 0.85) !important;
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
}

/* 2. 底部工具栏与导航条:半透明黑底 */
.viewer-footer {
  background-color: transparent;
}
.viewer-navbar {
  background-color: rgba(0, 0, 0, 0.5);
  border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.viewer-toolbar > ul > li {
  background-color: rgba(0, 0, 0, 0.5);
  border-radius: 50%;
  margin: 0 2px;
}
.viewer-toolbar > ul > li:hover {
  background-color: var(--vp-c-brand);
}

/* 3. 标题文字优化 */
.viewer-title {
  color: #ccc;
  font-family: monospace;
  font-size: 0.9rem;
  opacity: 0.8;
}

/* =======================================================
   🔧 核心修复:手绘 SVG 关闭按钮
   如果不加这段,按钮图标可能会变歪或消失
   ======================================================= */
.viewer-button {
  background-color: rgba(0, 0, 0, 0.5);
  border-radius: 50%;
  width: 60px;       
  height: 60px;      
  top: 30px;         
  right: 30px;
  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
  background-image: none !important; /* 清除默认图标 */
  z-index: 9999999 !important;
}

/* 绘制 "X" */
.viewer-button::before,
.viewer-button::after {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 24px; height: 3px;
  background-color: #fff;
  border-radius: 2px;
}
.viewer-button::before { transform: translate(-50%, -50%) rotate(45deg); }
.viewer-button::after { transform: translate(-50%, -50%) rotate(-45deg); }

/* 悬停动效 */
.viewer-button:hover {
  background-color: var(--vp-c-brand);
  transform: scale(1.1) rotate(90deg); /* 旋转特效 */
  box-shadow: 0 0 15px rgba(0,0,0,0.6);
}

/* =======================================================
   🔧 核心修复:页面防抖动 (Scrollbar Fix)
   防止打开图片时滚动条消失导致页面内容偏移
   ======================================================= */
body.viewer-open {
  overflow: auto !important;    /* 强制保留滚动条 */
  padding-right: 0 !important;  /* 禁止 Viewer.js 添加右侧填充 */
}

/* 现代浏览器优化:预留滚动条空间 */
html {
  scrollbar-gutter: stable;
}

右侧大纲自动编号

需求分析

  • 痛点:默认的右侧大纲(Outline)只有纯文字标题,当文章层级较深(H2 > H3 > H4)时,很难一眼看清结构关系

  • 目标:为右侧大纲添加自动递增的序号(如 1, 1.1, 1.1.1),增强文档的可读性和条理性

  • 技术选型:使用纯 CSS 计数器 (CSS Counters) 实现

    优势:无需修改 HTML 结构,无需编写 JS 逻辑,性能开销几乎为零,且完美支持 VitePress 的动态路由

涉及文件

文件路径说明修改内容
docs/.vitepress/theme/style.css全局样式表新增 CSS 计数器逻辑,自定义序号样式

实现代码

操作:将以下代码添加到 style.css 文件的末尾

CSS

css
/* =======================================================
   📋 右侧大纲自动编号 (Outline Numbering)
   实现效果:1. Introduction -> 1.1. Setup -> 1.1.1. Install
   ======================================================= */

/* 1. 初始化计数器 */
/* 每一个 ul 容器重置一次计数器,实现层级嵌套 */
.VPDocAsideOutline ul {
  list-style: none;     /* 移除默认列表样式 */
  counter-reset: list;  /* 定义计数器变量名为 'list' */
}

/* 2. 递增逻辑 */
/* 每一个 li 元素让计数器 +1 */
.VPDocAsideOutline li {
  counter-increment: list;
}

/* 3. 内容注入 */
/* 在链接文本前插入计算好的序号 */
.VPDocAsideOutline li > a::before {
  /* 核心函数:counters(变量名, 分隔符, 结尾字符) */
  content: counters(list, ".") " "; 
  
  /* --- 序号样式美化 --- */
  color: var(--vp-c-brand); /*跟随主题色,保持风格统一 */
  font-family: monospace;   /* 等宽字体,数字对齐更整齐 */
  font-weight: 600;         /* 适当加粗 */
  font-size: 0.85em;        /* 稍微缩小,形成层级感 */
  margin-right: 6px;        /* 序号与文字的间距 */
  opacity: 0.9;
}

效果预览

修改后,右侧 On this page (页面导航) 下的目录将变为:(例子)

  • 1. 简介
  • 2. 快速开始
    • 2.1. 环境准备
    • 2.2. 安装步骤
      • 2.2.1. NPM 安装
      • 2.2.2. Yarn 安装
  • 3. 常见问题

英语记忆卡片

需求分析

我在 Typora 写了大量的《新概念英语》翻译和纠错笔记。每次遇到生词,我都要面临一个两难选择:

  1. 记在 Markdown 里:写起来快,但写完就“沉底”了,很少会专门翻出来背,缺乏复习机制
  2. 录入背单词 App:功能强,但太累了!我刚在笔记里打了一遍,又要去 App 里再输一遍。而且,App 里的单词往往脱离了语境,我只记得 Epiphany 是“顿悟”,却忘了当时是在哪篇课文中看到它的

既然我的博客是基于 VitePress 构建的,而 VitePress 在打包时会扫描所有 Markdown 文件。那我为什么不写个脚本,让它在打包时顺便把我的笔记“读”一遍,把里面的生词和句子抓出来,自动生成一个复习题库呢?

我的核心需求:

  • 零冗余:绝不维护两份数据。笔记写完,题库即更新
  • 就地取材:不搞复杂的数据库,Markdown 原文就是数据库
  • 场景回溯:背单词时忘了怎么用?点一下卡片,直接跳转回那篇笔记的原文位置
  • 格式宽容:无论我习惯用 - 还是 * 做列表,程序都得认

实现流程

制定数据规范:

为了让程序能精准抓取内容,我们需要约定简单的 Markdown 书写格式

书写规则:

  1. 触发标题:任意层级的标题包含 单词卡片句子卡片(如 ## 单词卡片#### 句子卡片
  2. 列表格式:支持 -* 开头的无序列表
  3. 内容分隔:使用 :: 分隔问题和答案

✅ 示例 1:课文笔记 (Lesson.md)

## Lesson 53 总结
... (其他笔记内容) ...

#### 单词卡片
* Spring/Summer/Autumn/Winter :: 春夏秋冬
* Climate :: 气候

#### 句子卡片
* Where do you come from? :: 你是哪国人?

✅ 示例 2:日常积累 (vocabulary.md)

## 2025-12-28 技术文档生词
### 单词卡片
- **Reactivity** :: 响应式
- **Hydration** :: 水合

编写数据提取脚本:

文件路径docs/.vitepress/theme/utils/english.data.mjs

javascript
import { createContentLoader } from 'vitepress'

export default createContentLoader('docs/03.English/**/*.md', {
  includeSrc: true,
  transform(rawData) {
    const vocabList = []
    const sentenceList = []

    rawData.forEach((page) => {
      const content = page.src
      if (!content) return

      // --- 1. 单词卡片提取 ---
      const vocabBlocks = content.matchAll(/#{2,6}\s*.*单词卡片.*\n([\s\S]*?)(?=(#{2,6}\s)|$)/g);
      for (const block of vocabBlocks) {
        const lines = block[1].split('\n');
        lines.forEach(line => {
          // 🔥 核心修改:将 ^\s*-\s* 修改为 ^\s*[-*]\s*
          // 含义:匹配行首可能是 - 也可能是 *
          const match = line.match(/^\s*[-*]\s*(.*?)\s*::\s*(.*)$/);
          
          if (match) {
            vocabList.push({
              q: match[1].replace(/\*\*/g, '').trim(),
              a: match[2].trim(),
              url: page.url
            });
          }
        });
      }

      // --- 2. 句子卡片提取 ---
      const sentBlocks = content.matchAll(/#{2,6}\s*.*句子卡片.*\n([\s\S]*?)(?=(#{2,6}\s)|$)/g);
      for (const block of sentBlocks) {
        const lines = block[1].split('\n');
        lines.forEach(line => {
          // 🔥 核心修改:同样支持 * 号列表
          const match = line.match(/^\s*[-*]\s*(.*?)\s*::\s*(.*)$/);
          
          if (match) {
            sentenceList.push({
              q: match[1].replace(/\*\*/g, '').trim(),
              a: match[2].trim(),
              url: page.url
            });
          }
        });
      }
    })
    
    // 打印日志方便调试,你在终端运行 npm run docs:dev 时能看到
    console.log(`[English Loader] Loaded ${vocabList.length} words, ${sentenceList.length} sentences.`);

    return {
      vocab: vocabList,
      sentences: sentenceList
    }
  }
})

开发抽认卡组件:

文件路径docs/.vitepress/theme/components/FlashCard.vue

vue
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { data } from '../utils/english.data.mjs'

const mode = ref('vocab') // 'vocab' | 'sentence'
const isFlipped = ref(false)
const currentIndex = ref(0)
const isAnimating = ref(false)

// 获取当前题库
const currentDeck = computed(() => {
  return mode.value === 'vocab' ? data.vocab : data.sentences
})

const currentCard = computed(() => {
  if (!currentDeck.value || currentDeck.value.length === 0) return null
  return currentDeck.value[currentIndex.value]
})

// 随机洗牌
const shuffle = () => {
  if (currentDeck.value.length <= 1) return
  isAnimating.value = true
  setTimeout(() => {
    currentIndex.value = Math.floor(Math.random() * currentDeck.value.length)
    isFlipped.value = false
    isAnimating.value = false
  }, 200)
}

// 下一张
const nextCard = () => {
  isFlipped.value = false
  setTimeout(() => {
    currentIndex.value = (currentIndex.value + 1) % currentDeck.value.length
  }, 200)
}

// 模式切换重置
watch(mode, () => {
  currentIndex.value = 0
  isFlipped.value = false
})
</script>

<template>
  <div class="flashcard-wrapper">
    <div class="header-bar">
      <div class="mode-switch">
        <button :class="{ active: mode === 'vocab' }" @click="mode = 'vocab'">
          📝 单词 ({{ data.vocab.length }})
        </button>
        <button :class="{ active: mode === 'sentence' }" @click="mode = 'sentence'">
          🗣️ 句型 ({{ data.sentences.length }})
        </button>
      </div>
      <button class="shuffle-btn" @click="shuffle" title="随机抽取">🎲 Random</button>
    </div>

    <div v-if="currentCard" class="card-scene" @click="isFlipped = !isFlipped">
      <div class="card" :class="{ 'is-flipped': isFlipped, 'shaking': isAnimating }">
        
        <div class="card-face front">
          <span class="hint">点击翻看答案</span>
          <div class="card-content main-text">{{ currentCard.q }}</div>
        </div>

        <div class="card-face back">
          <div class="card-content sub-text">{{ currentCard.a }}</div>
          <a :href="currentCard.url" class="source-link" @click.stop>
            🔗 来源:点击回顾笔记
          </a>
        </div>

      </div>
    </div>

    <div v-else class="empty-state">
      <h3>📭 题库为空</h3>
      <p>请在笔记中使用 <code>## 单词卡片</code> 格式添加内容。</p>
    </div>

    <div class="footer-bar" v-if="currentCard">
      <button class="next-btn" @click="nextCard">Next Card →</button>
    </div>
  </div>
</template>

<style scoped>
.flashcard-wrapper {
  max-width: 680px;
  margin: 2rem auto;
  user-select: none;
}

.header-bar {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
  align-items: center;
}

/* 按钮组样式优化 */
.mode-switch {
  background: var(--vp-c-bg-soft);
  padding: 4px;
  border-radius: 8px;
  display: flex;
  gap: 4px;
}

.mode-switch button {
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 0.9rem;
  transition: all 0.2s;
  color: var(--vp-c-text-2);
  border: none;
  cursor: pointer;
}

.mode-switch button.active {
  background: var(--vp-c-bg);
  color: var(--vp-c-brand);
  font-weight: 600;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.shuffle-btn {
  background: var(--vp-c-bg-soft);
  border: 1px solid var(--vp-c-divider);
  padding: 6px 12px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 0.9rem;
  transition: all 0.2s;
}
.shuffle-btn:hover {
  border-color: var(--vp-c-brand);
  color: var(--vp-c-brand);
}

/* --- 卡片场景 --- */
.card-scene {
  height: 360px; /*稍微加高一点,容纳更多内容*/
  perspective: 1000px;
  cursor: pointer;
}

.card {
  width: 100%;
  height: 100%;
  position: relative;
  transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1);
  transform-style: preserve-3d;
  border-radius: 16px;
  box-shadow: 0 10px 30px -10px rgba(0,0,0,0.2);
}

.card.is-flipped { transform: rotateY(180deg); }
.card.shaking { animation: shake 0.4s ease-in-out; }

/* --- 卡片表面通用样式 --- */
.card-face {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  border-radius: 16px;
  border: 1px solid var(--vp-c-divider);
  
  /* 🔥 核心修复1:布局改为 Flex 垂直排列,防止内容被遮挡 */
  display: flex;
  flex-direction: column;
  justify-content: center; /* 内容少时居中 */
  align-items: center;
  
  padding: 40px 30px; /* 增加内边距,防止文字贴边 */
  text-align: center;
  box-sizing: border-box; /* 确保 padding 不撑大盒子 */
}

/* 翻转背面处理 */
.back { 
  background: var(--vp-c-bg-mute); 
  transform: rotateY(180deg); 
  border-color: var(--vp-c-brand);
}
.front { 
  background: var(--vp-c-bg-soft); 
}

/* --- 🔥 核心修复2:内容滚动与文字处理 --- */
.card-content {
  width: 100%;
  /* 允许长单词(如 slash 列表)强制换行 */
  word-wrap: break-word;
  overflow-wrap: break-word;
  word-break: break-word; 
  
  /* 如果内容太长,允许卡片内部滚动 */
  max-height: 100%;
  overflow-y: auto;
  
  /* 隐藏丑陋的滚动条但保留滚动功能 */
  scrollbar-width: thin; 
}

/* 针对长难句的字体优化 */
.main-text { 
  font-size: 1.4rem; /* 从 2rem 降到 1.4rem,更适合长句 */
  line-height: 1.6;  /* 增加行高,提升阅读体验 */
  font-weight: 600; 
  color: var(--vp-c-text-1);
}

.sub-text { 
  font-size: 1.1rem; 
  color: var(--vp-c-text-2); 
  margin-bottom: 20px;
  line-height: 1.5;
}

.hint { 
  position: absolute; 
  top: 15px; 
  font-size: 0.8rem; 
  color: var(--vp-c-text-3); 
  opacity: 0.6; 
}

.source-link {
  font-size: 0.9rem;
  color: var(--vp-c-brand);
  text-decoration: none;
  border-bottom: 1px dashed transparent;
  padding-bottom: 2px;
  margin-top: 10px; /* 稍微隔开 */
  flex-shrink: 0; /* 防止链接被挤压消失 */
}
.source-link:hover { border-bottom-color: var(--vp-c-brand); }

.next-btn {
  display: block;
  margin: 20px auto;
  padding: 12px 32px;
  background: var(--vp-c-brand);
  color: white;
  border-radius: 30px;
  border: none;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.1s;
}
.next-btn:active { transform: scale(0.95); }

@keyframes shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-5px); }
  75% { transform: translateX(5px); }
}
</style>

注册与部署:

  1. 全局注册 (docs/.vitepress/theme/index.js):

    vue
    import FlashCard from './components/FlashCard.vue'
    
    export default {
      // ...
      enhanceApp({ app }) {
        app.component('FlashCard', FlashCard)
      }
    }
  2. 创建入口页 (docs/utils/flashcard.md):

    ---
    title: 英语记忆胶囊
    ---
    
    # 🧠 英语记忆胶囊
    
    每天刷 5 分钟,积少成多。
    
    <FlashCard />
  3. 重启服务: 修改了 .data.mjs 后,必须重启终端 (Ctrl+C 然后 npm run docs:dev) 才能生效

每日灵感胶囊

我的需求

在这个信息过载的时代,我存了太多的“好东西”在笔记里——某本书里的一句话、某个大佬的观点、或者深夜的一点感悟。 把它们扔在文件夹里吃灰太可惜了。 我需要一个**“灵感胶囊”**:它像老式日历一样,每天只给我看一条。不多,不少,刚刚好。它藏在我的工具箱里,不喧宾夺主,只等待有缘(其实就是我自己)的点击

使用规则

为了让程序能精准地从我的笔记海洋里捞出这些金句,我们约定一个简单的“暗号”

📝 书写规则:

  1. 文件位置docs/05.Growth/素材积累/名言金句.md

  2. 抓取锚点:任意层级标题包含 名言金句(例如 ## 名言金句

  3. 内容格式

    无序列表(支持 -*

    格式:内容 :: 出处 (出处可选,没有就默认是你自己)

✅ 示例 (docs/05.Growth/素材积累/名言金句.md):

---
title: 素材积累
---

# 我的思想仓库

这里乱七八糟记了很多东西...

## 名言金句
- **种一棵树最好的时间是十年前,其次是现在。** :: 谚语
- **Stay hungry, stay foolish.** :: Steve Jobs
- **Done is better than perfect.** :: Facebook准则
- **生活不是等待风暴过去,而是学会在雨中跳舞。**

核心代码

灵感抓手 (Data Loader)

文件路径docs/.vitepress/theme/utils/inspiration.data.mjs

这个脚本负责精准定位到 名言金句 标题,把它下面的列表“偷”出来

javascript
import { createContentLoader } from 'vitepress'

// 🎯 精准打击:只扫描指定目录下的特定文件
export default createContentLoader('docs/05.Growth/素材积累/名言金句.md', {
  includeSrc: true,
  transform(rawData) {
    const quotes = []

    rawData.forEach((page) => {
      const content = page.src
      if (!content) return

      // 🔍 正则逻辑:
      // 1. 找到 "名言金句" 标题
      // 2. 捕获它下方直到下一个标题前的所有内容
      const blockMatch = content.match(/#{2,6}\s*.*名言金句.*\n([\s\S]*?)(?=(#{2,6}\s)|$)/);
      
      if (blockMatch) {
        const listContent = blockMatch[1];
        const lines = listContent.split('\n');
        
        lines.forEach(line => {
          // 匹配列表项,支持 - 或 *
          // 格式:- 内容 :: 出处
          const match = line.match(/^\s*[-*]\s*(.*?)(?:\s*::\s*(.*))?$/);
          
          if (match) {
            const text = match[1].replace(/\*\*/g, '').trim(); // 去掉加粗
            // 如果这行是空的(比如只有 - ),跳过
            if (!text) return;

            quotes.push({
              content: text,
              source: match[2] ? match[2].trim() : 'Unknown' // 如果没写出处,标记未知
            });
          }
        });
      }
    })

    console.log(`[Inspiration Loader] 成功提取 ${quotes.length} 条灵感。`);
    return quotes
  }
})

胶囊组件 (The UI)

文件路径docs/.vitepress/theme/components/DailyQuote.vue

这里我们加一点“私密感”。考虑到你不想太直接地展示,我设计了一个**“磨砂模糊”**的初始状态。鼠标放上去(或点击)才会清晰显示

vue
<script setup>
import { ref, computed, onMounted } from 'vue'
// 引入数据
import { data as quotes } from '../utils/inspiration.data.mjs'

// --- 📅 核心算法:伪随机每日推荐 ---
const getDailyQuote = () => {
  if (!quotes || quotes.length === 0) return null
  
  const today = new Date()
  // 构造种子:20251228
  const seed = today.getFullYear() * 10000 + (today.getMonth() + 1) * 100 + today.getDate()
  
  // 取模,保证每天固定,但内容轮换
  const index = seed % quotes.length
  return quotes[index]
}

const currentQuote = ref(null)
const isRevealed = ref(false) // 控制模糊/清晰状态

onMounted(() => {
  currentQuote.value = getDailyQuote()
})

// 切换显示状态
const toggleReveal = () => {
  isRevealed.value = !isRevealed.value
}

// 获取今天日期
const dateStr = computed(() => {
  return new Date().toLocaleDateString('en-US', {
    weekday: 'short', month: 'short', day: 'numeric'
  })
})
</script>

<template>
  <div class="quote-container" v-if="currentQuote">
    
    <div 
      class="quote-card" 
      :class="{ 'revealed': isRevealed }"
      @click="toggleReveal"
    >
      <div class="card-header">
        <span class="date-tag">📅 {{ dateStr }}</span>
        <span class="hint-tag">{{ isRevealed ? 'Daily Pick' : 'Tap to Reveal' }}</span>
      </div>

      <div class="card-body">
        <p class="quote-text">“ {{ currentQuote.content }} ”</p>
      </div>

      <div class="card-footer">
        <span class="source">—— {{ currentQuote.source }}</span>
      </div>
    </div>

  </div>
  
  <div v-else class="empty-state">
    <p>灵感枯竭中... (去名言金句.md加点料吧)</p>
  </div>
</template>

<style scoped>
.quote-container {
  max-width: 600px;
  margin: 2rem auto;
  user-select: none;
}

.quote-card {
  background: var(--vp-c-bg-soft);
  border: 1px solid var(--vp-c-divider);
  border-radius: 16px;
  padding: 24px;
  position: relative;
  overflow: hidden;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
  box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}

.quote-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 10px 15px rgba(0,0,0,0.1);
  border-color: var(--vp-c-brand);
}

.card-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
  font-size: 0.85rem;
  color: var(--vp-c-text-3);
  font-family: monospace;
}

/* --- 核心特效:模糊与揭示 --- */
.card-body {
  margin: 20px 0;
  min-height: 60px; /* 占位,防止抖动 */
  display: flex;
  align-items: center;
  justify-content: center;
  transition: filter 0.5s ease;
}

/* 默认状态:模糊,看不清字,制造神秘感 */
.quote-card:not(.revealed) .card-body {
  filter: blur(6px);
  opacity: 0.6;
}
.quote-card:not(.revealed) .card-footer {
  opacity: 0; /* 出处完全隐藏 */
}

/* 揭示状态:清晰 */
.revealed .card-body {
  filter: blur(0);
  opacity: 1;
}
.revealed .card-footer {
  opacity: 1;
}

.quote-text {
  font-size: 1.3rem;
  line-height: 1.6;
  font-weight: 600;
  color: var(--vp-c-text-1);
  text-align: center;
  margin: 0;
  font-family: serif; /* 衬线体更有质感 */
}

.card-footer {
  text-align: right;
  font-size: 0.9rem;
  color: var(--vp-c-text-2);
  font-style: italic;
  transition: opacity 0.5s ease;
}

.empty-state {
  text-align: center;
  padding: 40px;
  color: var(--vp-c-text-3);
  font-size: 0.9rem;
  border: 1px dashed var(--vp-c-divider);
  border-radius: 12px;
}
</style>

搭建上线

注册组件

docs/.vitepress/theme/index.js,给它发个“准生证”

javascript
import DailyQuote from './components/DailyQuote.vue'

export default {
  // ...
  enhanceApp({ app }) {
    // ...
    app.component('DailyQuote', DailyQuote)
  }
}

安置胶囊

既然是“生人勿进”的私房货,建议放在你的 Utils 目录下,或者专门建一个 inspiration.md

文件docs/utils/inspiration.md (或你喜欢的任何位置)

---
title: 灵感胶囊
layout: doc
---

# 💊 每日一粒

<br>

<DailyQuote />

<br>

> "Stay hungry, stay foolish." 
> —— 这里的每一句话,都是我思想的切片。

赛博战术人生面板

我的需求

视觉升级:摒弃简单的像素风,升级为 “战术终端 (Tactical HUD)” 风格。

  • 黑夜模式:赛博朋克霓虹风(青色光效 + 呼吸雷达),打造沉浸式控制台体验。
  • 白天模式:工业机甲风(高对比白/黑/橙),清晰锐利,类似 EVA 风格的仪表盘。

拒绝焦虑:将原本引发焦虑的“生命倒计时(血条)”移除,改为正向积累的 “人生阅历 (Life EXP)”,看着进度条增长是一种成就感,而不是压迫感。

Bento 布局:采用 Bento Grid (便当盒) 网格布局,将雷达图、日历、进度条模块化拼接,既有信息密度,又整齐美观。


功能介绍

角色档案卡 (Character Profile)

  • 六维雷达图:实时渲染的 SVG 雷达,展示智力、体力、技术等个人属性(可配置)。
  • 身份信息:包含 ID、职业、等级(基于年龄自动计算)和在线状态。

数据监控矩阵

  • 实时时钟:精确到毫秒的系统时间显示。
  • 能量电池 (Week):7 格电池条展示本周消耗情况。
  • 存储矩阵 (Month):点阵图展示本月度过的时间点。

任务与进度系统

  • 人生阅历 (XP):基于预期寿命的正向增长进度条 (0% -> 100%)。
  • 主线任务 (Quest):大学毕业/阶段性目标的倒计时进度。

核心代码

组件代码 (PixelHud.vue)

文件路径docs/.vitepress/theme/components/PixelHud.vue

vue
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useData } from 'vitepress'

const { isDark } = useData()

// ==========================================
// 🎛️ 个人数据配置 (已汉化)
// ==========================================
const CONFIG = {
  // 基础信息
  username: 'PandaC',
  profession: '独立开发者', // 职业
  birth: '2004-01-21',
  grad: '2026-06-30',
  college: '2022-09-01',
  expectAge: 70, 
  
  // 六维属性 (建议用单字中文,排版更整齐)
  stats: [
    { label: '智', value: 85 }, // Intelligence
    { label: '体', value: 70 }, // Vitality
    { label: '幸', value: 60 }, // Luck
    { label: '魅', value: 75 }, // Charisma
    { label: '技', value: 90 }, // Dev / Tech
    { label: '语', value: 65 }  // Language
  ]
}

// --- 🕒 系统时钟 ---
const timeStr = ref('')
const msStr = ref('00')
let timer = null
const updateClock = () => {
  const now = new Date()
  timeStr.value = now.toLocaleTimeString('en-GB', { hour12: false })
  msStr.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0')
}

// --- 📊 基础数据计算 ---
const today = new Date()
const birthDate = new Date(CONFIG.birth)

// 1. 日历信息
const dateInfo = computed(() => {
  const weeksCN = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
  return {
    day: today.getDate().toString().padStart(2, '0'),
    week: weeksCN[today.getDay()],
    month: today.getMonth() + 1,
    year: today.getFullYear()
  }
})

// 2. 等级 (Age = Level)
const level = computed(() => {
  let age = today.getFullYear() - birthDate.getFullYear()
  const m = today.getMonth() - birthDate.getMonth()
  if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--
  return age
})

// 3. XP (人生阅历)
const lifeExp = computed(() => {
  const endLife = new Date(CONFIG.birth)
  endLife.setFullYear(birthDate.getFullYear() + CONFIG.expectAge)
  const total = endLife - birthDate
  const lived = today - birthDate
  const percent = (lived / total) * 100
  return percent.toFixed(2)
})

// 4. 周进度 (电池格)
const weekProgress = computed(() => {
  let day = today.getDay()
  if (day === 0) day = 7 
  return day 
})

// 5. 月进度 (矩阵点)
const monthProgress = computed(() => {
  const year = today.getFullYear()
  const month = today.getMonth()
  const daysInMonth = new Date(year, month + 1, 0).getDate()
  return {
    total: daysInMonth,
    passed: today.getDate(),
    percent: ((today.getDate() / daysInMonth) * 100).toFixed(0)
  }
})

// 6. 任务进度 (毕业倒计时)
const questGrad = computed(() => {
  const start = new Date(CONFIG.college)
  const end = new Date(CONFIG.grad)
  let pct = ((today - start) / (end - start)) * 100
  return Math.min(100, Math.max(0, pct)).toFixed(1)
})

// --- 🕸️ 雷达图逻辑 ---
const radarPoints = computed(() => {
  const total = 6; const radius = 38; const center = 50 // 稍微调小半径防止文字溢出
  let points = ''
  CONFIG.stats.forEach((stat, i) => {
    const angle = (Math.PI * 2 * i) / total - Math.PI / 2
    const r = (stat.value / 100) * radius
    const x = center + r * Math.cos(angle)
    const y = center + r * Math.sin(angle)
    points += `${x},${y} `
  })
  return points
})
const radarGrid = computed(() => {
  const levels = [15, 25, 38]
  return levels.map(r => {
    let pts = ''; const total = 6; const center = 50
    for (let i = 0; i < total; i++) {
      const angle = (Math.PI * 2 * i) / total - Math.PI / 2
      const x = center + r * Math.cos(angle)
      const y = center + r * Math.sin(angle)
      pts += `${x},${y} `
    }
    return pts
  })
})

onMounted(() => {
  timer = setInterval(updateClock, 50)
  updateClock()
})
onUnmounted(() => clearInterval(timer))
</script>

<template>
  <div class="hud-container" :class="{ 'is-dark': isDark }">
    <div class="scan-line"></div>
    <div class="corner-screw tl"></div><div class="corner-screw tr"></div>
    <div class="corner-screw bl"></div><div class="corner-screw br"></div>

    <div class="hud-header">
      <div class="sys-group">
        <span class="status-led"></span>
        <span class="sys-text">系统正常 (ONLINE)</span>
      </div>
      <div class="sys-clock">
        {{ timeStr }}<span class="ms">.{{ msStr }}</span>
      </div>
    </div>

    <div class="hud-grid">
      
      <div class="hud-card character-card">
        <div class="card-label">角色档案 PROFILE</div>
        
        <div class="char-layout">
          <div class="char-info">
            <div class="info-row">
              <span class="label">ID</span>
              <span class="val highlight">{{ CONFIG.username }}</span>
            </div>
            <div class="info-row">
              <span class="label">职业</span>
              <span class="val">{{ CONFIG.profession }}</span>
            </div>
            <div class="info-row">
              <span class="label">等级</span>
              <span class="val lv">LV.{{ level }}</span>
            </div>
            <div class="info-row desc">
              <span class="label">状态</span>
              <span class="val">学习中...</span>
            </div>
          </div>

          <div class="radar-wrapper">
            <svg viewBox="0 0 100 100" class="radar-svg">
              <polygon v-for="(pts, i) in radarGrid" :key="i" :points="pts" class="grid-poly"/>
              <line v-for="i in 3" :key="'l'+i" x1="50" y1="5" x2="50" y2="95" class="cross-line" :transform="`rotate(${(i-1)*60} 50 50)`" />
              <polygon :points="radarPoints" class="stat-poly" />
            </svg>
            <div class="stat-labels">
              <span v-for="(s, i) in CONFIG.stats" :key="s.label" class="s-tag" :class="'p'+i">{{ s.label }}</span>
            </div>
          </div>
        </div>
      </div>

      <div class="right-col">
        <div class="hud-card calendar-card">
          <div class="card-label">当前日期 DATE</div>
          <div class="cal-content">
            <div class="cal-big">{{ dateInfo.day }}</div>
            <div class="cal-right">
              <div class="cal-month">{{ dateInfo.year }}年{{ dateInfo.month }}月</div>
              <div class="cal-week">{{ dateInfo.week }}</div>
            </div>
          </div>
        </div>

        <div class="hud-card">
          <div class="card-label">本周充能 WEEK</div>
          <div class="week-cells">
            <div 
              v-for="i in 7" :key="i" 
              class="cell" 
              :class="{ active: i <= weekProgress, pulse: i === weekProgress }"
            ></div>
          </div>
        </div>

        <div class="hud-card">
          <div class="card-header">
            <div class="card-label">本月概览 MONTH</div>
            <div class="card-val">{{ monthProgress.percent }}%</div>
          </div>
          <div class="month-dots">
            <div 
              v-for="i in monthProgress.total" :key="i" 
              class="dot" 
              :class="{ active: i <= monthProgress.passed }"
            ></div>
          </div>
        </div>
      </div>

    </div>

    <div class="hud-footer">
      
      <div class="bar-group">
        <div class="bar-info">
          <span>人生阅历 (EXP)</span>
          <span>{{ lifeExp }}%</span>
        </div>
        <div class="track">
          <div class="fill xp-fill" :style="{ width: lifeExp + '%' }"></div>
        </div>
      </div>

      <div class="bar-group">
        <div class="bar-info">
          <span>主线任务:大学毕业</span>
          <span>{{ questGrad }}%</span>
        </div>
        <div class="track">
          <div class="fill quest-fill" :style="{ width: questGrad + '%' }"></div>
        </div>
      </div>

    </div>

  </div>
</template>

<style scoped>
/* 🎨 变量系统 */
.hud-container {
  /* ☀️ 白天: 工业白/灰/橙 */
  --c-bg: rgba(245, 247, 250, 0.95);
  --c-border: #333;
  --c-text: #111;
  --c-dim: #888;
  --c-accent: #f39c12; /* 橙色 */
  --c-line: rgba(0,0,0,0.1);
  --c-xp: #2ecc71; /* 绿色 */
  --c-quest: #3498db; /* 蓝色 */
  --c-poly: rgba(50,50,50,0.2);
  --c-poly-stroke: #333;

  font-family: "PingFang SC", "Microsoft YaHei", "Consolas", monospace; /* 中文优先字体 */
  border: 2px solid var(--c-border);
  border-radius: 6px;
  padding: 20px;
  margin-bottom: 2rem;
  position: relative;
  box-shadow: 0 10px 20px rgba(0,0,0,0.05);
  user-select: none;
}

/* 🌙 黑夜: 赛博青/深灰 */
.hud-container.is-dark {
  --c-bg: rgba(12, 16, 20, 0.85);
  --c-border: rgba(0, 255, 200, 0.3);
  --c-text: #ccffee;
  --c-dim: rgba(0, 255, 200, 0.4);
  --c-accent: #ff0055;
  --c-line: rgba(0, 255, 200, 0.1);
  --c-xp: #00ffc8;
  --c-quest: #00ccff;
  --c-poly: rgba(0, 255, 200, 0.15);
  --c-poly-stroke: #00ffc8;

  box-shadow: 0 0 25px rgba(0, 255, 200, 0.05), inset 0 0 50px rgba(0,0,0,0.5);
  backdrop-filter: blur(12px);
}

/* 装饰:扫描线 */
.scan-line {
  position: absolute; top: 0; left: 0; width: 100%; height: 3px;
  background: var(--c-accent); opacity: 0.1;
  animation: scan 6s linear infinite; pointer-events: none; z-index: 0;
}
@keyframes scan { 0% { top: 0; opacity: 0; } 50% { opacity: 0.2; } 100% { top: 100%; opacity: 0; } }

/* 装饰:螺丝 */
.corner-screw {
  position: absolute; width: 6px; height: 6px;
  border: 1px solid var(--c-border); background: var(--c-bg);
  z-index: 2;
}
.tl { top: 4px; left: 4px; border-right: 0; border-bottom: 0; }
.tr { top: 4px; right: 4px; border-left: 0; border-bottom: 0; }
.bl { bottom: 4px; left: 4px; border-right: 0; border-top: 0; }
.br { bottom: 4px; right: 4px; border-left: 0; border-top: 0; }

/* 1. Header */
.hud-header {
  display: flex; justify-content: space-between; align-items: flex-end;
  border-bottom: 1px dashed var(--c-border);
  padding-bottom: 12px; margin-bottom: 20px;
}
.sys-group { display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: var(--c-text); font-weight: bold; }
.status-led { width: 6px; height: 6px; background: var(--c-xp); border-radius: 50%; box-shadow: 0 0 6px var(--c-xp); animation: blink 2s infinite; }
.sys-clock { font-family: 'Consolas', monospace; font-size: 1.2rem; font-weight: 700; color: var(--c-text); }
.ms { font-size: 0.8rem; color: var(--c-dim); }

/* 2. Grid Layout (Bento) */
.hud-grid {
  display: grid;
  grid-template-columns: 1.2fr 1fr; /* 左列稍微宽一点放角色卡 */
  gap: 15px;
  margin-bottom: 20px;
}

/* 右侧列布局 */
.right-col {
  display: flex; flex-direction: column; gap: 15px;
}

/* 通用卡片样式 */
.hud-card {
  border: 1px solid var(--c-line);
  background: rgba(125,125,125,0.03);
  padding: 12px;
  border-radius: 4px;
  position: relative;
  display: flex; flex-direction: column; justify-content: center;
}
.card-label {
  position: absolute; top: 8px; left: 10px;
  font-size: 0.65rem; color: var(--c-dim); letter-spacing: 1px;
}

/* 2.1 Character Card (左侧大卡) */
.character-card { grid-row: span 3; /* 撑满高度 */ padding-top: 25px; } 

.char-layout {
  display: flex; justify-content: space-between; align-items: center;
  height: 100%;
}
.char-info { display: flex; flex-direction: column; gap: 8px; flex: 1; }
.info-row { display: flex; flex-direction: column; }
.info-row .label { font-size: 0.6rem; color: var(--c-dim); }
.info-row .val { font-size: 0.95rem; font-weight: bold; color: var(--c-text); }
.info-row .highlight { color: var(--c-accent); font-size: 1.1rem; }
.info-row .lv { font-family: 'Consolas', monospace; color: var(--c-xp); }

/* 雷达图微调 */
.radar-wrapper { position: relative; width: 110px; height: 110px; flex-shrink: 0; }
.radar-svg { overflow: visible; }
.grid-poly { fill: none; stroke: var(--c-line); }
.cross-line { stroke: var(--c-line); stroke-dasharray: 2; }
.stat-poly {
  fill: var(--c-poly); stroke: var(--c-poly-stroke); stroke-width: 2;
  stroke-linejoin: round; filter: drop-shadow(0 0 4px var(--c-poly-stroke));
  animation: breathe 4s ease-in-out infinite;
}
@keyframes breathe { 50% { opacity: 0.7; } }
.stat-labels .s-tag {
  position: absolute; font-size: 0.75rem; color: var(--c-text); font-weight: bold;
  text-shadow: 0 0 2px var(--c-bg); /* 增加描边防止看不清 */
}
/* 标签定位 */
.p0 { top: -14px; left: 50%; transform: translateX(-50%); } 
.p1 { top: 20%; right: -15px; } 
.p2 { bottom: 20%; right: -15px; } 
.p3 { bottom: -14px; left: 50%; transform: translateX(-50%); } 
.p4 { bottom: 20%; left: -15px; } 
.p5 { top: 20%; left: -15px; } 

/* 2.2 Calendar */
.calendar-card { padding-top: 25px; }
.cal-content { display: flex; align-items: center; gap: 12px; }
.cal-big { font-size: 2.8rem; font-weight: 800; line-height: 1; color: var(--c-text); font-family: 'Consolas', monospace; }
.cal-right { display: flex; flex-direction: column; gap: 2px; }
.cal-month { font-size: 0.8rem; font-weight: 700; color: var(--c-accent); }
.cal-week { font-size: 0.75rem; color: var(--c-dim); }

/* 2.3 Week Cells */
.week-cells { display: flex; gap: 4px; height: 18px; margin-top: 15px; }
.cell {
  flex: 1; background: var(--c-line); border-radius: 2px;
  transition: 0.3s;
}
.cell.active { background: var(--c-accent); opacity: 0.5; }
.cell.pulse { background: var(--c-accent); opacity: 1; box-shadow: 0 0 8px var(--c-accent); }

/* 2.4 Month Dots */
.card-header { display: flex; justify-content: space-between; margin-bottom: 8px; margin-top: 15px;}
.card-val { font-size: 0.8rem; font-weight: bold; color: var(--c-text); }
.month-dots { display: flex; flex-wrap: wrap; gap: 3px; }
.dot { width: 6px; height: 6px; background: var(--c-line); border-radius: 1px; }
.dot.active { background: var(--c-text); }

/* 3. Footer Bars */
.hud-footer { display: flex; flex-direction: column; gap: 12px; }
.bar-group { display: flex; flex-direction: column; gap: 4px; }
.bar-info { display: flex; justify-content: space-between; font-size: 0.75rem; font-weight: bold; color: var(--c-text); }
.track {
  height: 10px; background: var(--c-line); border: 1px solid var(--c-border);
  position: relative; border-radius: 2px;
}
.fill { height: 100%; transition: width 0.8s ease; position: relative; }
/* 条纹装饰 */
.fill::after {
  content: ''; position: absolute; inset: 0;
  background: repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(0,0,0,0.2) 4px, rgba(0,0,0,0.2) 8px);
}
.xp-fill { background: var(--c-xp); box-shadow: 0 0 8px var(--c-xp); }
.quest-fill { background: var(--c-quest); box-shadow: 0 0 8px var(--c-quest); }

@media (max-width: 600px) {
  .hud-grid { grid-template-columns: 1fr; }
  .right-col { display: grid; grid-template-columns: 1fr 1fr; } /* 手机端右侧两列变一行两个 */
  .calendar-card { grid-column: span 2; }
}
</style>

注册组件

文件路径docs/.vitepress/theme/index.js

JavaScript

javascript
import PixelHud from './components/PixelHud.vue'

export default {
  // ...
  enhanceApp({ app }) {
    app.component('PixelHud', PixelHud)
  }
}

部署页面

文件路径docs/05.Growth/index.md

# 🌏 认识世界
xxxyyy介绍

<br>

<PixelHud />

---

## 📚 文章列表

配置指南

直接修改 PixelHud.vue 顶部的常量区:

  1. 基础信息:修改 username (ID)、profession (职业)、birth (生日)。
  2. 属性六维:修改 stats 数组,每个对象包含 label (属性名,建议单字) 和 value (数值 0-100)。
  3. 任务进度:修改 grad (毕业日期) 和 college (入学日期) 来调整主线任务的进度。

摄影足迹地图

功能概述

本功能旨在为“视觉工坊”板块打造一个动态的中国地图索引。它的核心逻辑非常简单直接:你只需要按照原本的习惯,将文章发布在 docs/02.Visuals/摄影作品/ 目录下(支持任意层级的年份或季度子目录),脚本就会在构建时自动扫描这些文件

系统会自动读取文章头部的地点(Location)和封面(Cover)信息,并在地图上点亮对应的城市。如果是视频内容,地图交互时会智能显示场记板图标加以区分


目录结构

严格保持你现有的中文目录结构,无需任何修改

MyBlog/
├── .vitepress/theme
│   └── data/
│       └── china.json(数据来源于互联网,自行下载!!!)  
│   └── components/
│       └── ChinaMap.vue   

└── docs/02.Photography/
    ├── index.md                <-- 挂载地图
    └── 摄影作品/               <-- [核心] 脚本只扫描这里
        ├── 25年一季度/
        │   ├── 洪崖洞.md
        │   └── 西湖Vlog.md
        └── 25年四季度/
            └── ...

核心代码

自动扫描脚本

文件docs/.vitepress/theme/utils/photo-map.data.mjs

逻辑:精准锁定 摄影作品 中文目录,递归抓取所有子文件夹

javascript
import { createContentLoader } from 'vitepress'

// 🎯 核心修改:路径严格匹配你的中文目录 '摄影作品'
export default createContentLoader('docs/02.Photography/摄影作品/**/*.md', {
  transform(rawData) {
    const footprints = []

    rawData.forEach((page) => {
      const fm = page.frontmatter
      
      // 必须包含 location 和 cover 才能上地图
      if (fm.location && fm.cover) {
        footprints.push({
          title: fm.title || '无标题',
          location: fm.location,
          cover: fm.cover,
          link: page.url,
          date: fm.date,
          type: fm.type || 'photo' // 默认为照片
        })
      }
    })

    // 按日期倒序,最新的在前面
    footprints.sort((a, b) => new Date(b.date) - new Date(a.date))

    return footprints
  }
})

地图渲染组件

文件docs/.vitepress/theme/components/ChinaMap.vue

逻辑:直接读取本地 JSON,支持视频图标

vue
<script setup>
import { onMounted, ref, onBeforeUnmount, computed, watch } from 'vue'
import * as echarts from 'echarts'
import { useRouter, useData } from 'vitepress'
import { data as photoData } from '../utils/photo-map.data.mjs'
import chinaJson from './china.json' 

const chartRef = ref(null)
let myChart = null
const router = useRouter()
const activeProvince = ref(null) 
const { isDark } = useData()

// 🗺️ 映射表 (在这里补充所有你可能去的非省会城市)
const cityToProvince = {
  // 直辖市及其下辖区县 (必须手动映射到直辖市名)
  '北京': '北京', '朝阳': '北京', '海淀': '北京', '延庆': '北京',
  '上海': '上海', '浦东': '上海', '崇明': '上海',
  '天津': '天津', '滨海': '天津',
  '重庆': '重庆', '丰都': '重庆', '武隆': '重庆', '奉节': '重庆', '巫山': '重庆', '大足': '重庆', '涪陵': '重庆', '万州': '重庆',

  // 特别行政区 & 台湾
  '香港': '香港', '澳门': '澳门', '台湾': '台湾', '台北': '台湾', '高雄': '台湾', '垦丁': '台湾',

  // 浙江
  '杭州': '浙江', '宁波': '浙江', '温州': '浙江', '绍兴': '浙江', '舟山': '浙江', '嘉兴': '浙江', '台州': '浙江', '金华': '浙江', '湖州': '浙江', '丽水': '浙江', '衢州': '浙江', '乌镇': '浙江', '西塘': '浙江', '千岛湖': '浙江', '普陀山': '浙江', '莫干山': '浙江',

  // 江苏
  '南京': '江苏', '苏州': '江苏', '无锡': '江苏', '扬州': '江苏', '常州': '江苏', '徐州': '江苏', '连云港': '江苏', '镇江': '江苏', '南通': '江苏', '泰州': '江苏',

  // 四川
  '成都': '四川', '九寨沟': '四川', '稻城': '四川', '亚丁': '四川', '乐山': '四川', '峨眉山': '四川', '阿坝': '四川', '甘孜': '四川', '色达': '四川', '理塘': '四川', '都江堰': '四川', '青城山': '四川', '若尔盖': '四川', '四姑娘山': '四川',

  // 广东
  '广州': '广东', '深圳': '广东', '珠海': '广东', '佛山': '广东', '汕头': '广东', '惠州': '广东', '东莞': '广东', '中山': '广东', '潮州': '广东', '顺德': '广东',

  // 湖北
  '武汉': '湖北', '宜昌': '湖北', '恩施': '湖北', '神农架': '湖北', '武当山': '湖北', '襄阳': '湖北',

  // 湖南
  '长沙': '湖南', '张家界': '湖南', '凤凰': '湖南', '湘西': '湖南', '衡阳': '湖南', '岳阳': '湖南',

  // 陕西
  '西安': '陕西', '咸阳': '陕西', '延安': '陕西', '宝鸡': '陕西', '华山': '陕西',

  // 云南
  '昆明': '云南', '大理': '云南', '丽江': '云南', '香格里拉': '云南', '西双版纳': '云南', '迪庆': '云南', '腾冲': '云南', '芒市': '云南', '普者黑': '云南', '泸沽湖': '云南', '玉龙雪山': '云南',

  // 福建
  '厦门': '福建', '福州': '福建', '泉州': '福建', '武夷山': '福建', '漳州': '福建', '平潭': '福建', '霞浦': '福建',

  // 山东
  '青岛': '山东', '济南': '山东', '威海': '山东', '烟台': '山东', '泰山': '山东', '淄博': '山东', '泰安': '山东', '曲阜': '山东',

  // 海南
  '三亚': '海南', '海口': '海南', '万宁': '海南', '陵水': '海南', '文昌': '海南', '五指山': '海南',

  // 西藏
  '拉萨': '西藏', '林芝': '西藏', '日喀则': '西藏', '阿里': '西藏', '纳木错': '西藏', '珠峰': '西藏',

  // 贵州
  '贵阳': '贵州', '荔波': '贵州', '黄果树': '贵州', '千户苗寨': '贵州', '遵义': '贵州', '镇远': '贵州',

  // 黑龙江
  '哈尔滨': '黑龙江', '雪乡': '黑龙江', '漠河': '黑龙江', '伊春': '黑龙江',

  // 辽宁
  '沈阳': '辽宁', '大连': '辽宁', '丹东': '辽宁',

  // 吉林
  '长春': '吉林', '延吉': '吉林', '长白山': '吉林', '吉林市': '吉林',

  // 河南
  '郑州': '河南', '洛阳': '河南', '开封': '河南', '焦作': '河南', '少林寺': '河南', '龙门石窟': '河南',

  // 山西
  '太原': '山西', '大同': '山西', '平遥': '山西', '五台山': '山西',

  // 安徽
  '合肥': '安徽', '黄山': '安徽', '宏村': '安徽',

  // 江西
  '南昌': '江西', '景德镇': '江西', '婺源': '江西', '庐山': '江西',

  // 广西
  '南宁': '广西', '桂林': '广西', '北海': '广西', '阳朔': '广西', '涠洲岛': '广西',

  // 甘肃
  '兰州': '甘肃', '敦煌': '甘肃', '张掖': '甘肃', '嘉峪关': '甘肃', '甘南': '甘肃',

  // 青海
  '西宁': '青海', '青海湖': '青海', '茶卡盐湖': '青海', '德令哈': '青海',

  // 宁夏
  '银川': '宁夏', '中卫': '宁夏',

  // 新疆
  '乌鲁木齐': '新疆', '喀什': '新疆', '伊犁': '新疆', '阿勒泰': '新疆', '禾木': '新疆', '赛里木湖': '新疆', '吐鲁番': '新疆', '独库公路': '新疆',

  // 内蒙古
  '呼和浩特': '内蒙古', '呼伦贝尔': '内蒙古', '额济纳': '内蒙古', '阿尔山': '内蒙古',

  // 河北
  '石家庄': '河北', '秦皇岛': '河北', '北戴河': '河北', '承德': '河北'
}

// 🔄 数据聚合:按省份分组
const mapSeriesData = (() => {
  const groups = {}
  
  photoData.forEach(item => {
    let prov = cityToProvince[item.location]
    // 💡 如果字典里没找到(比如你写了个冷门县城),代码会尝试直接用这个名字
    // 所以如果 location 写的是 '丰都' 且字典里没有,它就会找 '丰都' 省,当然找不到。
    if (!prov) prov = item.location
    
    if (!groups[prov]) groups[prov] = []
    groups[prov].push(item)
  })

  return Object.keys(groups).map(prov => ({
    name: prov, 
    articles: groups[prov],
    itemStyle: {
      areaColor: '#3e8ce2', 
      borderColor: '#ffffff',
      borderWidth: 1,
      shadowBlur: 0 
    },
    emphasis: {
      itemStyle: {
        areaColor: '#f72585',
        shadowBlur: 20,
        shadowColor: 'rgba(247, 37, 133, 0.5)',
        borderColor: '#fff',
        borderWidth: 2,
        zIndex: 100
      },
      label: { show: true, color: '#fff', fontSize: 14, fontWeight: 'bold' }
    }
  }))
})()

// 🔄 列表按城市分组
const groupedActiveList = computed(() => {
  if (!activeProvince.value) return {}
  const groups = {}
  activeProvince.value.list.forEach(item => {
    // 这里显示你原始填写的 location(比如 '丰都')
    const city = item.location || '其他'
    if (!groups[city]) groups[city] = []
    groups[city].push(item)
  })
  return groups
})

const closeCard = () => {
  activeProvince.value = null
  myChart?.dispatchAction({ type: 'downplay' })
}

const getTitleColor = () => isDark.value ? '#eee' : '#333'
const getSubTitleColor = () => isDark.value ? '#888' : '#666'

const initChart = () => {
  if (!chartRef.value) return

  myChart = echarts.init(chartRef.value, null, { renderer: 'canvas' })
  echarts.registerMap('china', chinaJson)

  const option = {
    backgroundColor: 'transparent',
    title: {
      text: 'Visual Footprints',
      subtext: `已点亮 ${mapSeriesData.length} 个省份`,
      left: 'center', top: '0', 
      textStyle: { color: getTitleColor(), fontSize: 18, fontWeight: 'bold' },
      subtextStyle: { color: getSubTitleColor(), fontSize: 12 }
    },
    tooltip: { show: false }, 
    series: [
      {
        type: 'map',
        map: 'china',
        roam: false,
        zoom: 1.2,
        itemStyle: {
          areaColor: '#2b2d30', 
          borderColor: '#4a4a4a',  
          borderWidth: 1
        },
        label: { show: false },
        emphasis: {
          label: { show: true, color: '#fff' }, 
          itemStyle: { areaColor: '#555' }      
        },
        selectedMode: false,
        data: mapSeriesData
      }
    ]
  }

  myChart.setOption(option)

  myChart.on('click', (params) => {
    if (params.data && params.data.articles) {
      activeProvince.value = {
        name: params.name,
        list: params.data.articles
      }
    }
  })

  myChart.getZr().on('click', (event) => {
    if (!event.target) closeCard()
  })
}

watch(isDark, () => {
  if (myChart) {
    myChart.setOption({
      title: {
        textStyle: { color: getTitleColor() },
        subtextStyle: { color: getSubTitleColor() }
      }
    })
  }
})

const handleResize = () => myChart && myChart.resize()

onMounted(() => {
  initChart()
  window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
  myChart && myChart.dispose()
})
</script>

<template>
  <div class="map-wrapper">
    <div ref="chartRef" class="echarts-box"></div>

    <Transition name="fade">
      <div v-if="activeProvince" class="location-card">
        <div class="card-header">
          <h3>📍 {{ activeProvince.name }}足迹</h3>
          <button class="close-btn" @click="closeCard">×</button>
        </div>
        
        <div class="card-body">
          <div v-for="(articles, city) in groupedActiveList" :key="city" class="city-group">
            <div class="city-divider">
              <span class="city-name">{{ city }}</span>
              <span class="city-count">{{ articles.length }}部作品</span>
            </div>

            <div 
              v-for="(item, index) in articles" 
              :key="index"
              class="article-item"
              @click="router.go(item.link)"
            >
              <div class="item-cover">
                <img :src="item.cover" loading="lazy" />
                <div v-if="item.type === 'video'" class="video-overlay">
                  <span class="play-icon">▶</span>
                </div>
              </div>
              
              <div class="item-info">
                <div class="title-row">
                  <span v-if="item.type === 'video'" class="badge-vlog">VLOG</span>
                  <span v-else class="badge-photo">PICS</span>
                  <span class="item-title">{{ item.title }}</span>
                </div>
                <div class="meta-row">
                  <span class="date-tag">{{ item.date.substring(0, 10) }}</span>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </Transition>
  </div>
</template>

<style scoped>
.map-wrapper {
  position: relative;
  width: 100%;
  margin: 20px 0;
  background: transparent;
  border-radius: 12px;
}

.echarts-box { width: 100%; height: 600px; }

.location-card {
  position: absolute;
  top: 20px; right: 20px;
  width: 320px;
  max-height: 520px;
  background: rgba(25, 25, 25, 0.95); 
  backdrop-filter: blur(16px);
  border: 1px solid rgba(255,255,255,0.15);
  border-radius: 16px;
  box-shadow: 0 20px 50px rgba(0,0,0,0.6);
  display: flex; flex-direction: column;
  z-index: 10;
}

.card-header {
  padding: 16px;
  border-bottom: 1px solid rgba(255,255,255,0.1);
  display: flex; justify-content: space-between; align-items: center;
  flex-shrink: 0;
}
.card-header h3 { margin: 0; font-size: 16px; color: #fff; font-weight: 700; }

.close-btn {
  background: none; border: none; color: #666; font-size: 24px;
  cursor: pointer; padding: 0 4px; line-height: 1; transition: color 0.2s;
}
.close-btn:hover { color: #fff; }

.card-body { flex: 1; overflow-y: auto; padding: 12px; }
.card-body::-webkit-scrollbar { width: 4px; }
.card-body::-webkit-scrollbar-thumb { background: #555; border-radius: 2px; }

.city-group { margin-bottom: 16px; }

.city-divider {
  display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 8px;
  padding-bottom: 4px;
  border-bottom: 1px dashed rgba(255,255,255,0.1);
}

.city-name {
  color: #3e8ce2;
  font-size: 13px; font-weight: bold;
  padding-left: 4px; border-left: 3px solid #3e8ce2; line-height: 1;
}

.city-count { color: #666; font-size: 11px; }

.article-item {
  display: flex; gap: 12px; padding: 8px;
  background: rgba(255,255,255,0.03);
  border: 1px solid rgba(255,255,255,0.02);
  border-radius: 8px;
  margin-bottom: 8px;
  cursor: pointer;
  transition: all 0.2s;
}
.article-item:hover {
  background: rgba(255,255,255,0.08);
  border-color: rgba(255,255,255,0.1);
}

.item-cover {
  width: 80px; height: 50px;
  border-radius: 4px; overflow: hidden;
  position: relative; flex-shrink: 0;
}
.item-cover img { width: 100%; height: 100%; object-fit: cover; }

.video-overlay {
  position: absolute; inset: -1px;
  background: rgba(0,0,0,0.3);
  display: flex; align-items: center; justify-content: center;
}
.play-icon { color: #fff; font-size: 16px; text-shadow: 0 1px 3px rgba(0,0,0,0.5); }

.item-info { display: flex; flex-direction: column; justify-content: center; flex: 1; min-width: 0; }
.title-row { display: flex; align-items: center; gap: 6px; }

.badge-vlog {
  background: #ff4757; color: #fff; font-size: 9px; padding: 1px 3px; border-radius: 3px;
}
.badge-photo {
  background: #2ed573; color: #fff; font-size: 9px; padding: 1px 3px; border-radius: 3px;
}

.item-title {
  color: #ddd; font-size: 13px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.meta-row { display: flex; margin-top: 4px; }
.date-tag { color: #666; font-size: 10px; font-family: monospace; }

.fade-enter-active, .fade-leave-active { transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(10px) scale(0.95); }

@media (max-width: 600px) {
  .echarts-box { height: 450px; }
  .location-card { width: auto; left: 15px; right: 15px; bottom: 15px; top: auto; max-height: 50%; }
}
</style>

注册组件

文件docs/.vitepress/theme/index.js

javascript
import ChinaMap from './components/ChinaMap.vue'

export default {
  // ...
  enhanceApp({ app }) {
    app.component('ChinaMap', ChinaMap)
  }

页面使用

文件docs/02.Photography/index.md

---
title: 视觉工坊
---

# 📷 视觉工坊

<ChinaMap />

录入示例

docs/02.Photography/摄影作品/ 下的任意子目录中创建文件:

📷 普通照片:

yaml
---
title: 洪崖洞夜景
date: 2025-01-15
location: 重庆(只能用中文!)
cover: https://...
aside: false  # 关闭右侧大纲
sidebar: true # 不关闭左侧侧边栏
---

🎬 视频内容:

yaml
---
title: 西湖VLOG
date: 2025-03-20
location: 杭州
cover: https://...
type: video
aside: false  # 关闭右侧大纲
sidebar: true # 不关闭左侧侧边栏
---

3D数字百宝箱

我的需求

核心目标:在博客的 04.Share 目录首页,创建一个展示软件/工具/资源的创意导航组件

视觉风格

  • 布局:采用 Bento Grid(便当盒)网格布局,支持不同尺寸的卡片(小方块、长条、大方块),且布局紧凑无缝隙
  • 交互:鼠标悬停时,卡片要有 3D 物理倾斜 效果,并且有 跟随鼠标的光晕 (Glow)
  • 配色
    • 黑夜模式:必须是纯正的深色 (#1d1d1d),拒绝发白或廉价的灰色;光晕要像手电筒一样明亮
    • 白天模式:陶瓷白质感,光晕为高级的淡蓝紫色

数据管理:拒绝手动写死 HTML,必须自动化


功能概述

自动化数据流:通过 VitePress 的构建时数据加载器,扫描 docs/04.Share/ 下的所有文章,筛选出配置了 cardStyle 的文章自动上架

智能布局:使用 CSS Grid 的 dense 填充模式,自动填补大卡片留下的空隙,像俄罗斯方块一样严丝合缝

双模光影系统

  • Logic:移除 mix-blend-mode,采用直出的透明度光晕,确保在纯黑/纯白背景下光效都清晰可见
  • Dark:硬核深灰底 + 强力白光
  • Light:渐变白底 + 淡紫科技光

实现流程

核心机制:Markdown 元数据 (输入) $\rightarrow$ 自动抓取脚本 (处理) $\rightarrow$ Vue 组件 (渲染)

①数据源头:Markdown 笔记

  • 涉及文件:docs/04.Share/ 目录下的任意 .md 文件

  • 操作内容:在文章头部的 Frontmatter 中配置特定字段

  • 处理逻辑:

    • 定义样式:通过 cardStyle 属性决定卡片形态

      normal: 标准小方块 (1x1)

      wide: 宽长条 (2x1)

      large: 重点推荐大方块 (2x2)

    • 定义内容:设置 icon (图标) 和 desc (简短描述)

    • 注:未配置或删除 cardStyle 属性的文章会被系统视为“普通文章”,自动过滤,不展示在百宝箱中

②数据抓取与清洗:加载脚本

  • 涉及文件:docs/.vitepress/theme/utils/share.data.mjs
  • 处理逻辑:
    • 扫描:在构建阶段(Build Time),利用 VitePress 的 createContentLoader 自动扫描指定目录下的所有 Markdown 文件
    • 过滤 (白名单)**:遍历所有文件的元数据,仅保留包含合法 cardStyle 属性(normal/wide/large)的项目
    • 排序:读取 date 字段,按日期倒序排列,确保最新的工具排在网格前面
    • 输出:将清洗后的数据打包成轻量级的 JSON 数组供组件使用

③视图渲染与交互:Vue 组件

  • 涉及文件:docs/.vitepress/theme/components/BentoGrid.vue

  • 处理逻辑:

    • 布局引擎:接收 JSON 数据,利用 CSS Grid 的 grid-auto-flow: dense 算法,自动根据卡片尺寸(1x1, 2x1, 2x2)寻找最优空隙进行填充,实现无缝拼接

    • 主题响应:通过 Vue 的 useData() 实时监听博客的主题状态(Light/Dark)

      黑夜模式:强制切换为 #1d1d1d 实心深灰底色 + 高亮白光

      白天模式:切换为陶瓷白渐变底色 + 淡紫科技光

    • 物理交互:监听鼠标的 mousemove 事件,实时计算鼠标相对于卡片中心的位置,动态生成 3D 倾斜变换 (transform)光晕跟随 (radial-gradient) 效果

④最终挂载

  • 涉及文件:docs/04.Share/index.md
  • 操作内容:直接在文件中写入 <BentoGrid /> 组件标签
  • 结果:页面加载时,组件自动拉取脚本处理好的数据,渲染出动态的 3D 数字百宝箱

代码详情

数据抓取脚本

路径docs/.vitepress/theme/utils/share.data.mjs

javascript
import { createContentLoader } from 'vitepress'

// 扫描 04.Share 目录下所有的 .md 文件
export default createContentLoader('docs/04.Share/**/*.md', {
  transform(rawData) {
    const items = []
    
    // ✅ 定义允许显示的样式 (白名单)
    const validStyles = ['large', 'wide', 'normal']

    rawData.forEach((page) => {
      const fm = page.frontmatter
      
      // 1. 必须有 cardStyle 属性
      // 2. cardStyle 必须是 large, wide, normal 其中之一
      // 满足这俩条件,才会被收录到百宝箱
      if (fm.cardStyle && validStyles.includes(fm.cardStyle)) {
        items.push({
          title: fm.title || '未命名资源',
          link: page.url,
          icon: fm.icon || '📦',
          desc: fm.desc || '',
          cardStyle: fm.cardStyle, // 直接使用,因为已经过滤过了
          date: fm.date
        })
      }
    })

    // 按日期倒序
    items.sort((a, b) => new Date(b.date) - new Date(a.date))

    return items
  }
})

视图组件

路径docs/.vitepress/theme/components/BentoGrid.vue

vue
<script setup>
import { useRouter, useData } from 'vitepress'
import { data as shareData } from '../utils/share.data.mjs'

const router = useRouter()
const { isDark } = useData()

const handleMouseMove = (e) => {
  const card = e.currentTarget
  const rect = card.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  const centerX = rect.width / 2
  const centerY = rect.height / 2
  
  const rotateX = (centerY - y) / 12 
  const rotateY = (x - centerX) / 12

  card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.01, 1.01, 1.01)`
  
  const glow = card.querySelector('.glow')
  if (glow) {
    // 🔥 去掉了 mix-blend-mode,光晕实打实显示
    // 扩散范围保持 70%
    glow.style.background = `radial-gradient(circle at ${x}px ${y}px, var(--glow-color), transparent 70%)`
    glow.style.opacity = '1'
  }
}

const handleMouseLeave = (e) => {
  const card = e.currentTarget
  card.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)'
  const glow = card.querySelector('.glow')
  if (glow) glow.style.opacity = '0'
}
</script>

<template>
  <div class="bento-container" :class="{ 'is-dark': isDark }">
    <div v-if="shareData.length === 0" class="empty-tip">
      暂无内容,请在文章 Frontmatter 设置 cardStyle: normal/wide/large
    </div>

    <div class="grid-layout">
      <div 
        v-for="(item, index) in shareData" 
        :key="index"
        class="card"
        :class="item.cardStyle" 
        @mousemove="handleMouseMove"
        @mouseleave="handleMouseLeave"
        @click="router.go(item.link)"
      >
        <div class="glow"></div>
        
        <div class="content">
          <div class="icon-box">{{ item.icon }}</div>
          <h3 class="title">{{ item.title }}</h3>
          <p class="desc">{{ item.desc }}</p>
        </div>
        
        <div class="arrow">↗</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
/* --- 🎨 核心调色盘 --- */
.bento-container {
  width: 100%;
  padding: 10px 0;

  /* ☀️ 白天模式 */
  --bg-color: linear-gradient(145deg, #ffffff 0%, #f3f4f6 100%);
  --border-color: rgba(0, 0, 0, 0.06);
  --box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  --text-primary: #1f2937;
  --text-secondary: #6b7280;
  
  /* 🔥 白天光晕:显眼的紫色!(透明度 0.2,足够你看清) */
  --glow-color: rgba(139, 92, 246, 0.2); 
  
  --arrow-color: rgba(0,0,0,0.2);
  --hover-border: rgba(0,0,0,0.15);
}

/* 🌙 黑夜模式 */
.bento-container.is-dark {
  /* 背景:#1d1d1d */
  --bg-color: #1d1d1d;
  
  /* 🔥 黑夜光晕:纯白光!由于去掉了混合模式,0.15 就非常亮了 */
  /* 如果用 0.3 会像探照灯一样亮瞎眼,0.15 刚好还原 123.html 的质感 */
  --glow-color: rgba(255, 255, 255, 0.15);
  
  --border-color: rgba(255, 255, 255, 0.1);
  --box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
  --text-primary: #ffffff;
  --text-secondary: rgba(255, 255, 255, 0.6);
  --arrow-color: rgba(255,255,255,0.3);
  --hover-border: rgba(255,255,255,0.4);
}

.grid-layout {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  grid-auto-rows: 120px; 
  grid-auto-flow: dense; 
  gap: 16px;
}

/* --- 卡片本体 --- */
.card {
  position: relative;
  background: var(--bg-color);
  backdrop-filter: blur(10px);
  border: 1px solid var(--border-color);
  border-radius: 20px;
  cursor: pointer;
  transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
  transform-style: preserve-3d;
  overflow: hidden;
  box-shadow: var(--box-shadow);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  padding: 16px;
}

.card:hover {
  border-color: var(--hover-border);
  box-shadow: 0 20px 40px -5px rgba(0,0,0,0.4);
  z-index: 10; 
}

/* --- 内容层 --- */
.content {
  transform: translateZ(30px);
  pointer-events: none;
  width: 100%;
  z-index: 2;
}

.icon-box {
  font-size: 32px; 
  margin-bottom: 10px;
  filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
  transition: transform 0.3s;
}

.card:hover .icon-box {
  transform: scale(1.1) translateZ(10px);
}

.title {
  margin: 0;
  font-size: 15px; 
  font-weight: 700;
  color: var(--text-primary);
  line-height: 1.3;
  letter-spacing: 0.02em;
}

.desc {
  margin: 6px 0 0;
  font-size: 12px;
  color: var(--text-secondary);
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  font-weight: 400;
}

/* --- 尺寸适配 --- */
.card.wide { grid-column: span 2; }
.card.large { grid-column: span 2; grid-row: span 2; }
.card.large .icon-box { font-size: 48px; margin-bottom: 16px; }
.card.large .title { font-size: 20px; }
.card.large .desc { font-size: 13px; -webkit-line-clamp: 4; }
.card.wide .content { display: flex; align-items: center; justify-content: center; gap: 16px; text-align: left; }
.card.wide .icon-box { margin-bottom: 0; font-size: 36px; }
.card.wide .desc { -webkit-line-clamp: 1; }

/* --- ✨ 光晕层 (去掉了 mix-blend-mode) --- */
.glow {
  position: absolute; 
  inset: 0; 
  background: transparent; 
  opacity: 0;
  transition: opacity 0.4s ease; 
  z-index: 1; 
  pointer-events: none;
  /* ❌ 删掉了 mix-blend-mode: overlay */
  /* 现在光晕是实实在在的颜色层,绝对看得见 */
}

/* --- 箭头 --- */
.arrow {
  position: absolute; top: 14px; right: 14px; font-size: 12px;
  color: var(--arrow-color); transform: translateZ(10px);
  transition: all 0.3s; z-index: 2; opacity: 0.6;
}
.card:hover .arrow {
  opacity: 1; color: var(--text-primary);
  transform: translateZ(10px) translate(2px, -2px);
}

@media (max-width: 600px) {
  .grid-layout { grid-template-columns: repeat(2, 1fr); grid-auto-rows: 120px; }
  .card.wide { grid-column: span 2; }
  .card.large { grid-column: span 2; grid-row: span 2; }
}
</style>

注册组件

文件路径docs/.vitepress/theme/index.js

javascript
import BentoGrid from './components/BentoGrid.vue'

export default {
  enhanceApp({ app }) {
    // ... 其他组件
    app.component('BentoGrid', BentoGrid)
  }
}

挂载到页面: 打开 docs/04.Share/index.md,清空内容,只需要写:

# 💎 资源分享
📦 数字百宝箱

<BentoGrid />

重力技术匣子

我的需求

核心目标:彻底改造“技术雷达”页面,拒绝死板的图表展示,打造一个具有“物理实感”和“解压互动”的技术栈展示区

视觉风格

  • 容器:半透明的玻璃匣子,能透出页面的背景网格
  • 积木:根据熟练度(文章数量)自动决定大小的圆角矩形,带有霓虹边框
  • 适配:在手机端自动缩小积木体积,防止堆叠溢出;在电脑端保持大气

交互体验

  • 真实物理:积木受重力影响,可碰撞、堆叠
  • 鼠标/触控:支持抓取、拖拽、甩飞积木(解决了高分屏和移动端触控问题)
  • 防丢机制:增加了天花板防止飞出,增加了“虚空重置”防止穿模丢失
  • 重置功能:双击容器或点击标题,所有积木瞬间重置重生

功能概述

Matter.js 驱动:使用专业的 2D 物理引擎处理刚体碰撞和运动

智能尺寸计算

  • 宽度:根据标签文字长度动态计算 (tag.name.length)
  • 高度/字号:根据该标签下的文章数量动态放大 (tag.count)

全封闭边界:四周(上下左右)均有隐形墙壁,形成一个封闭的“摇摇乐”空间

越狱监控:实时监听物理世界,一旦发现有积木因速度过快穿墙(Bug),立即将其瞬移回中心,确保“一个都不能少”

主题自适应:监听 VitePress 的深色/浅色模式,自动切换文字颜色(黑/白)


实现流程

依赖安装:安装 matter-js 物理引擎

数据准备:复用现有的 radar.data.mjs,获取标签名称和统计数量

组件开发 (TechGravity.vue)

  • 初始化物理引擎 (Engine, World, Render)
  • 创建边界(墙壁、地面、天花板)
  • 遍历数据生成刚体 (Bodies.rectangle),根据设备类型 (isMobile) 调整物理参数
  • 修复交互 Bug(Retina 屏坐标修正、触摸事件冲突)
  • 利用 Canvas API 在物理刚体上实时绘制文字

注册与挂载:全局注册组件并替换首页旧内容


代码详情

第一步:安装依赖

请在项目根目录下运行终端命令:

bash
npm install matter-js

第二步:准备数据抓取脚本

我们要写一个脚本,扫描 01.Coding 目录下所有的文章,统计 Tags(标签) 的出现频率

请在 docs/.vitepress/theme/utils/ 下新建 radar.data.mjs

javascript
import { createContentLoader } from 'vitepress'

export default createContentLoader('docs/01.Coding/**/*.md', {
  transform(rawData) {
    const tagMap = {}

    rawData.forEach((page) => {
      if (page.url.endsWith('/')) return
      const fm = page.frontmatter
      if (fm.tags) {
        const tags = Array.isArray(fm.tags) ? fm.tags : [fm.tags]
        tags.forEach(tag => {
          if (!tagMap[tag]) tagMap[tag] = { count: 0 }
          tagMap[tag].count++
        })
      }
    })

    const tagsArray = Object.keys(tagMap).map(tag => ({
      name: tag,
      count: tagMap[tag].count
    }))

    // 按数量倒序,取前 20 个,防止积木太多卡顿
    tagsArray.sort((a, b) => b.count - a.count)
    return tagsArray.slice(0, 20)
  }
})

第三步:封装 Vue 组件

docs/.vitepress/theme/components/ 下新建 TechGravity.vue

vue
<script setup>
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { useData } from 'vitepress'
import Matter from 'matter-js'
import { data as rawTags } from '../utils/radar.data.mjs'

const { isDark } = useData()
const containerRef = ref(null)

// 全局实例变量
let engine = null
let render = null
let runner = null

// 🎨 核心:缤纷色板系统 (6色循环)
// index: 当前是第几个积木
// dark: 是否黑夜模式
// isMobile: 是否移动端
const getColors = (index, dark, isMobile) => {
  const strokeWidth = isMobile ? (dark ? 1 : 2) : (dark ? 2 : 3)
  
  // 🌙 黑夜模式:赛博霓虹 (Cyberpunk Neon) - 6色扩充
  const darkPalette = [
    { fill: 'rgba(50, 255, 100, 0.15)', stroke: '#32ff64', text: '#fff', glow: '#32ff64' }, // 绿
    { fill: 'rgba(0, 242, 255, 0.15)', stroke: '#00f2ff', text: '#fff', glow: '#00f2ff' }, // 蓝
    { fill: 'rgba(255, 170, 0, 0.15)', stroke: '#ffaa00', text: '#fff', glow: '#ffaa00' }, // 橙
    { fill: 'rgba(255, 0, 200, 0.15)', stroke: '#ff00c8', text: '#fff', glow: '#ff00c8' }, // 粉
    { fill: 'rgba(160, 100, 255, 0.15)', stroke: '#a064ff', text: '#fff', glow: '#a064ff' }, // 紫
    { fill: 'rgba(255, 50, 50, 0.15)',  stroke: '#ff3232', text: '#fff', glow: '#ff3232' }  // 红
  ]

  // ☀️ 白天模式:工业贴纸 (Industrial Stickers) - 6色扩充
  // 统一用深色边框和文字
  const lightStroke = '#1a1a1a'
  const lightText = '#000000'
  const lightPalette = [
    { fill: '#facc15', stroke: lightStroke, text: lightText, glow: null }, // 警示黄
    { fill: '#ffffff', stroke: lightStroke, text: lightText, glow: null }, // 纯净白
    { fill: '#e5e7eb', stroke: lightStroke, text: lightText, glow: null }, // 混凝土灰
    { fill: '#60a5fa', stroke: lightStroke, text: lightText, glow: null }, // 图纸蓝
    { fill: '#4ade80', stroke: lightStroke, text: lightText, glow: null }, // 环保绿
    { fill: '#fb7185', stroke: lightStroke, text: lightText, glow: null }  // 警示红
  ]

  // 根据索引取模,循环分配颜色
  const palette = dark ? darkPalette : lightPalette
  const color = palette[index % palette.length]

  return {
    ...color,
    width: strokeWidth
  }
}

const initPhysics = () => {
  if (!containerRef.value) return

  // 1. 尺寸识别
  const width = containerRef.value.clientWidth
  const isMobile = window.innerWidth < 640
  const height = isMobile ? 450 : 500
  const maxCount = rawTags.length > 0 ? rawTags[0].count : 1

  // 2. 创建引擎
  engine = Matter.Engine.create()
  const world = engine.world
  
  // 3. 创建渲染器
  render = Matter.Render.create({
    element: containerRef.value,
    engine: engine,
    options: {
      width,
      height,
      background: 'transparent',
      wireframes: false,
      pixelRatio: window.devicePixelRatio || 1
    }
  })

  // 4. 创建全封闭边界
  const wallThick = 100 
  const wallOptions = { isStatic: true, render: { visible: false } } 
  
  const ground = Matter.Bodies.rectangle(width / 2, height + wallThick/2, width + 500, wallThick, wallOptions)
  const leftWall = Matter.Bodies.rectangle(0 - wallThick/2, height / 2, wallThick, height * 4, wallOptions)
  const rightWall = Matter.Bodies.rectangle(width + wallThick/2, height / 2, wallThick, height * 4, wallOptions)
  const ceiling = Matter.Bodies.rectangle(width / 2, -height * 1.5, width + 500, wallThick, wallOptions)
  
  Matter.World.add(world, [ground, leftWall, rightWall, ceiling])

  // 5. 生成方块
  const bodies = rawTags.map((tag, index) => { // 👈 拿到 index
    const baseSize = isMobile ? 12 : 16
    const sizeFactor = (tag.count / maxCount) * (isMobile ? 6 : 10)
    const fontSize = baseSize + sizeFactor
    
    const charWidth = isMobile ? 0.6 : 0.7
    const boxWidth = (tag.name.length * fontSize * charWidth) + (isMobile ? 20 : 30)
    const boxHeight = fontSize * 2 + (isMobile ? 4 : 10)
    
    // 随机位置
    const x = Math.random() * (width / 2) + (width / 4)
    const y = Math.random() * -500 - 100

    // 🔥 传入 index 获取缤纷颜色
    const colors = getColors(index, isDark.value, isMobile)

    return Matter.Bodies.rectangle(x, y, boxWidth, boxHeight, {
      chamfer: { radius: isMobile ? 4 : 6 },
      restitution: 0.5,
      friction: 0.1,    
      density: 0.01,
      render: {
        fillStyle: colors.fill,
        strokeStyle: colors.stroke,
        lineWidth: colors.width
      },
      plugin: {
        text: tag.name,
        textColor: colors.text,
        glowColor: colors.glow,
        fontSize: fontSize
      }
    })
  })

  Matter.World.add(world, bodies)

  // 6. 交互
  const mouse = Matter.Mouse.create(render.canvas)
  mouse.pixelRatio = window.devicePixelRatio || 1; 

  const mouseConstraint = Matter.MouseConstraint.create(engine, {
    mouse: mouse,
    constraint: { stiffness: 0.2, render: { visible: false } }
  })
  
  mouse.element.removeEventListener("mousewheel", mouse.mousewheel)
  mouse.element.removeEventListener("DOMMouseScroll", mouse.mousewheel)
  Matter.World.add(world, mouseConstraint)

  // 7. 防丢监控
  Matter.Events.on(engine, 'beforeUpdate', () => {
    bodies.forEach(body => {
      if (
        body.position.y > height + 200 || 
        body.position.y < -height * 2 ||
        body.position.x < -200 || 
        body.position.x > width + 200
      ) {
        Matter.Body.setPosition(body, {
          x: Math.random() * (width - 100) + 50,
          y: -100
        })
        Matter.Body.setVelocity(body, { x: 0, y: 0 })
        Matter.Body.setAngularVelocity(body, 0)
      }
    })
  })

  // 8. 文字渲染
  Matter.Events.on(render, 'afterRender', () => {
    const context = render.context
    
    bodies.forEach(body => {
      if (body.position.y < -height * 2 || body.position.y > height + 200) return

      const { position, angle } = body
      const { text, textColor, fontSize, glowColor } = body.plugin
      
      context.save()
      context.translate(position.x, position.y)
      context.rotate(angle)
      
      context.font = `bold ${fontSize}px "Menlo", "Monaco", monospace`
      context.textAlign = 'center'
      context.textBaseline = 'middle'
      
      if (glowColor) {
        context.shadowColor = glowColor
        context.shadowBlur = isMobile ? 0 : 8
      } else {
        context.shadowBlur = 0
      }
      
      context.fillStyle = textColor
      context.fillText(text, 0, 0)
      
      context.restore()
    })
  })

  runner = Matter.Runner.create()
  Matter.Runner.run(runner, engine)
  Matter.Render.run(render)
}

const reload = () => {
  if (render) {
    Matter.Render.stop(render)
    if (render.canvas) render.canvas.remove()
    render.canvas = null
    render.context = null
    render.textures = {}
  }
  if (runner) {
    Matter.Runner.stop(runner)
    runner = null
  }
  if (engine) {
    Matter.World.clear(engine.world)
    Matter.Engine.clear(engine)
    engine = null
  }
  initPhysics()
}

const handleReset = () => { reload() }

onMounted(() => {
  setTimeout(initPhysics, 200)
  window.addEventListener('resize', reload)
})

onUnmounted(() => {
  window.removeEventListener('resize', reload)
  if (render) {
    Matter.Render.stop(render)
    if (render.canvas) render.canvas.remove()
  }
  if (runner) {
    Matter.Runner.stop(runner)
  }
})

watch(() => isDark.value, () => {
  nextTick(() => {
    reload()
  })
})
</script>

<template>
  <div class="gravity-wrapper">
    <div class="header" @click="handleReset" title="Click to Respawn!">
      <h2>🛠️ TECH STACK</h2>
      <p>Drag, Drop, and Crash! (Double click box to reset)</p>
    </div>
    
    <div ref="containerRef" class="physics-box" @dblclick="handleReset"></div>
  </div>
</template>

<style scoped>
.gravity-wrapper {
  width: 100%;
  position: relative;
  margin: 20px 0;
  padding: 0 10px;
}

.header {
  text-align: center;
  margin-bottom: 20px;
  cursor: pointer;
  user-select: none;
}

.header h2 {
  font-size: 2rem;
  font-weight: 900;
  margin: 0;
  background: -webkit-linear-gradient(120deg, #bd34fe 30%, #41d1ff);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  letter-spacing: 2px;
}

.header p {
  color: var(--vp-c-text-2);
  font-size: 0.9rem;
  font-family: monospace;
}

.physics-box {
  width: 100%;
  height: 500px;
  background: var(--vp-c-bg-soft);
  border: 1px solid var(--vp-c-divider);
  border-radius: 12px;
  overflow: hidden;
  position: relative;
  touch-action: none; 
  cursor: grab;
  box-shadow: inset 0 0 20px rgba(0,0,0,0.02);
}

.physics-box:active {
  cursor: grabbing;
}

@media (max-width: 640px) {
  .physics-box { height: 450px; }
  .header h2 { font-size: 1.5rem; }
}
</style>

第四步:注册并使用

  1. 注册组件: 确保在 docs/.vitepress/theme/index.js 中注册了这个新组件:

    javascript
    import TechGravity from './components/TechGravity.vue'
    // ... 其他组件引入
    
    export default {
      // ...
      enhanceApp({ app }) {
        // ... 其他组件注册
        app.component('TechGravity', TechGravity)
      }
    }
  2. 挂载到页面: 打开 docs/01.Coding/index.md,清空或添加以下内容:

    # 💻 技术博客
    
    <TechGravity />

如何让它动起来?

现在你只需要去 01.Coding 下的随便几篇文章里,确保 Frontmatter 有 tags 字段:

YAML
---
title: Python 爬虫实战
date: xxx
update_logs: [xx, yy]
tags: [Python, 爬虫]
---

写得越多,那个 Tag 在雷达上就越靠近圆心,颜色越绿(Core)

纪念碑谷·奇迹之城

我的需求

核心目标:在博客的 03.English 首页,通过一个精美的 3D 组件展示我的英语学习进度

视觉风格:致敬《纪念碑谷》,采用等轴测 (Isometric) 视角,莫兰迪配色,风格要唯美、精致、高级

自动化:不要手动填数据。脚本自动扫描 03.English 目录下的子文件夹(如 NCE, Listening 等),统计文章数量

视觉通货膨胀:解决“冷启动”难看的问题。即使只有几篇文章,也要生成完整的、带基座和特效的建筑,而不是光秃秃的方块。文章多了之后,建筑要变得更加宏伟、拥挤

交互与适配:支持鼠标旋转查看,手机端自动调整视野不切边。UI 界面(数据面板、说明书)不遮挡 3D 主体


功能概述

浮空岛群落:每个子目录自动生成一座独立的浮空岛,拥有随机的高低基座和浮动动画

动态建筑系统:根据文件夹顺序循环分配 4 种建筑风格:

  • 魔法尖塔 (The Spire):带螺旋结构和顶端水晶
  • 星门 (The Gate):带发光光幕和台阶
  • 图书馆 (The Library):书架与散落的书籍堆叠
  • 悬空花园 (The Garden):错落的台阶与几何树木

细节增强:所有建筑周围环绕着漂浮的魔法粒子(Particles),随文章数量增加,特效更明显

仪表盘 UI:底部独立显示各板块等级(Lv.)和进度条,右上角提供带呼吸灯效的“说明书”弹窗


实现流程

数据层:编写 monument.data.mjs,利用 VitePress 的 createContentLoader 扫描指定目录,返回 { 目录名: 文章数 } 的 JSON 数据。

组件层:编写 EnglishMonument.vue

  • 使用 Three.js 进行渲染
  • 使用 OrthographicCamera (正交相机) 实现等轴测视角
  • 编写多个 Builder 函数,根据文章数量动态堆叠几何体(BoxGeometry)
  • 添加 OrbitControls 实现交互,添加 ResizeObserver 实现响应式适配

应用层:在 theme/index.js 注册组件,并在 index.md 中使用 <ClientOnly> 包裹挂载


代码详情

第一步:准备依赖

确保你的项目里安装了 three。如果之前没装过,请运行:

bash
npm install three

第二步:编写数据抓取脚本

我们需要一个“侦察兵”,去扫描你的文件夹,看看你到底学了多少

创建文件:docs/.vitepress/theme/utils/monument.data.mjs

javascript
import { createContentLoader } from 'vitepress'

// 扫描 03.English 下所有的 markdown 文件
export default createContentLoader('docs/03.English/**/*.md', {
  transform(rawData) {
    // 动态存储结构: { "NCE": 45, "Speaking": 12, "Netflix": 5 ... }
    const categories = {}

    rawData.forEach(page => {
      // 忽略 index.md 本身
      if (page.url.endsWith('/')) return

      // 解析 URL 路径
      // page.url 格式通常是 /03.English/SubDir/ArticleName.html
      const parts = page.url.split('/')
      
      // 找到 "03.English" 的位置
      const dirIndex = parts.indexOf('03.English')
      
      // 取 "03.English" 后面的那一级目录名作为分类
      // 例如:/03.English/NCE/L1.html -> 目录是 NCE
      if (dirIndex !== -1 && parts[dirIndex + 1]) {
        const folderName = parts[dirIndex + 1]
        
        // 排除掉没有子目录直接放在根目录的文件,或者其他干扰项
        if (!folderName.endsWith('.html')) {
           if (!categories[folderName]) {
             categories[folderName] = 0
           }
           categories[folderName]++
        }
      }
    })

    return categories
  }
})

第三步:封装 Vue 组件

这是重头戏。我将之前的 Demo 逻辑移植到了 Vue 中,并加上了动态生成逻辑

创建文件:docs/.vitepress/theme/components/EnglishMonument.vue

vue
<script setup>
import { onMounted, ref, onBeforeUnmount, computed, nextTick } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { data as categoryData } from '../utils/monument.data.mjs'

const containerRef = ref(null)
let scene, camera, renderer, controls, animationId

// 数据转换
const folderList = computed(() => {
    if (!categoryData) return []
    return Object.entries(categoryData).map(([name, count]) => ({ name, count }))
})

// 配色方案:高饱和度魔法风
const colors = {
    bg: 0xF7F4EB,
    baseDark: 0x3F51B5,  // 深蓝基座侧面
    baseLight: 0x7986CB, // 浅蓝基座表面
    
    styles: [
        { main: 0xFF5252, detail: 0xFFCDD2, accent: 0xB71C1C }, // 绯红
        { main: 0x009688, detail: 0xB2DFDB, accent: 0x004D40 }, // 青绿
        { main: 0x2196F3, detail: 0xBBDEFB, accent: 0x0D47A1 }, // 湛蓝
        { main: 0x9C27B0, detail: 0xE1BEE7, accent: 0x4A148C }, // 魅紫
        { main: 0xFF9800, detail: 0xFFE0B2, accent: 0xE65100 }, // 暖橙
        { main: 0x795548, detail: 0xD7CCC8, accent: 0x3E2723 }, // 棕褐
    ]
}

// --- 🧱 几何工具 ---
function createBox(w, h, d, color, x, y, z) {
    const geo = new THREE.BoxGeometry(w, h, d)
    const mat = new THREE.MeshStandardMaterial({ color: color, roughness: 0.8, flatShading: true })
    const mesh = new THREE.Mesh(geo, mat)
    mesh.position.set(x, y, z)
    mesh.castShadow = true
    mesh.receiveShadow = true
    return mesh
}

// 粒子生成器
function createParticles(color) {
    const group = new THREE.Group()
    for(let i=0; i<6; i++) { // 每个建筑配6个悬浮粒子
        const s = 0.15 + Math.random() * 0.15
        const mesh = new THREE.Mesh(new THREE.OctahedronGeometry(s), new THREE.MeshBasicMaterial({color: color}))
        // 随机分布在建筑周围
        const r = 2.5 + Math.random() * 1.5
        const theta = Math.random() * Math.PI * 2
        mesh.position.set(Math.cos(theta)*r, 1 + Math.random() * 3, Math.sin(theta)*r)
        
        // 动画数据
        mesh.userData = { 
            isParticle: true,
            speed: 0.5 + Math.random() * 0.5, 
            yOffset: Math.random() * 100,
        }
        group.add(mesh)
    }
    return group
}

// --- 🏰 建筑策略库 ---
const builders = [
    // 0. The Spire (尖塔)
    (count, color) => {
        const group = new THREE.Group()
        const levels = 3 + Math.floor(count / 4)
        for(let i=0; i<levels; i++) {
            const w = 2.0 - (i * 0.15)
            const c = i % 2 === 0 ? color.main : color.detail
            const b = createBox(w, 0.8, w, c, 0, 0.4 + i*0.8, 0)
            b.rotation.y = i * 0.2
            group.add(b)
        }
        const gem = new THREE.Mesh(new THREE.OctahedronGeometry(0.6), new THREE.MeshStandardMaterial({color: color.accent, emissive: color.accent, emissiveIntensity: 0.5}))
        gem.position.y = levels * 0.8 + 0.8
        group.add(gem)
        group.add(createParticles(color.accent))
        return group
    },
    // 1. The Gate (星门)
    (count, color) => {
        const group = new THREE.Group()
        const h = 4
        group.add(createBox(0.8, h, 0.8, color.main, -1.2, h/2, 0))
        group.add(createBox(0.8, h, 0.8, color.main, 1.2, h/2, 0))
        group.add(createBox(4, 0.8, 1, color.main, 0, h + 0.4, 0))
        const opacity = Math.min(0.2 + count * 0.05, 0.9)
        const portal = new THREE.Mesh(new THREE.PlaneGeometry(2, 3.5), new THREE.MeshBasicMaterial({color: color.detail, side: THREE.DoubleSide, transparent:true, opacity: opacity}))
        portal.position.set(0, 1.8, 0)
        group.add(portal)
        group.add(createBox(2, 0.2, 1, color.detail, 0, 0.1, 1))
        group.add(createParticles(color.detail))
        return group
    },
    // 2. The Library (书库)
    (count, color) => {
        const group = new THREE.Group()
        group.add(createBox(3, 0.5, 2, color.main, 0, 0.25, 0))
        group.add(createBox(2, 3, 0.5, color.main, 0, 1.5, -0.5))
        const books = 5 + Math.min(count, 15)
        for(let i=0; i<books; i++) {
            const bx = (Math.random()-0.5) * 1.5
            const by = 0.5 + Math.random() * 2
            const b = createBox(0.4, 0.15, 0.5, Math.random()>0.5?color.detail:color.accent, bx, by, -0.4)
            b.rotation.y = (Math.random()-0.5) * 0.5
            group.add(b)
        }
        group.add(createParticles(color.accent))
        return group
    },
    // 3. The Garden (悬空花园)
    (count, color) => {
        const group = new THREE.Group()
        group.add(createBox(3, 1, 3, color.detail, 0, 0.5, 0))
        group.add(createBox(1.5, 1.5, 1.5, color.main, 0.5, 1.25, 0.5))
        const trees = 3 + Math.floor(count/3)
        for(let i=0; i<trees; i++) {
            const t = new THREE.Group()
            t.position.set((Math.random()-0.5)*2.5, 1, (Math.random()-0.5)*2.5)
            const trunk = createBox(0.1, 0.5, 0.1, 0x5D4037, 0, 0.25, 0)
            const leaves = new THREE.Mesh(new THREE.DodecahedronGeometry(0.3), new THREE.MeshStandardMaterial({color: color.accent}))
            leaves.position.y = 0.6
            t.add(trunk, leaves)
            group.add(t)
        }
        return group
    }
]

onMounted(async () => {
    await nextTick()
    if (!containerRef.value) return
    
    const container = containerRef.value
    container.style.height = "500px"
    const width = container.clientWidth
    const height = 500

    scene = new THREE.Scene()
    scene.fog = new THREE.Fog(colors.bg, 25, 60)

    const aspect = width / height
    const viewSize = 24 
    camera = new THREE.OrthographicCamera(-viewSize * aspect / 2, viewSize * aspect / 2, viewSize / 2, -viewSize / 2, 1, 1000)
    camera.position.set(20, 20, 20)
    camera.lookAt(0, 0, 0)

    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
    renderer.setSize(width, height)
    renderer.shadowMap.enabled = true
    renderer.shadowMap.type = THREE.PCFSoftShadowMap
    container.appendChild(renderer.domElement)

    const ambi = new THREE.AmbientLight(0xffffff, 0.8)
    scene.add(ambi)
    const dir = new THREE.DirectionalLight(0xffffff, 0.8)
    dir.position.set(10, 30, 20)
    dir.castShadow = true
    dir.shadow.mapSize.width = 2048; dir.shadow.mapSize.height = 2048
    dir.shadow.camera.left = -20; dir.shadow.camera.right = 20
    dir.shadow.camera.top = 20; dir.shadow.camera.bottom = -20
    scene.add(dir)

    const world = new THREE.Group()
    const folders = folderList.value
    const cellSize = 5.5 
    const positions = [
        {x: 0, z: 0}, {x: -1, z: 0}, {x: 1, z: 0}, 
        {x: 0, z: -1}, {x: -1, z: -1}, {x: 1, z: -1},
        {x: 0, z: 1}, {x: -1, z: 1}, {x: 1, z: 1}
    ]

    folders.forEach((folder, index) => {
        if (index >= positions.length) return 
        try {
            const pos = positions[index]
            const realX = pos.x * cellSize
            const realZ = pos.z * cellSize
            const tileGroup = new THREE.Group()
            tileGroup.position.set(realX, 0, realZ)
            const baseHeight = 1.5 + Math.random() * 2
            
            tileGroup.add(createBox(4.5, baseHeight, 4.5, colors.baseDark, 0, baseHeight/2 - 2, 0))
            tileGroup.add(createBox(4.5, 0.2, 4.5, colors.baseLight, 0, baseHeight - 2 + 0.1, 0))
            
            const style = colors.styles[index % colors.styles.length]
            const builder = builders[index % builders.length]
            const building = builder(folder.count, style)
            building.position.y = baseHeight - 2 + 0.2 
            tileGroup.add(building)
            
            tileGroup.userData = { isIsland: true, phase: Math.random() * Math.PI * 2, speed: 0.5 + Math.random() * 0.5 }
            world.add(tileGroup)
        } catch (e) { console.error("Error", e) }
    })
    scene.add(world)

    controls = new OrbitControls(camera, renderer.domElement)
    controls.enableDamping = true; controls.enableZoom = false
    controls.autoRotate = true; controls.autoRotateSpeed = 0.8
    controls.minPolarAngle = Math.PI / 4; controls.maxPolarAngle = Math.PI / 2.2
    controls.target.set(0, 1, 0)

    const animate = () => {
        animationId = requestAnimationFrame(animate)
        const time = Date.now() * 0.001
        world.children.forEach(child => { if (child.userData.isIsland) child.position.y = Math.sin(time * child.userData.speed + child.userData.phase) * 0.15 })
        scene.traverse(obj => { if (obj.userData && obj.userData.isParticle) { obj.rotation.y += 0.05; obj.position.y += Math.sin(time * 2 + obj.userData.yOffset) * 0.005 } })
        controls.update()
        renderer.render(scene, camera)
    }
    animate()

    const handleResize = () => {
        if (!container) return
        const w = container.clientWidth
        const h = 500
        renderer.setSize(w, h)
        const a = w / h
        const newViewSize = w < 600 ? 32 : 22
        camera.left = -newViewSize * a / 2; camera.right = newViewSize * a / 2
        camera.top = newViewSize / 2; camera.bottom = -newViewSize / 2
        camera.updateProjectionMatrix()
    }
    window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
    cancelAnimationFrame(animationId)
    if (renderer) renderer.dispose()
})
</script>

<template>
  <div class="monument-container">
    <div ref="containerRef" class="canvas-wrapper">
        <div class="loading-tip" v-if="!folderList.length">Loading World...</div>
    </div>
    
    <div class="info-icon">
        <span>?</span>
        <div class="tooltip">
            <div class="tooltip-header">🏰 奇迹之城说明书</div>
            <div class="tooltip-content">
                <div class="tooltip-item">
                    <span class="icon">📁</span>
                    <div class="text"><b>地块生成</b><br>每个文件夹自动生成一座浮空岛</div>
                </div>
                <div class="tooltip-item">
                    <span class="icon">🏗️</span>
                    <div class="text"><b>建筑进化</b><br>文章越多,塔楼越高,结构越复杂</div>
                </div>
                <div class="tooltip-item">
                    <span class="icon">✨</span>
                    <div class="text"><b>魔法特效</b><br>积累知识触发粒子光效与水晶</div>
                </div>
            </div>
        </div>
    </div>

    <div class="dashboard">
        <div class="dash-header">
            <h3>English Archive</h3>
            <span class="total-badge">{{ folderList.reduce((a,b)=>a+b.count,0) }} Articles</span>
        </div>
        <div class="dash-grid">
            <div v-for="(item, index) in folderList" :key="item.name" class="dash-item">
                <div class="color-dot" :style="{ backgroundColor: '#' + colors.styles[index % colors.styles.length].main.toString(16) }"></div>
                <div class="info">
                    <span class="name">{{ item.name }}</span>
                    <span class="count">Lv.{{ Math.floor(item.count/3) + 1 }}</span>
                </div>
            </div>
        </div>
    </div>
  </div>
</template>

<style scoped>
.monument-container {
    position: relative; display: flex; flex-direction: column;
    background: #fff; border-radius: 16px; margin: 2rem 0;
    box-shadow: 0 10px 40px rgba(0,0,0,0.08); border: 1px solid rgba(0,0,0,0.05); overflow: visible;
}

.canvas-wrapper {
    width: 100%; height: 500px;
    background: radial-gradient(circle at center, #FFFDF5 0%, #F0F4F8 100%);
    position: relative; cursor: grab; border-radius: 16px 16px 0 0;
}
.canvas-wrapper:active { cursor: grabbing; }

/* --- ✨ Tooltip 样式重写 --- */
.info-icon {
    position: absolute; top: 20px; right: 20px; width: 32px; height: 32px;
    background: rgba(255,255,255,0.9); border-radius: 50%; 
    display: flex; align-items: center; justify-content: center;
    cursor: help; color: #666; font-weight: bold; font-size: 16px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 10;
    transition: transform 0.2s;
}
.info-icon:hover { transform: scale(1.1); color: #333; }

.tooltip {
    position: absolute; top: 45px; right: 0; width: 260px;
    background: rgba(255,255,255,0.98); backdrop-filter: blur(12px);
    border-radius: 12px; box-shadow: 0 15px 50px rgba(0,0,0,0.2);
    opacity: 0; visibility: hidden; transform: translateY(-8px) scale(0.95);
    transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
    pointer-events: none; z-index: 20;
    border: 1px solid rgba(0,0,0,0.05); overflow: hidden;
}
.info-icon:hover .tooltip { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }

.tooltip-header {
    background: linear-gradient(to right, #f8f9fa, #fff);
    padding: 12px 16px; font-weight: 700; color: #333; font-size: 14px;
    border-bottom: 1px solid #eee;
}

.tooltip-content { padding: 12px 16px; }

.tooltip-item {
    display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px;
}
.tooltip-item:last-child { margin-bottom: 0; }
.tooltip-item .icon { font-size: 18px; line-height: 1.2; }
.tooltip-item .text { font-size: 12px; color: #666; line-height: 1.4; }
.tooltip-item b { color: #333; display: block; margin-bottom: 2px; }

/* Dashboard */
.dashboard { padding: 16px 20px; background: #fff; border-top: 1px solid #f0f0f0; border-radius: 0 0 16px 16px; }
.dash-header { display: flex; justify-content: space-between; margin-bottom: 12px; align-items: center; }
.dash-header h3 { margin: 0; font-size: 15px; color: #333; }
.total-badge { background: #E3F2FD; color: #1565C0; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; }

.dash-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
.dash-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: #fafafa; border-radius: 6px; border: 1px solid transparent; }
.dash-item:hover { background: #fff; border-color: #ddd; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.color-dot { width: 12px; height: 12px; border-radius: 3px; }
.info { display: flex; flex-direction: column; }
.name { font-size: 12px; font-weight: 600; color: #444; }
.count { font-size: 10px; color: #999; }

:root.dark .monument-container { background: #1e1e1e; border-color: #333; }
:root.dark .canvas-wrapper { background: radial-gradient(circle at center, #2c3e50 0%, #111 100%); }
:root.dark .dashboard { background: #1e1e1e; border-top-color: #333; }
:root.dark .dash-header h3 { color: #eee; }
:root.dark .dash-item { background: #2a2a2a; }
:root.dark .name { color: #ccc; }
:root.dark .tooltip { background: #222; border-color: #444; color: #ccc; }
:root.dark .tooltip-header { background: #2a2a2a; color: #fff; border-bottom-color: #444; }
:root.dark .tooltip-item b { color: #ddd; }
:root.dark .tooltip-item .text { color: #aaa; }
</style>

第四步:注册组件

打开 docs/.vitepress/theme/index.js,注册它:

javascript
import EnglishMonument from './components/EnglishMonument.vue'

export default {
  // ...
  enhanceApp({ app }) {
    app.component('EnglishMonument', EnglishMonument)
  }
}

第五步:挂载到首页

打开 docs/03.English/index.md,放入组件:

Markdown

# 🔤 英语进阶

🏰 Knowledge Monument

<EnglishMonument />

创作热力图

我的需求

核心目标:在博客中通过可视化的方式展示过去一年的创作轨迹(仿 GitHub Contribution Graph)

交互体验

  • 电脑端:支持鼠标悬停查看简略信息,点击格子锁定弹窗查看文章列表;支持按住鼠标拖拽左右滑动图表(解决无触控板无法横向滚动的问题)
  • 移动端:支持手指点击格子弹出详情卡片,点击空白处关闭

视觉风格:简约精致,支持暗黑模式适配。强制白天模式下空格子为浅灰色,防止在白色背景下不可见

功能扩展:除了热力图,还需要展示:

  • 核心指标:最长连更天数 (Streak)、活跃天数、总文章数
  • 知识构成:各分类(目录)的文章占比进度条
  • 最近动态:以垂直时间轴形式展示最近更新的文章

功能概述

全自动数据流:利用 VitePress 的 data loader 自动扫描全站 Markdown 文件,提取日期、标题、分类

交互式热力图

  • 365 天滚动窗口机制
  • 智能 Tooltip/Popover 系统:支持锁定状态,可在弹窗中直接点击跳转文章
  • Drag-to-Scroll:实现了类似地图软件的“抓手”拖拽滚动效果

多维数据看板

  • Stats Card:顶部三个卡片展示量化数据
  • Knowledge Base:左下角展示“我到底写了什么类型的文章”
  • Recent Signals:右下角展示“博客是不是还活着”

实现流程

数据层 (activity.data.mjs)

  • 编写 createContentLoader 脚本
  • 清洗路径(去除 /docs/ 和数字前缀)
  • 按日期归档文章详情(不仅仅是数量,还包含标题和链接)
  • 倒推生成过去 365 天的完整数组(补齐空缺日期为 0)

组件层 (ActivityHeatmap.vue)

  • 使用 CSS GridFlexbox 绘制 52列 x 7行 的网格
  • 编写 getLevel 函数根据文章数映射 GitHub 风格颜色
  • 实现 混合交互逻辑:区分 Hover(仅桌面)和 Click(通用锁定)
  • 实现 拖拽逻辑:监听 mousedown, mousemove, mouseup 实现容器的滚动

应用层

  • theme/index.js 中注册组件
  • 在 Markdown 文件中调用

代码详情

数据加载脚本

文件路径:docs/.vitepress/theme/utils/activity.data.mjs

javascript
import { createContentLoader } from 'vitepress'

export default createContentLoader('docs/**/*.md', {
  transform(rawData) {
    // 1. 准备容器:按日期归档文章详情
    // 结构: { "2025-01-01": [ { title: "文章A", url: "..." }, ... ] }
    const postsByDate = {}
    const categoryCount = {}
    const recentPosts = []

    rawData.forEach(page => {
      // 获取时间
      let dateObj = null
      if (page.frontmatter.date) {
        dateObj = new Date(page.frontmatter.date)
      } else if (page.lastUpdated) {
        dateObj = new Date(page.lastUpdated)
      }

      if (dateObj) {
        const key = dateObj.toISOString().split('T')[0]
        
        // --- 🔥 关键修改:存储文章详情 ---
        if (!postsByDate[key]) postsByDate[key] = []
        postsByDate[key].push({
          title: page.frontmatter.title || page.url.split('/').pop().replace('.md', ''),
          url: page.url
        })

        // 收集最近文章
        recentPosts.push({
          title: page.frontmatter.title || page.url.split('/').pop().replace('.md', ''),
          url: page.url,
          date: key,
          dateShort: key.slice(5),
          timestamp: dateObj.getTime()
        })
      }

      // 处理分类 (English/Java...)
      const cleanUrl = page.url.replace(/^\/docs\//, '').replace(/^\//, '')
      const parts = cleanUrl.split('/')
      if (parts.length > 1) {
        let rawCat = parts[0]
        if (rawCat.endsWith('.html')) rawCat = 'General'
        const category = rawCat.replace(/^\d+\./, '')
        if (!categoryCount[category]) categoryCount[category] = 0
        categoryCount[category]++
      } else {
        if (!categoryCount['General']) categoryCount['General'] = 0
        categoryCount['General']++
      }
    })

    // 2. 补全 365 天历史数据
    const history = []
    const today = new Date()
    for (let i = 364; i >= 0; i--) {
      const d = new Date(today)
      d.setDate(d.getDate() - i)
      const dateKey = d.toISOString().split('T')[0]
      
      const dayPosts = postsByDate[dateKey] || []
      
      history.push({
        date: dateKey,
        count: dayPosts.length, // 直接用数组长度
        posts: dayPosts         // 🔥 把文章列表带上前台
      })
    }

    // 3. 其他数据排序
    recentPosts.sort((a, b) => b.timestamp - a.timestamp)
    const topRecent = recentPosts.slice(0, 6)

    const categories = Object.entries(categoryCount)
      .map(([name, count]) => ({ 
          name, 
          count, 
          percent: Math.round((count / rawData.length) * 100) 
      }))
      .sort((a, b) => b.count - a.count)
      .slice(0, 6)

    return { history, recent: topRecent, categories }
  }
})

Vue 组件 (核心逻辑)

文件路径:docs/.vitepress/theme/components/ActivityHeatmap.vue

vue
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { data as activityData } from '../utils/activity.data.mjs'

// --- 1. 数据初始化 (改为响应式,以便动态修补) ---
// 复制一份原始数据,避免直接修改导入的静态对象
const historyList = ref([...activityData.history])
const { recent, categories } = activityData

// --- 2. 自动补齐日期逻辑 (核心修复) ---
function fillMissingDays() {
  const lastRecord = historyList.value[historyList.value.length - 1]
  if (!lastRecord) return

  // 获取最后一条数据的日期对象
  const lastDate = new Date(lastRecord.date)
  // 获取今天的日期对象 (清空时分秒,只比较日期)
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  lastDate.setHours(0, 0, 0, 0)

  // 如果最后一条数据早于今天,说明有空缺,需要补齐
  // 比如你上次部署是3天前,这里就会循环补齐这3天的空格子
  while (lastDate < today) {
    // 日期 +1 天
    lastDate.setDate(lastDate.getDate() + 1)
    
    // 格式化为 YYYY-MM-DD
    const year = lastDate.getFullYear()
    const month = String(lastDate.getMonth() + 1).padStart(2, '0')
    const day = String(lastDate.getDate()).padStart(2, '0')
    const dateStr = `${year}-${month}-${day}`

    // 推入一个空的记录 (count: 0)
    historyList.value.push({
      date: dateStr,
      count: 0,
      posts: []
    })
  }
}

// --- 3. 热力图数据处理 (依赖 historyList) ---
const weeks = computed(() => {
  const result = []
  let currentWeek = []
  historyList.value.forEach((day, index) => {
    currentWeek.push(day)
    // 每7天换一行,或者最后一天强制换行
    if (currentWeek.length === 7 || index === historyList.value.length - 1) {
      result.push(currentWeek)
      currentWeek = []
    }
  })
  return result
})

function getLevel(count) {
  if (count === 0) return 0
  if (count <= 1) return 1
  if (count <= 3) return 2
  if (count <= 5) return 3
  return 4
}

// --- 4. 统计数据 ---
const stats = computed(() => {
  let maxStreak = 0, currentStreak = 0, activeDays = 0, totalCount = 0
  
  historyList.value.forEach(day => {
    totalCount += day.count
    if (day.count > 0) {
      activeDays++
      currentStreak++
      maxStreak = Math.max(maxStreak, currentStreak)
    } else {
      currentStreak = 0
    }
  })
  
  return { totalCount, activeDays, maxStreak }
})

// --- 5. Tooltip 交互逻辑 (保持不变) ---
const tooltip = ref({ 
  show: false, locked: false, x: 0, y: 0, date: '', count: 0, posts: [] 
})

function onHover(e, day) {
  if (tooltip.value.locked) return
  const rect = e.target.getBoundingClientRect()
  tooltip.value = {
    show: true, locked: false,
    x: rect.left + rect.width / 2,
    y: rect.top - 8,
    date: day.date, count: day.count, posts: day.posts
  }
}

function onLeave() {
  if (!tooltip.value.locked) tooltip.value.show = false
}

function onClick(e, day) {
  e.stopPropagation()
  if (tooltip.value.locked && tooltip.value.date === day.date) {
    tooltip.value.show = false
    tooltip.value.locked = false
    return
  }
  const rect = e.target.getBoundingClientRect()
  let left = rect.left + rect.width / 2
  if (left < 110) left = 110
  if (window.innerWidth - left < 110) left = window.innerWidth - 110

  tooltip.value = {
    show: true, locked: true,
    x: left, y: rect.top - 12,
    date: day.date, count: day.count, posts: day.posts
  }
}

function closeTooltip() {
  if (tooltip.value.locked) {
    tooltip.value.show = false
    tooltip.value.locked = false
  }
}

// --- 6. 拖拽滚动 (保持不变) ---
const scrollRef = ref(null)
let isDown = false, startX, scrollLeft

function startDrag(e) { 
  isDown = true
  scrollRef.value.classList.add('active')
  startX = e.pageX - scrollRef.value.offsetLeft
  scrollLeft = scrollRef.value.scrollLeft 
}
function stopDrag() { isDown = false; if(scrollRef.value) scrollRef.value.classList.remove('active') }
function doDrag(e) { 
  if(!isDown) return
  e.preventDefault()
  const x = e.pageX - scrollRef.value.offsetLeft
  const walk = (x - startX) * 2
  scrollRef.value.scrollLeft = scrollLeft - walk 
}

// 生命周期
onMounted(() => {
  // 🔥 页面加载后,立即执行补齐逻辑
  fillMissingDays()
  
  // 自动滚动到最右侧 (显示最新日期)
  if (scrollRef.value) {
    setTimeout(() => {
      scrollRef.value.scrollLeft = scrollRef.value.scrollWidth
    }, 100)
  }

  window.addEventListener('click', closeTooltip)
})

onUnmounted(() => {
  window.removeEventListener('click', closeTooltip)
})
</script>

<template>
  <div class="dashboard-container">
    
    <div class="stats-row">
      <div class="stat-card">
        <div class="stat-icon">🔥</div>
        <div class="stat-info"><span class="stat-val">{{ stats.maxStreak }}</span><span class="stat-label">Streak</span></div>
      </div>
      <div class="stat-card">
        <div class="stat-icon">📅</div>
        <div class="stat-info"><span class="stat-val">{{ stats.activeDays }}</span><span class="stat-label">Active</span></div>
      </div>
      <div class="stat-card">
        <div class="stat-icon">✍️</div>
        <div class="stat-info"><span class="stat-val">{{ stats.totalCount }}</span><span class="stat-label">Posts</span></div>
      </div>
    </div>

    <div class="heatmap-section">
      <div class="section-header"><span class="section-title">Contribution Graph</span></div>
      
      <div 
        ref="scrollRef" class="graph-scroll"
        @mousedown="startDrag" @mouseleave="stopDrag" @mouseup="stopDrag" @mousemove="doDrag"
      >
        <div class="weeks">
          <div v-for="(week, wIndex) in weeks" :key="wIndex" class="week">
            <div 
              v-for="(day, dIndex) in week" :key="day.date" class="day" 
              :class="[`level-${getLevel(day.count)}`, { 'active-day': tooltip.show && tooltip.date === day.date }]"
              @mouseenter="onHover($event, day)"
              @mouseleave="onLeave"
              @click="onClick($event, day)"
            ></div>
          </div>
        </div>
      </div>
      
      <div class="legend"><span>Less</span><span class="day level-0"></span><span class="day level-2"></span><span class="day level-4"></span><span>More</span></div>
    </div>

    <div class="detail-grid">
      <div class="detail-card">
        <div class="card-title">📚 Knowledge</div>
        <div class="cat-list">
          <div v-for="cat in categories" :key="cat.name" class="cat-item">
            <div class="cat-header"><span class="cat-name">{{ cat.name }}</span><span class="cat-meta">{{ cat.count }}</span></div>
            <div class="progress-track"><div class="progress-fill" :style="{ width: cat.percent + '%' }"></div></div>
          </div>
        </div>
      </div>
      <div class="detail-card">
        <div class="card-title">📡 Signals</div>
        <div class="timeline">
          <a v-for="post in recent" :key="post.url" :href="post.url" class="timeline-item">
            <div class="time-node"></div>
            <div class="time-content"><div class="time-date">{{ post.dateShort }}</div><div class="time-title">{{ post.title }}</div></div>
          </a>
        </div>
      </div>
    </div>

    <Teleport to="body">
      <div 
        v-if="tooltip.show" 
        class="heatmap-popover" 
        :class="{ 'is-locked': tooltip.locked }"
        :style="{ top: tooltip.y + 'px', left: tooltip.x + 'px' }"
        @click.stop
      >
        <div class="popover-header">
          <span class="pop-date">{{ tooltip.date }}</span>
          <span class="pop-count" v-if="tooltip.count > 0">{{ tooltip.count }} posts</span>
          <span class="pop-count" v-else>No contributions</span>
        </div>

        <div v-if="tooltip.locked && tooltip.count > 0" class="popover-body">
          <div class="pop-divider"></div>
          <a v-for="post in tooltip.posts" :key="post.url" :href="post.url" class="pop-link">
            {{ post.title }}
          </a>
        </div>
        
        <div v-if="tooltip.locked && tooltip.count === 0" class="popover-body empty">
          Nothing here...
        </div>

        <div class="pop-arrow"></div>
      </div>
    </Teleport>
  </div>
</template>

<style scoped>
/* 样式部分完全保持不变 */
.dashboard-container { margin: 20px 0; font-family: -apple-system, BlinkMacSystemFont, sans-serif; user-select: none; }

/* 顶部统计 */
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; }
.stat-card { background: var(--vp-c-bg-soft); border: 1px solid var(--vp-c-bg-soft-up); padding: 12px; border-radius: 12px; display: flex; align-items: center; gap: 8px; }
.stat-icon { font-size: 20px; }
.stat-info { display: flex; flex-direction: column; }
.stat-val { font-size: 18px; font-weight: 700; color: var(--vp-c-text-1); }
.stat-label { font-size: 10px; text-transform: uppercase; color: var(--vp-c-text-2); font-weight: 600; }

/* 热力图 */
.heatmap-section { background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider); border-radius: 12px; padding: 15px; margin-bottom: 20px; }
.section-header { margin-bottom: 12px; }
.section-title { font-weight: 600; font-size: 14px; color: var(--vp-c-text-1); }

.graph-scroll { overflow-x: auto; padding-bottom: 10px; cursor: grab; }
.graph-scroll.active { cursor: grabbing; }
.graph-scroll::-webkit-scrollbar { height: 4px; }
.graph-scroll::-webkit-scrollbar-thumb { background: var(--vp-c-divider); border-radius: 2px; }

.weeks { display: flex; gap: 3px; }
.week { display: flex; flex-direction: column; gap: 3px; }

.day { width: 10px; height: 10px; border-radius: 2px; border: 1px solid rgba(0,0,0,0.04); cursor: pointer; transition: 0.1s;}
.active-day { border-color: #333 !important; transform: scale(1.3); z-index: 10; }

.level-0 { background-color: #ebedf0; }
.level-1 { background-color: #9be9a8; }
.level-2 { background-color: #40c463; }
.level-3 { background-color: #30a14e; }
.level-4 { background-color: #216e39; }
:root.dark .level-0 { background-color: #161b22; border-color: rgba(255,255,255,0.05); }
:root.dark .level-1 { background-color: #0e4429; }
:root.dark .level-2 { background-color: #006d32; }
:root.dark .level-3 { background-color: #26a641; }
:root.dark .level-4 { background-color: #39d353; }

.legend { display: flex; justify-content: flex-end; align-items: center; gap: 4px; font-size: 10px; color: var(--vp-c-text-2); margin-top: 8px; }

/* 底部详情 */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
@media (max-width: 640px) { .detail-grid { grid-template-columns: 1fr; } }
.detail-card { background: var(--vp-c-bg-soft); border: 1px solid var(--vp-c-bg-soft-up); border-radius: 12px; padding: 15px; }
.card-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--vp-c-text-1); border-bottom: 1px solid var(--vp-c-divider); padding-bottom: 6px;}

.cat-item { margin-bottom: 10px; }
.cat-header { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 3px; }
.progress-track { height: 4px; background: var(--vp-c-bg-mute); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--vp-c-brand); border-radius: 2px; }

.timeline { display: flex; flex-direction: column; }
.timeline-item { display: flex; gap: 10px; text-decoration: none !important; position: relative; padding-bottom: 15px; }
.timeline-item::before { content: ''; position: absolute; left: 4px; top: 12px; bottom: 0; width: 1px; background: var(--vp-c-divider); }
.timeline-item:last-child::before { display: none; }
.time-node { width: 9px; height: 9px; border-radius: 50%; border: 2px solid var(--vp-c-brand); background: var(--vp-c-bg); flex-shrink: 0; margin-top: 4px; z-index: 1; }
.time-content { display: flex; flex-direction: column; }
.time-date { font-size: 10px; color: var(--vp-c-text-2); font-family: monospace; }
.time-title { font-size: 12px; color: var(--vp-c-text-1); font-weight: 500;}

/* Popover */
.heatmap-popover {
  position: fixed; z-index: 9999; 
  transform: translate(-50%, -100%); margin-top: -12px;
  background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(4px);
  color: #fff; border-radius: 6px; 
  padding: 6px 10px; font-size: 12px;
  pointer-events: none;
  transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
  box-shadow: 0 4px 15px rgba(0,0,0,0.2);
  min-width: 120px; text-align: center;
}
.heatmap-popover.is-locked {
  pointer-events: auto;
  background: var(--vp-c-bg);
  color: var(--vp-c-text-1);
  border: 1px solid var(--vp-c-divider);
  padding: 12px;
  width: 220px; text-align: left;
  box-shadow: 0 10px 30px rgba(0,0,0,0.15);
  margin-top: -15px;
}
.pop-arrow {
  position: absolute; bottom: -5px; left: 50%; transform: translateX(-50%);
  width: 0; height: 0; 
  border-left: 5px solid transparent; border-right: 5px solid transparent;
  border-top: 5px solid rgba(0,0,0,0.85);
}
.heatmap-popover.is-locked .pop-arrow { border-top-color: var(--vp-c-bg); }

.pop-date { font-weight: bold; display: block; }
.pop-count { color: #888; font-size: 11px; }
.pop-divider { height: 1px; background: var(--vp-c-divider); margin: 8px 0; }
.pop-link {
  display: block; padding: 6px 8px; 
  color: var(--vp-c-brand); text-decoration: none; 
  border-radius: 4px; background: var(--vp-c-bg-mute);
  margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pop-link:hover { background: var(--vp-c-brand); color: #fff; }
.popover-body.empty { color: var(--vp-c-text-3); font-style: italic; text-align: center; padding: 10px 0;}
</style>

注册与使用

文件路径:docs/.vitepress/theme/index.js

javascript
import ActivityHeatmap from './components/ActivityHeatmap.vue'
export default {
  enhanceApp({ app }) {
    app.component('ActivityHeatmap', ActivityHeatmap)
  }
}

文件路径:docs/util/activity.md

---
layout: doc
title: 创作日志
---

# 📅 创作热力图

这里记录了我过去一年的思维轨迹。

<ActivityHeatmap />

首页应用导航坞

我的需求

核心目标:替换 VitePress 默认 Hero 区域那几个单调的按钮,打造一个类似 iOS/macOS 桌面风格的“应用入口”。

视觉风格

  • 磨砂质感:卡片带有轻微的磨砂和半透明效果,适配暗黑模式。
  • 精致图标:每个应用都有独立的渐变色背景容器。
  • 交互动效:鼠标悬停时卡片上浮、阴影加深、图标微微旋转。

布局要求

  • 严格对齐:必须与页面上的其他内容(如 H1 标题、下方的推荐卡片)左对齐,不能缩在中间或者左边留白太多。
  • 响应式:在宽屏上横向排列撑满容器,在窄屏上自动折行。

功能概述

应用聚合:目前集成了 4 个核心工具入口:

  • 📊 创作日志 (Creation Log) - 跳转至热力图
  • 待办清单 (To-Do List)。
  • 🗂️ 记忆卡片 (Flashcards)。
  • 💬 每日一句 (Daily Quote)。

数据配置化:通过组件内部的 apps 数组管理,修改文字、链接或图标无需改动 HTML 结构

自适应容器:移除了最大宽度限制,组件会根据父容器宽度自动拉伸,保证视觉上的整齐划一


实现流程

组件开发:创建 HomeDock.vue,使用 CSS Grid (repeat(auto-fit)) 实现响应式布局,并通过 CSS 变量适配 VitePress 主题色。

样式调优

  • 去除 max-widthmargin: auto,强制组件占满宽度并左对齐。
  • 去除 padding,消除左侧的人为缝隙,与页面边缘对齐。

全局注册:在 theme/index.js 中注册该组件。

页面挂载:在 index.md 的 Hero 配置下方直接调用组件。


代码详情

Vue 组件 (HomeDock.vue)

文件路径:docs/.vitepress/theme/components/HomeDock.vue

vue
<script setup>
import { useRouter } from 'vitepress'

const router = useRouter()

// 🛠️ 在这里修改你的应用配置
const apps = [
  { 
    id: 'activity',
    name: '创作日志', 
    desc: 'Creation Log', 
    icon: '📊', 
    link: '/docs/util/activity',
    // 渐变色背景,你可以自己换
    color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' 
  },
  { 
    id: 'todo',
    name: '待办清单', 
    desc: 'To-Do List', 
    icon: '✅', 
    link: '/docs/util/todolist',
    color: 'linear-gradient(135deg, #2af598 0%, #009efd 100%)' 
  },
  { 
    id: 'flashcard',
    name: '记忆卡片', 
    desc: 'Flashcards', 
    icon: '🗂️', 
    link: '/docs/util/flashcard',
    color: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%)' 
  },
  { 
    id: 'quote',
    name: '每日一句', 
    desc: 'Daily Quote', 
    icon: '💬', 
    link: '/docs/util/dailyquote',
    color: 'linear-gradient(135deg, #f6d365 0%, #fda085 100%)' 
  }
]

function go(link) {
  router.go(link)
}
</script>

<template>
  <div class="dock-container">
    <div class="dock-grid">
      <div 
        v-for="app in apps" 
        :key="app.id" 
        class="dock-item"
        @click="go(app.link)"
      >
        <div class="icon-box" :style="{ background: app.color }">
          <span class="icon">{{ app.icon }}</span>
        </div>
        <div class="info-box">
          <span class="app-name">{{ app.name }}</span>
          <span class="app-desc">{{ app.desc }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.dock-container {
  /* 1. 撑满父容器,跟上下的内容对齐 */
  width: 100%;
  
  /* 2. 删掉了 max-width 和 margin: auto,不再强制居中 */
  margin: 0 0 20px 0; 
  
  /* 3. 删掉了 padding: 0 24px,消除左侧留白 */
  padding: 0;
}

.dock-grid {
  display: grid;
  /* 自动适应宽度,但在大屏上保持整齐 */
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 14px;
}

.dock-item {
  position: relative;
  background: var(--vp-c-bg-soft);
  border: 1px solid var(--vp-c-bg-soft-up);
  border-radius: 14px; /* 稍微调小一点圆角,更干练 */
  padding: 14px;
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
  overflow: hidden;
}

.dock-item:hover {
  transform: translateY(-4px);
  background: var(--vp-c-bg-mute);
  border-color: var(--vp-c-brand);
  box-shadow: 0 8px 20px -6px rgba(0,0,0,0.1);
}

.icon-box {
  width: 42px;
  height: 42px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 22px;
  flex-shrink: 0;
  box-shadow: 0 4px 8px rgba(0,0,0,0.08);
  transition: transform 0.3s;
}

.dock-item:hover .icon-box {
  transform: scale(1.1) rotate(-3deg);
}

.icon {
  filter: drop-shadow(0 2px 2px rgba(0,0,0,0.1));
}

.info-box {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-width: 0; /* 防止文字溢出撑开 */
}

.app-name {
  font-size: 14px;
  font-weight: 600;
  color: var(--vp-c-text-1);
  line-height: 1.4;
  white-space: nowrap;       /* 强制不换行 */
  overflow: hidden;          /* 超出隐藏 */
  text-overflow: ellipsis;   /* 显示省略号 */
}

.app-desc {
  font-size: 11px;
  color: var(--vp-c-text-2);
  opacity: 0.8;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* 暗黑模式适配 */
:root.dark .dock-item {
  background: rgba(255, 255, 255, 0.05);
}
:root.dark .dock-item:hover {
  background: rgba(255, 255, 255, 0.08);
}
</style>

注册组件

文件路径:docs/.vitepress/theme/index.js

javascript
import DefaultTheme from 'vitepress/theme'
import HomeDock from './components/HomeDock.vue' // 引入

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    app.component('HomeDock', HomeDock) // 注册
  }
}

首页使用

文件路径:docs/index.md

---
layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界"
  tagline: xxx
  actions:
    - theme: brand
      text: 查看建站指南
      link: /guide/what-is-vitepress
    - theme: alt
      text: 欣赏摄影作品
      link: /photography
---

<HomeDock />

首页智能健康胶囊

我的需求

核心目标:为博客增加“生活感”和“人情味”,替换首页 Hero 区域原本的静态图片/Logo

功能期望

  • 智能关怀:根据不同时间段(早安、午休、深夜)显示不同的暖心文案
  • 生活提醒:特别关注喝水、吃早餐、运动和周末拍照
  • 数据联动:最好能根据我的更文状态(热力图)给出反馈

交互体验

  • 平时保持极简(类似“灵动岛”的胶囊形态)
  • 点击后展开“打卡面板”,可以记录喝水杯数和运动状态
  • 移动端适配:在手机上要紧凑美观,不能留白太多

功能概述

多维智能决策系统:组件内部有一套优先级逻辑,决定当前显示什么内容:

  1. 🚨 深夜警告 (最高级):凌晨 0-5 点访问,显示红色警告,催促睡觉
  2. ✍️ 更文表扬:检测到今日热力图有数据且为晚间,显示绿色表扬
  3. 📷 周末模式:周六/日白天,提示出门摄影或放松
  4. 🕰️ 日常轮播:根据早/中/晚随机抽取文案库中的问候语

双模式交互

  • Message Mode (看):带呼吸动效的问候胶囊
  • Action Mode (玩):点击胶囊切换为“控制中心”,包含喝水计数器(持久化存储)和运动打卡开关

视觉风格:磨砂玻璃质感 (Glassmorphism) + 全局呼吸动效 + 状态指示灯(红/绿/蓝)


实现流程

组件开发 (HealthWidget.vue)

  • 定义 quotes 文案库。
  • 编写 currentStatus 计算属性,实现优先级判断逻辑
  • 实现 localStorage 读写,保证刷新页面后“喝水数据”不丢失
  • 设计 CSS 动画 (float, pulse) 实现呼吸感,并适配暗黑模式

布局注入 (Layout.vue)

  • 利用 VitePress 的 Layout 插槽机制,将组件精准注入到 home-hero-image 位置
  • 编写全局 CSS 覆盖 (!important),强制消除 VitePress 默认 Hero 图片区域在移动端的巨大内边距

数据导入(activity.data.mjs)

  • 使用util/activity.data.mjs中的数据,查看当前系统中最近更新情况

首页配置 (index.md)

  • 移除 Frontmatter 中的 image 字段,防止默认图片渲染占位

代码详情

核心组件 (HealthWidget.vue)

文件路径:docs/.vitepress/theme/components/HealthWidget.vue

  • 引入activity.data.mjs
vue
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { data as activityData } from '../utils/activity.data.mjs'

// --- 1. 状态管理 ---
const mode = ref('message') 
const now = ref(new Date()) // 初始化时间
const healthState = ref({ water: 0, sport: false })
// 新增:一个标记,确保只在客户端渲染特定逻辑,避免水合报错
const isMounted = ref(false)

// ... (文案库代码保持不变,省略) ...
const quotes = {
  morning: [{ icon: '☕', text: '早安!早餐是开启大脑的钥匙。' }, { icon: '🌤️', text: '新的一天,今天也要充满干劲!' }],
  noon: [{ icon: '🍱', text: '午餐时间到!吃点好的犒劳自己。' }],
  afternoon: [{ icon: '🍵', text: '午后时光,起来走走,提神醒脑。' }, { icon: '🐠', text: '摸鱼时间到!听首歌放松一下?' }],
  evening: [{ icon: '🥗', text: '晚餐吃什么?对自己好一点。' }],
  night: [{ icon: '📖', text: '夜晚静谧,适合阅读或整理思绪。' }, { icon: '🌙', text: '熬夜伤身,保存进度,晚安。' }],
  weekend: [{ icon: '📷', text: '周末愉快!带上相机去扫街吧!' }, { icon: '🌳', text: '别闷在屋里,去公园呼吸新鲜空气。' }]
}

// --- 3. 智能决策逻辑 ---
const currentStatus = computed(() => {
  // 如果还没挂载(在服务端构建时),返回默认状态,避免红绿闪烁
  if (!isMounted.value) return getRandomQuote(quotes.morning)

  const hour = now.value.getHours()
  const day = now.value.getDay()
  const isWeekend = day === 0 || day === 6
  
  // A. 深夜警告 (逻辑保持不变)
  if (hour >= 0 && hour < 5) return { icon: '🚫', text: '凌晨了还在卷?注意发际线啊!快睡!', type: 'warning' }
  
  // B. 热力图联动
  if (activityData?.history?.length > 0) {
    const todayStr = now.value.toISOString().split('T')[0]
    const todayRecord = activityData.history.find(h => h.date === todayStr)
    if (todayRecord && todayRecord.count > 0 && hour > 20) {
      return { icon: '✍️', text: '今天已更文,太强了。快去休息吧。', type: 'success' }
    }
  }

  // C. 周末
  if (isWeekend && hour > 9 && hour < 18) return getRandomQuote(quotes.weekend)

  // D. 日常
  if (hour >= 5 && hour < 12) return getRandomQuote(quotes.morning)
  if (hour >= 12 && hour < 14) return getRandomQuote(quotes.noon)
  if (hour >= 14 && hour < 18) return getRandomQuote(quotes.afternoon)
  if (hour >= 18 && hour < 21) return getRandomQuote(quotes.evening)
  return getRandomQuote(quotes.night)
})

function getRandomQuote(arr) {
  // 加上防护,防止数组为空报错
  if (!arr || arr.length === 0) return { icon: '✨', text: '加油' }
  return arr[now.value.getHours() % arr.length]
}

// ... (交互逻辑 toggleMode, loadHealthData, saveHealthData 等保持不变) ...
function toggleMode() {
  mode.value = mode.value === 'message' ? 'action' : 'message'
}

function loadHealthData() {
  try {
    const local = localStorage.getItem('panda-health-data')
    if (local) {
      const data = JSON.parse(local)
      const todayStr = new Date().toISOString().split('T')[0]
      if (data.lastDate !== todayStr) {
        healthState.value = { water: 0, sport: false, lastDate: todayStr }
        saveHealthData()
      } else {
        healthState.value = data
      }
    }
  } catch(e) {}
}

function saveHealthData() {
  const data = { ...healthState.value, lastDate: new Date().toISOString().split('T')[0] }
  localStorage.setItem('panda-health-data', JSON.stringify(data))
}

function addWater(e) {
  e.stopPropagation()
  healthState.value.water++
  saveHealthData()
}

function toggleSport(e) {
  e.stopPropagation()
  healthState.value.sport = !healthState.value.sport
  saveHealthData()
}

let timer
onMounted(() => {
  // 关键修复:组件挂载后,标记为已挂载,并立即刷新时间
  isMounted.value = true
  now.value = new Date() 
  
  loadHealthData()
  timer = setInterval(() => { now.value = new Date() }, 60000)
})
onUnmounted(() => { if (timer) clearInterval(timer) })
</script>

<template>
  <div class="widget-wrapper">
    <div class="breathing-container">
      
      <div 
        class="health-pill" 
        :class="{ 'is-action': mode === 'action', 'is-warning': currentStatus.type === 'warning' }"
        @click="toggleMode"
      >
        <div v-if="mode === 'message'" class="mode-content message-mode">
          <div class="icon-box">{{ currentStatus.icon }}</div>
          <div class="content">
            <span class="status-dot" :class="currentStatus.type || 'normal'"></span>
            <span class="text">{{ currentStatus.text }}</span>
          </div>
          <div class="hint-arrow">
            <span class="dots">•••</span>
          </div>
        </div>

        <div v-else class="mode-content action-mode">
          <div class="action-card water-card" @click="addWater">
            <div class="card-icon">💧</div>
            <div class="card-info">
              <span class="card-label">Hydration</span>
              <span class="card-val">{{ healthState.water }} <small>cups</small></span>
            </div>
            <div class="card-btn">+</div>
          </div>

          <div class="action-card sport-card" :class="{ 'active': healthState.sport }" @click="toggleSport">
            <div class="card-icon">{{ healthState.sport ? '🥇' : '🏃' }}</div>
            <div class="card-info">
              <span class="card-label">Workout</span>
              <span class="card-val">{{ healthState.sport ? 'Done' : 'Go!' }}</span>
            </div>
            <div class="card-toggle">
              <div class="toggle-dot"></div>
            </div>
          </div>
        </div>

      </div>
      
      <div class="glow-bg" :class="currentStatus.type || 'normal'"></div>
    </div>
  </div>
</template>

<style scoped>
/* ... 样式部分完全保持不变 ... */
/* 容器:默认桌面端样式 */
.widget-wrapper {
  width: 100%; 
  height: 100%; 
  min-height: 160px; 
  display: flex; 
  align-items: center; 
  justify-content: center; 
  position: relative;
}

.breathing-container {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  animation: float 6s ease-in-out infinite;
}

.health-pill {
  position: relative; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  background: rgba(255, 255, 255, 0.75);
  backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.6);
  border-radius: 60px;
  box-shadow: 0 10px 30px -10px rgba(0,0,0,0.1), 0 0 0 1px rgba(255,255,255,0.6) inset;
  transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
  cursor: pointer; user-select: none;
  width: auto; min-width: 340px; height: 80px; padding: 0 12px;
}
.health-pill:hover { transform: scale(1.02); background: rgba(255, 255, 255, 0.85); }
.health-pill:active { transform: scale(0.98); }

.health-pill.is-warning { background: rgba(255, 235, 235, 0.9); border-color: #ffccc7; }

.mode-content {
  display: flex; align-items: center; width: 100%; height: 100%;
  animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }

.message-mode { gap: 16px; padding-left: 4px; padding-right: 12px;}
.icon-box {
  width: 52px; height: 52px; background: linear-gradient(135deg, #fff 0%, #f0f2f5 100%);
  border-radius: 50%; display: flex; align-items: center; justify-content: center;
  font-size: 26px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); flex-shrink: 0;
}
.content { display: flex; flex-direction: column; justify-content: center; gap: 4px; flex: 1; }
.text { font-size: 15px; font-weight: 500; color: var(--vp-c-text-1); line-height: 1.4; max-width: 240px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.normal { background-color: #42b883; box-shadow: 0 0 8px #42b883; }
.status-dot.warning { background-color: #ff4d4f; box-shadow: 0 0 8px #ff4d4f; }
.status-dot.success { background-color: #52c41a; box-shadow: 0 0 8px #52c41a; }
.hint-arrow { color: var(--vp-c-text-3); opacity: 0.5; transition: 0.3s; }
.health-pill:hover .hint-arrow { opacity: 1; transform: translateX(3px); }

.action-mode { gap: 10px; justify-content: space-between; padding: 0 4px; }

.action-card {
  flex: 1; height: 64px; border-radius: 40px; 
  display: flex; align-items: center; gap: 10px; padding: 0 8px 0 16px;
  transition: all 0.2s; position: relative; overflow: hidden;
}

.water-card { background: rgba(230, 247, 255, 0.6); border: 1px solid rgba(145, 213, 255, 0.3); }
.water-card:hover { background: rgba(230, 247, 255, 1); }
.water-card .card-icon { font-size: 20px; }
.water-card .card-label { color: #0050b3; }
.water-card .card-val { color: #1890ff; }
.water-card .card-btn { 
  width: 32px; height: 32px; border-radius: 50%; background: #1890ff; color: #fff;
  display: flex; align-items: center; justify-content: center; font-weight: bold;
}

.sport-card { background: rgba(246, 255, 237, 0.6); border: 1px solid rgba(183, 235, 143, 0.3); }
.sport-card:hover { background: rgba(246, 255, 237, 1); }
.sport-card.active { background: #d9f7be; border-color: #b7eb8f; } 
.sport-card .card-icon { font-size: 20px; }
.sport-card .card-label { color: #389e0d; }
.sport-card .card-val { color: #52c41a; }
.card-toggle {
  width: 36px; height: 20px; border-radius: 10px; background: rgba(0,0,0,0.1);
  position: relative; transition: 0.3s;
}
.sport-card.active .card-toggle { background: #52c41a; }
.toggle-dot {
  width: 16px; height: 16px; background: #fff; border-radius: 50%;
  position: absolute; top: 2px; left: 2px; transition: 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.sport-card.active .toggle-dot { left: 18px; }

.card-info { flex: 1; display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
.card-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
.card-val { font-size: 15px; font-weight: 600; }

.glow-bg {
  position: absolute; width: 200px; height: 200px;
  opacity: 0.2; filter: blur(70px); z-index: 1; border-radius: 50%;
  animation: pulse 4s ease-in-out infinite alternate;
}
.glow-bg.normal { background: var(--vp-c-brand); }
.glow-bg.warning { background: #ff4d4f; }
.glow-bg.success { background: #52c41a; }

@keyframes float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}
@keyframes pulse { from { opacity: 0.15; transform: scale(0.9); } to { opacity: 0.3; transform: scale(1.1); } }

:root.dark .health-pill { background: rgba(30, 30, 30, 0.6); border-color: rgba(255, 255, 255, 0.1); }
:root.dark .water-card { background: rgba(23, 125, 220, 0.15); border-color: rgba(23, 125, 220, 0.3); }
:root.dark .water-card:hover { background: rgba(23, 125, 220, 0.25); }
:root.dark .water-card .card-label { color: #a9d8ff; } 
:root.dark .sport-card { background: rgba(73, 170, 25, 0.15); border-color: rgba(73, 170, 25, 0.3); }
:root.dark .sport-card:hover { background: rgba(73, 170, 25, 0.25); }
:root.dark .sport-card.active { background: rgba(73, 170, 25, 0.4); }
:root.dark .sport-card .card-label { color: #d9f7be; }

@media (max-width: 640px) {
  .widget-wrapper {
    min-height: 100px; 
    margin-bottom: -10px;
  }
  .health-pill {
    min-width: auto;
    width: 92%;
    height: 70px;
    padding: 0 8px;
  }
  .icon-box { width: 42px; height: 42px; font-size: 22px; }
  .text { font-size: 13px; max-width: 200px; }
  .glow-bg { width: 140px; height: 140px; }
}
</style>

布局文件 (Layout.vue)

文件路径:docs/.vitepress/theme/Layout.vue

关键点padding-top: 80px !important 解决了移动端顶部大片留白的问题

vue
<script setup>
import DefaultTheme from 'vitepress/theme'
import { useData } from 'vitepress'
import MagicFooter from './components/MagicFooter.vue'
import HomePixelSnow from './components/HomePixelSnow.vue'
import HealthWidget from './components/HealthWidget.vue'

const { Layout } = DefaultTheme
const { frontmatter } = useData()
</script>

<template>
  <Layout>
    <template #layout-top>
      <HomePixelSnow />
    </template>

    <template #home-hero-image>
      <HealthWidget />
    </template>

    <template #layout-bottom>
      <MagicFooter />
    </template>
  </Layout>
</template>

<style>
/* 保持原有基础样式 */
.VPHomeHero .text,
.VPHomeHero .container {
  position: relative;
  z-index: 10;
}

/* 🔥🔥🔥 移动端布局强制修复 🔥🔥🔥 */
@media (max-width: 640px) {
  
  /* 1. 核心修复:大幅减小整个 Hero 区域的顶部内边距 */
  .VPHomeHero {
    padding-top: 80px !important;  /* 👈 关键修改:改小这个值 */
    padding-bottom: 0px !important;
  }

  /* 2. 图片区域(胶囊容器)微调 */
  .VPHomeHero .image {
    /* 去掉胶囊上方的所有额外边距 */
    margin-top: 0 !important; 
    /* 既然你说下边距差不多了,我们就保持一点距离,或者微调 */
    margin-bottom: 10px !important; 
  }

  /* 3. 清除容器高度限制 */
  .VPHomeHero .image-container {
    min-height: auto !important;
    height: auto !important;
  }
}


</style>

首页配置 (index.md)

文件路径:docs/index.md 注意:务必确认 image 字段已移除

---
layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界"
  tagline: 记录学习,享受生活
  # image: ... (已删除)
  actions:
    - theme: brand
      text: 查看建站指南
      link: /guide/what-is-vitepress
---

摄影作品时空穿梭墙

我的需求

极致视觉:拒绝平庸的九宫格或普通瀑布流,需要一种能“镇得住场子”、让人眼前一亮的效果。

全屏沉浸:组件必须突破文章容器的宽度限制,占满整个 100vw x 100vh 屏幕。

海量展示:未来可能有 30+ 张图片,需要一种能自动循环、无限滚动的展示方式。

高级交互

  • 默认显示总标题。
  • 鼠标悬停在某张图时,总标题瞬间变为该图的名称(HUD 指挥官模式)。
  • 点击图片跳转到对应文章,但不能有拖拽图片的廉价感。

功能概述

3D 倾斜视界:整体画廊向左倾斜 15 度,配合透视效果,营造速度感和空间感。

多轨差速流动:三条图片轨道,左右两列向下流,中间一列向上流,形成视觉差(Parallax)。

动态 HUD 标题

  • Idle 状态:屏幕中央显示巨大的 "GALLERY"。
  • Hover 状态:鼠标指向哪张图,中央文字即刻变为该图的 Title 和 Desc。

无限循环算法:基于 CSS Animation 无限滚动,通过数据倍增策略(少于 10 张自动复制)保证视觉上永远填满屏幕。

无干扰交互

  • 图片禁止拖拽(User Select None)。
  • 使用透明遮罩层拦截点击,解决 Viewer.js 冲突,实现丝滑跳转。
  • 悬停时高亮图片,压暗背景,聚焦感极强。

实现流程

数据准备:在 data/gallery.js 中维护图片列表(图片地址、标题、描述、跳转链接)。

组件逻辑 (PhotoStage.vue)

  • 使用 computed 计算属性,根据图片数量自动复制数组,制造“无限数据”的假象。
  • 使用 ref 追踪 hoverItem,实现 HUD 标题的动态切换。

样式构建

  • 父容器使用 margin-left: -50vw; width: 100vw 强制突破 layout: doc 的限制。
  • 使用 transform: rotate(15deg) 倾斜整体轨道。
  • 使用 @keyframes 实现垂直方向的无限循环滚动。

页面挂载:在 Markdown 文件中关闭侧边栏 (aside: false) 并引入组件。


代码详情

数据源 (gallery.js)

文件路径:docs/.vitepress/theme/data/gallery.js

javascript
export const galleryData = [
  {
    id: 1,
    title: '中央公园',
    desc: 'New York · 2024',
    image: '/images/central-park.jpg', // 你的图片路径
    link: '/docs/photography/NewYork/CentralPark' // 跳转文章链接
  },
  {
    id: 2,
    title: '西湖残雪',
    desc: 'Hangzhou · Action 5',
    image: '/images/west-lake.jpg',
    link: '/docs/photography/China/Hangzhou'
  },
  // ... 更多图片
]

核心组件 (PhotoStage.vue)

文件路径:docs/.vitepress/theme/components/PhotoStage.vue

vue
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vitepress'
import { galleryData } from '../data/gallery.js'

const router = useRouter()

// ⚡️ 状态管理
const hoverItem = ref(null)

// ⚡️ 数据无限流
const infiniteData = computed(() => {
  const len = galleryData.length
  if (len < 10) return [...galleryData, ...galleryData, ...galleryData, ...galleryData]
  if (len < 20) return [...galleryData, ...galleryData, ...galleryData]
  return [...galleryData, ...galleryData]
})

// 🔗 跳转
const go = (link) => {
  if (link) router.go(link)
}

// 🖱️ 鼠标交互
const onEnter = (item) => { hoverItem.value = item }
const onLeave = () => { hoverItem.value = null }

// 🔠 HUD 标题
const displayTitle = computed(() => hoverItem.value ? hoverItem.value.title : 'GALLERY')
const displayDesc = computed(() => hoverItem.value ? hoverItem.value.desc : 'SONY ZV-E10 / ACTION 5 / VISUAL LOG')
</script>

<template>
  <div class="warp-container">
    
    <div class="overlay-gradient"></div>

    <div class="hero-content">
      <div class="glitch-wrapper">
        <h1 class="hero-title" :key="displayTitle">{{ displayTitle }}</h1>
      </div>
      <p class="hero-subtitle" :key="displayDesc">{{ displayDesc }}</p>
      <div class="scroll-hint" v-show="!hoverItem">SCROLL TO EXPLORE</div>
    </div>

    <div class="gallery-lanes">
      
      <div class="lane lane-1">
        <div class="lane-wrap">
          <div 
            v-for="(item, i) in infiniteData" 
            :key="`c1-${i}`" 
            class="photo-card"
            @mouseenter="onEnter(item)" 
            @mouseleave="onLeave"
          >
            <img :src="item.image" loading="lazy" />
            <div class="click-mask" @click="go(item.link)"></div>
          </div>
        </div>
        <div class="lane-wrap" aria-hidden="true">
          <div 
            v-for="(item, i) in infiniteData" 
            :key="`c1-copy-${i}`" 
            class="photo-card"
            @mouseenter="onEnter(item)" 
            @mouseleave="onLeave"
          >
            <img :src="item.image" loading="lazy" />
            <div class="click-mask" @click="go(item.link)"></div>
          </div>
        </div>
      </div>

      <div class="lane lane-2">
        <div class="lane-wrap">
          <div 
            v-for="(item, i) in infiniteData" 
            :key="`c2-${i}`" 
            class="photo-card"
            @mouseenter="onEnter(item)" 
            @mouseleave="onLeave"
          >
            <img :src="item.image" loading="lazy" />
            <div class="click-mask" @click="go(item.link)"></div>
          </div>
        </div>
        <div class="lane-wrap" aria-hidden="true">
          <div 
            v-for="(item, i) in infiniteData" 
            :key="`c2-copy-${i}`" 
            class="photo-card"
            @mouseenter="onEnter(item)" 
            @mouseleave="onLeave"
          >
            <img :src="item.image" loading="lazy" />
            <div class="click-mask" @click="go(item.link)"></div>
          </div>
        </div>
      </div>

      <div class="lane lane-3">
        <div class="lane-wrap">
          <div 
            v-for="(item, i) in infiniteData" 
            :key="`c3-${i}`" 
            class="photo-card"
            @mouseenter="onEnter(item)" 
            @mouseleave="onLeave"
          >
            <img :src="item.image" loading="lazy" />
            <div class="click-mask" @click="go(item.link)"></div>
          </div>
        </div>
        <div class="lane-wrap" aria-hidden="true">
          <div 
            v-for="(item, i) in infiniteData" 
            :key="`c3-copy-${i}`" 
            class="photo-card"
            @mouseenter="onEnter(item)" 
            @mouseleave="onLeave"
          >
            <img :src="item.image" loading="lazy" />
            <div class="click-mask" @click="go(item.link)"></div>
          </div>
        </div>
      </div>

    </div>
  </div>
</template>

<style scoped>
/* 容器 */
.warp-container {
  width: 100vw;
  height: 100vh;
  position: relative;
  left: 50%; right: 50%;
  margin-left: -50vw; margin-right: -50vw;
  margin-top: -60px;
  overflow: hidden;
  background: #000;
  display: flex;
  justify-content: center;
  align-items: center;
  perspective: 1000px;
}

/* 文字层:精致化调整 */
.hero-content {
  position: absolute;
  z-index: 20;
  text-align: center;
  pointer-events: none;
  /* 强阴影:保证文字清晰 */
  text-shadow: 0 10px 40px rgba(0,0,0,0.9);
  width: 90%;
  max-width: 1200px;
}

.hero-title {
  /* 🟢 修改:从 8rem 减小到 6rem,更优雅 */
  font-size: 6rem;
  font-weight: 900;
  color: #fff;
  line-height: 1.1;
  margin: 0;
  letter-spacing: -3px; /* 字间距稍微收紧 */
  font-family: impact, sans-serif;
  opacity: 0.95;
  text-transform: uppercase;
  animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}

.hero-subtitle {
  color: rgba(255, 255, 255, 0.85); /* 稍微透一点 */
  font-family: monospace;
  font-size: 1rem;
  letter-spacing: 6px; /* 增加间距,提升高级感 */
  margin-top: 16px;
  text-transform: uppercase;
  animation: fadeIn 0.3s ease;
}

.scroll-hint {
  margin-top: 40px;
  font-size: 0.75rem;
  letter-spacing: 2px;
  opacity: 0.5;
  animation: pulse 2s infinite;
}

@keyframes popIn { 0% { transform: scale(0.9); opacity: 0; } 100% { transform: scale(1); opacity: 0.95; } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 0.7; } }

/* 轨道系统:保留倾斜 */
.gallery-lanes {
  display: flex;
  gap: 30px;
  width: 120%;
  height: 150%;
  transform: rotate(15deg) scale(1.2); 
  filter: brightness(0.6) contrast(1.2);
  transition: filter 0.5s;
}

.gallery-lanes:hover {
  /* 悬停时整体稍微变亮,方便看图 */
  filter: brightness(0.7) contrast(1.1);
}

.lane { flex: 1; position: relative; overflow: hidden; }
.lane-wrap { display: flex; flex-direction: column; gap: 30px; padding-bottom: 30px; will-change: transform; }

.photo-card {
  position: relative;
  border-radius: 6px; /* 圆角稍微改小一点点,更硬朗 */
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(0,0,0,0.5);
  transition: transform 0.3s, box-shadow 0.3s;
}

.photo-card img {
  width: 100%; height: auto; display: block;
  user-select: none; -webkit-user-select: none; -webkit-user-drag: none; pointer-events: none;
}

/* 点击遮罩:纯交互层 */
.click-mask {
  position: absolute; inset: 0; z-index: 5; cursor: pointer;
  background: rgba(255, 255, 255, 0); transition: all 0.2s;
}

/* 🟢 修改:悬停时的高亮反馈 */
.photo-card:hover { 
  transform: scale(1.05); 
  z-index: 10; 
  box-shadow: 0 25px 60px rgba(0,0,0,0.9); /* 阴影更深 */
  /* 给个微微发光的边框 */
  outline: 2px solid rgba(255, 255, 255, 0.3);
}

.photo-card:hover .click-mask { 
  /* 微微提亮图片,不加任何文字 */
  background: rgba(255, 255, 255, 0.05); 
}

/* 动效定义 */
.lane-1 .lane-wrap, .lane-3 .lane-wrap { animation: scrollDown 60s linear infinite; }
.lane-2 .lane-wrap { animation: scrollUp 50s linear infinite; }
@keyframes scrollUp { 0% { transform: translateY(0); } 100% { transform: translateY(-100%); } }
@keyframes scrollDown { 0% { transform: translateY(-100%); } 100% { transform: translateY(0); } }

.overlay-gradient {
  position: absolute; inset: 0; z-index: 5;
  background: radial-gradient(circle, transparent 30%, #000 100%);
  pointer-events: none;
}

@media (max-width: 768px) {
  /* 手机端字体适配 */
  .hero-title { font-size: 3rem; letter-spacing: -1px; } 
  .hero-subtitle { font-size: 0.8rem; letter-spacing: 2px; }
  .gallery-lanes { gap: 15px; }
  .lane-3 { display: none; }
}
</style>

入口页面 (photowall.md)

文件路径:docs/util/photostage.md

---
layout: doc
aside: false
title: 摄影精选
---

<PhotoStage />

全局注册 (index.js)

文件路径:docs/.vitepress/theme/index.js

javascript
import PhotoStage from './components/PhotoStage.vue'
// ...
app.component('PhotoStage', PhotoStage)

插入首页hero区(index.md)

文件路径:MyBlog/index.md

layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界"
  tagline: 

  actions:
    - theme: brand
      text: xx
      link: xx
    - theme: brand
      text: 📷 欣赏摄影作品
      link: /docs/util/photostage.md

精华文章时空档案馆

我的需求

视觉目标:抛弃传统的博客列表,打造一个具有科幻工业感沉浸式的文章展示入口。

风格定义

  • 黑夜模式:赛博朋克、霓虹光晕、雷达扫描、粒子浮游。
  • 白天模式:精密蓝图、工程网格、高对比度线条、噪点纸质感。

交互体验

  • 支持 PC 端鼠标拖拽、键盘方向键控制。
  • 核心痛点:必须支持移动端手指滑动切换。
  • 底部提供直观的时间轴滑块

性能要求:滚动页面不能卡顿,切换动画必须达到 60fps 丝滑。

布局要求:打破 VitePress 默认布局限制,实现真·全屏 (100vw),同时不能遮挡页脚交互。


功能概述

核心形态:基于 CSS3D 的 Coverflow (封面流) 布局。中间卡片聚焦,两侧卡片层叠后退。

双模态引擎 (Dual-Core Visuals)

  • 自动检测系统/网站主题,无缝切换 “深空 (Dark)”“蓝图 (Light)” 两套完全不同的视觉 UI。

HUD 抬头显示

  • 顶部:战术状态栏 (时间、版本、装饰线)。
  • 中场:动态信号分析柱 (左) + 垂直进度游标 (右)。
  • 背景:旋转的巨型雷达 + 动态粒子系统。

智能数据源

  • 自动读取 ../utils/posts.data.mjs
  • 自动过滤非核心目录 (只保留 01~05)。
  • 自动提取分类名并在背景生成巨型装饰字。

性能黑科技

  • 主角独享模式:只有当前选中的卡片才开启高消耗的 backdrop-filter (毛玻璃) 和 box-reflect (倒影),其余卡片降级渲染。
  • Z轴管理:修正层级,确保不遮挡页脚。

实现流程

布局突破

  • 使用 width: 100vw; margin-left: -50vw; left: 50% 的负边距技巧,在 Markdown 容器内强行撑满屏幕。
  • 使用 z-index: 0 确保组件层级低于页脚,修复底部无法点击的问题。

3D 舞台搭建

  • 容器开启 perspective: 1000px
  • 卡片通过计算 offset = index - activeIndex,动态分配 translateX (横向位移), translateZ (纵深), rotateY (旋转角度)。

交互逻辑

  • 监听 mousedown / mousemove / mouseup 实现 PC 拖拽。
  • 监听 touchstart / touchmove / touchend 实现移动端丝滑触控。
  • 底部 <input type="range"> 滑块双向绑定 activeIndex

视觉装饰 (Decor)

  • 天花板 (Ceiling Grid):利用 rotateX 倒置一个网格平面,填补顶部空白。
  • 粒子系统:JS 生成随机坐标和飞行向量,CSS mix-blend-mode 控制日夜融合效果。

性能优化 (Optimization)

  • 移除全局 backdrop-filter,改为按需开启。
  • 添加 will-change: transform 提示浏览器开启硬件加速。
  • 白天模式移除复杂的 SVG 滤镜,改用 CSS 渐变模拟网格。

代码详情

组件源码 (ChronoRing.vue)

路径:docs/.vitepress/theme/components/ChronoRing.vue

  • 涉及utils/posts.data.mjs文件(数据提取)
vue
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useData } from 'vitepress'
import { data as allPosts } from '../utils/posts.data.mjs'

const { isDark } = useData()
const timeString = ref('00:00:00')
let clockTimer = null

// ⚡️ 1. 粒子系统
const particles = ref([])

const initParticles = () => {
  const count = 15 
  const arr = []
  for (let i = 0; i < count; i++) {
    const type = Math.random() > 0.5 ? 'shape' : 'cross'
    arr.push({
      type,
      left: Math.random() * 100 + '%',
      top: Math.random() * 100 + '%',
      size: Math.random() * 4 + 2 + 'px', 
      opacity: Math.random() * 0.4 + 0.1, 
      duration: Math.random() * 40 + 30 + 's',
      delay: Math.random() * -60 + 's',
      tx: (Math.random() - 0.5) * 150 + 'px', 
      ty: (Math.random() - 0.5) * 150 + 'px'
    })
  }
  particles.value = arr
}

// ⚡️ 2. 数据清洗
const galleryData = computed(() => {
  const buckets = { 'Coding': [], 'Photography': [], 'English': [], 'Share': [], 'Growth': [] }
  
  allPosts.forEach(post => {
    if (!post.url.match(/\/0[1-5]\./)) return

    const catStr = post.category || ''
    let cleanCat = catStr.replace(/^docs\s*\/\s*/, '') 
    let mainCat = cleanCat.split(' / ')[0] 

    if (mainCat.includes('Coding')) buckets['Coding'].push({ ...post, mainCat })
    else if (mainCat.includes('Photography')) buckets['Photography'].push({ ...post, mainCat })
    else if (mainCat.includes('English')) buckets['English'].push({ ...post, mainCat })
    else if (mainCat.includes('Share')) buckets['Share'].push({ ...post, mainCat })
    else if (mainCat.includes('Growth')) buckets['Growth'].push({ ...post, mainCat })
  })

  let balanced = []
  const LIMIT = 6
  Object.values(buckets).forEach(list => {
    balanced = balanced.concat(list.slice(0, LIMIT))
  })

  balanced.sort((a, b) => (b.date?.time || 0) - (a.date?.time || 0))

  return balanced.map((post, index) => {
    let color = '#a0a0a0'
    let icon = '📄'
    
    if (post.mainCat.includes('Coding')) { color = '#ff4757'; icon = '⚡️' }
    else if (post.mainCat.includes('Photography')) { color = '#ffa502'; icon = '📸' }
    else if (post.mainCat.includes('English')) { color = '#2ed573'; icon = '🅰️' }
    else if (post.mainCat.includes('Share')) { color = '#5352ed'; icon = '💠' }
    else if (post.mainCat.includes('Growth')) { color = '#1e90ff'; icon = '🌱' }

    return {
      ...post,
      id: index,
      color,
      icon,
      cleanCatName: post.mainCat.replace(/^\d+\./, '').toUpperCase(), 
      displayTag: post.mainCat.replace(/^\d+\./, '').toUpperCase(),
      displayDate: post.date && post.date.time 
        ? new Date(post.date.time).toISOString().split('T')[0] 
        : 'ARCHIVED'
    }
  })
})

const activeIndex = ref(Math.floor(galleryData.value.length / 2))
const containerRef = ref(null)
const isDragging = ref(false)
const startX = ref(0)
const startIndex = ref(0)

const currentTheme = computed(() => {
  const item = galleryData.value[activeIndex.value]
  return item ? item.color : '#888'
})

const currentBgText = computed(() => {
  const item = galleryData.value[activeIndex.value]
  return item ? item.cleanCatName : 'SYSTEM'
})

const progressPercent = computed(() => {
  if (galleryData.value.length <= 1) return 0
  return (activeIndex.value / (galleryData.value.length - 1)) * 100
})

const setActive = (index) => {
  if (index < 0) index = 0
  if (index >= galleryData.value.length) index = galleryData.value.length - 1
  activeIndex.value = index
}

const handleMouseDown = (e) => {
  if (e.target.tagName === 'INPUT') return
  isDragging.value = true
  startX.value = e.clientX
  startIndex.value = activeIndex.value
  containerRef.value.style.cursor = 'grabbing'
}

const handleMouseMove = (e) => {
  if (!isDragging.value) return
  const deltaX = e.clientX - startX.value
  const step = Math.round(deltaX / -100) 
  setActive(startIndex.value + step)
}

const handleMouseUp = () => {
  isDragging.value = false
  containerRef.value.style.cursor = 'grab'
}

const handleTouchStart = (e) => {
  if (e.target.tagName === 'INPUT') return
  isDragging.value = true
  startX.value = e.touches[0].clientX
  startIndex.value = activeIndex.value
}

const handleTouchMove = (e) => {
  if (!isDragging.value) return
  const deltaX = e.touches[0].clientX - startX.value
  const step = Math.round(deltaX / -60)
  setActive(startIndex.value + step)
}

const handleTouchEnd = () => {
  isDragging.value = false
}

const handleKey = (e) => {
  if (e.key === 'ArrowRight') setActive(activeIndex.value + 1)
  if (e.key === 'ArrowLeft') setActive(activeIndex.value - 1)
}

const goPost = (url) => {
  window.location.href = url
}

const goBack = () => {
  window.location.href = '/'
}

// 📐 3D 样式 (性能优化版)
const getCardStyle = (index) => {
  const active = activeIndex.value
  const offset = index - active
  const absOffset = Math.abs(offset)
  
  if (absOffset > 5) return { display: 'none' } 

  const SPACING = 200 
  const ROTATION = 60 
  const Z_DEPTH = -300 
  
  const isActive = offset === 0
  
  if (isActive) {
    return {
      transform: `translateX(0) translateZ(180px) rotateY(0deg)`,
      zIndex: 100,
      opacity: 1,
      filter: isDark.value ? 'brightness(1.1)' : 'none',
      pointerEvents: 'auto',
      willChange: 'transform' // 内部开启优化
    }
  }
  
  const sign = Math.sign(offset)
  
  return {
    transform: `
      translateX(${offset * SPACING * 0.6 + (sign * 140)}px) 
      translateZ(${Z_DEPTH - (absOffset * 100)}px) 
      rotateY(${sign * -ROTATION}deg)
    `,
    zIndex: 90 - absOffset,
    opacity: Math.max(0.2, 1 - absOffset * 0.2),
    filter: isDark.value ? 'brightness(0.5)' : 'brightness(0.95) grayscale(0.2)',
    pointerEvents: 'auto',
    cursor: 'pointer',
    willChange: 'transform, opacity'
  }
}

const updateClock = () => {
  const now = new Date()
  timeString.value = now.toLocaleTimeString('en-GB')
}

onMounted(() => {
  initParticles()
  window.addEventListener('keydown', handleKey)
  window.addEventListener('mouseup', handleMouseUp)
  window.addEventListener('mousemove', handleMouseMove)
  clockTimer = setInterval(updateClock, 1000)
  updateClock()
})

onUnmounted(() => {
  window.removeEventListener('keydown', handleKey)
  window.removeEventListener('mouseup', handleMouseUp)
  window.removeEventListener('mousemove', handleMouseMove)
  if (clockTimer) clearInterval(clockTimer)
})
</script>

<template>
  <div 
    class="gallery-stage" 
    :class="{ 'light-mode': !isDark }"
    ref="containerRef" 
    @mousedown="handleMouseDown"
    @touchstart="handleTouchStart"
    @touchmove="handleTouchMove"
    @touchend="handleTouchEnd"
    :style="{ '--active-color': currentTheme }"
  >
    
    <div class="ceiling-grid"></div>

    <div class="ambient-layer">
      <div class="radar-bg">
        <div class="radar-circle c1"></div>
        <div class="radar-circle c2"></div>
        <div class="radar-cross"></div>
        <div class="radar-circle c3"></div>
      </div>

      <div class="big-bg-text" :key="currentBgText">
        {{ currentBgText }}
      </div>
      
      <div class="mid-decor left-ruler">
        <div class="signal-header">SIGNAL</div>
        <div class="signal-bars">
          <div class="s-bar b1"></div><div class="s-bar b2"></div><div class="s-bar b3"></div><div class="s-bar b4"></div><div class="s-bar b5"></div>
        </div>
        <div class="signal-footer">CH_01</div>
      </div>

      <div class="mid-decor right-ruler">
        <div class="gauge-top">00</div>
        <div class="gauge-track">
          <div class="tick-marks"></div>
          <div class="gauge-cursor" :style="{ top: progressPercent + '%' }">
            <span class="cursor-arrow">◀</span>
            <span class="cursor-val">{{ activeIndex + 1 }}</span>
          </div>
        </div>
        <div class="gauge-bottom">{{ galleryData.length }}</div>
      </div>

      <div class="particles">
        <div 
          v-for="(p, i) in particles" 
          :key="i" 
          class="particle"
          :class="`type-${p.type}`" 
          :style="{
            left: p.left,
            top: p.top,
            width: p.size,
            height: p.size,
            opacity: p.opacity,
            animationDuration: p.duration,
            animationDelay: p.delay,
            '--tx': p.tx,
            '--ty': p.ty
          }"
        ></div>
      </div>

      <div class="side-hud left">
        <div class="v-line"></div>
        <div class="hud-text">
          <span>// SYSTEM_READY</span>
          <span>>> DATA_STREAM: ON</span>
        </div>
      </div>
      <div class="side-hud right">
        <div class="v-line"></div>
        <div class="hud-text align-right">
          <span>MODE: VIEW</span>
          <span>SEC: A-0{{ activeIndex % 9 }}</span>
        </div>
      </div>
    </div>

    <div class="hud-layer">
      <div class="top-bar-safe-zone">
        <button class="back-btn" @click.stop="goBack">
          <span class="icon">◄</span> RETURN
        </button>
        <div class="top-center-decor">
          <span class="line"></span><span class="box"></span><span class="line"></span>
        </div>
        <div class="sys-status">
          <span class="dot"></span> {{ timeString }}
        </div>
      </div>
      
      <div class="header-info">
        <h1 class="glitch" data-text="CHRONO ARCHIVE">CHRONO ARCHIVE</h1>
      </div>
    </div>

    <div class="scene">
      <div class="floor-grid"></div>
      <div class="card-rail">
        <div 
          v-for="(item, index) in galleryData" 
          :key="item.url"
          class="card-wrapper"
          :class="{ 'is-active': index === activeIndex }"
          :style="getCardStyle(index)"
          @click.stop="index === activeIndex ? goPost(item.url) : setActive(index)"
        >
          <div class="glass-card" :style="{ '--theme': item.color }">
            <div class="status-bar"></div>
            <div class="card-body">
              <div class="icon-wrap">{{ item.icon }}</div>
              <div class="title-container">
                <div class="title">{{ item.title }}</div>
              </div>
              <div class="info-row">
                <span class="tag">{{ item.displayTag }}</span>
              </div>
            </div>
            <div class="enter-btn" v-if="index === activeIndex">OPEN FILE</div>
          </div>
        </div>
      </div>
    </div>

    <div class="control-deck">
      <div class="timeline-box">
        <div class="index-indicator">{{ activeIndex + 1 }}</div>
        <input 
          type="range" 
          min="0" 
          :max="galleryData.length - 1" 
          v-model.number="activeIndex"
          class="cyber-range"
        >
        <div class="total-indicator">{{ galleryData.length }}</div>
      </div>
    </div>

  </div>
</template>

<style scoped>
/* =========================================
   🌙 Base Dark Mode
   ========================================= */
.gallery-stage {
  width: 100vw; height: 100vh;
  position: relative;
  /* 布局修复:保持你满意的 -100px */
  left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw;
  
  /* 🔥 保持你现在的设置,不动! */
  margin-top: -100px; 
  padding-top: 0;
  
  background: radial-gradient(circle at center 60%, rgba(var(--active-color), 0.1) 0%, #0f1115 60%, #000000 100%);
  transition: background 0.5s ease;
  overflow: hidden; 
  perspective: 1000px; 
  user-select: none; 
  cursor: grab;
  
  --text-main: #fff;
  --card-bg: rgba(20, 20, 22, 0.85);
  --card-border: rgba(255, 255, 255, 0.1);
  --hud-bg: rgba(0,0,0,0.6);
  --grid-color: rgba(255,255,255,0.05);
  --decor-color: rgba(255,255,255,0.15);
  --particle-base: var(--active-color);
}

/* =========================================
   ☀️ Light Mode
   ========================================= */
.gallery-stage.light-mode {
  background-color: #f0f2f5;
  background-image: 
    radial-gradient(at 50% 100%, rgba(var(--active-color), 0.1) 0%, transparent 50%),
    linear-gradient(#e5e9f2 1px, transparent 1px),
    linear-gradient(90deg, #e5e9f2 1px, transparent 1px);
  background-size: 100% 100%, 40px 40px, 40px 40px;
  
  --text-main: #2c3e50;
  --card-bg: rgba(255, 255, 255, 0.85);
  --card-border: rgba(255, 255, 255, 1);
  --hud-bg: rgba(255,255,255,0.8);
  --grid-color: rgba(0,0,0,0.08); 
  --decor-color: rgba(0,0,0,0.1);
  --particle-base: #000;
}

/* 🏗️ Ceiling & Radar */
.ceiling-grid { position: absolute; top: -20%; left: -50%; width: 200%; height: 60%; background: linear-gradient(var(--grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--grid-color) 1px, transparent 1px); background-size: 100px 100px; transform: perspective(1000px) rotateX(60deg) translateY(-200px) translateZ(-200px); mask-image: linear-gradient(to top, rgba(0,0,0,1) 0%, transparent 80%); pointer-events: none; z-index: 0; }
.radar-bg { position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); width: 120vw; height: 120vw; pointer-events: none; }
.light-mode .radar-bg { mix-blend-mode: multiply; opacity: 0.5; }
.radar-circle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); border: 1px dashed var(--decor-color); border-radius: 50%; }
.c1 { width: 60%; height: 60%; animation: spin 60s linear infinite; }
.c2 { width: 40%; height: 40%; border-style: solid; opacity: 0.5; animation: spin 40s linear infinite reverse; }
.c3 { width: 80%; height: 80%; border: 1px solid var(--grid-color); opacity: 0.3; }
.radar-cross { position: absolute; top: 50%; left: 50%; width: 100%; height: 1px; background: var(--grid-color); transform: translate(-50%, -50%); }
.radar-cross::after { content: ''; position: absolute; top: 50%; left: 50%; width: 1px; height: 100vw; background: var(--grid-color); transform: translate(-50%, -50%); }
@keyframes spin { 0% { transform: translate(-50%, -50%) rotate(0deg); } 100% { transform: translate(-50%, -50%) rotate(360deg); } }

.ambient-layer { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.big-bg-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -60%); font-family: 'Impact', sans-serif; font-size: 15vw; color: transparent; -webkit-text-stroke: 2px var(--active-color); opacity: 0.05; white-space: nowrap; letter-spacing: 20px; transition: all 0.5s ease; }
.light-mode .big-bg-text { opacity: 0.08; -webkit-text-stroke: 3px var(--active-color); }

/* 📏 Decor */
.mid-decor { position: absolute; top: 35%; bottom: 35%; width: 60px; display: flex; flex-direction: column; justify-content: center; align-items: center; opacity: 0.6; pointer-events: none; }
.left-ruler { left: 18%; border-right: 2px solid var(--decor-color); padding-right: 10px; }
.right-ruler { right: 18%; border-left: 2px solid var(--decor-color); padding-left: 10px; }
.signal-header, .signal-footer { font-size: 0.5rem; font-family: monospace; color: var(--text-main); opacity: 0.5; margin: 5px 0; writing-mode: vertical-rl; }
.signal-bars { flex: 1; display: flex; flex-direction: column; gap: 4px; justify-content: center; width: 20px; }
.s-bar { width: 100%; background: var(--active-color); opacity: 0.2; height: 2px; animation: signalPulse 1s infinite alternate; }
.b1 { width: 40%; animation-delay: 0.1s; } .b2 { width: 80%; animation-delay: 0.3s; } .b3 { width: 60%; animation-delay: 0.5s; } .b4 { width: 90%; animation-delay: 0.2s; } .b5 { width: 50%; animation-delay: 0.4s; }
@keyframes signalPulse { 0% { opacity: 0.1; width: 30%; } 100% { opacity: 0.8; width: 100%; } }
.gauge-top, .gauge-bottom { font-family: monospace; font-size: 0.8rem; color: var(--text-main); opacity: 0.7; margin: 10px 0; }
.gauge-track { flex: 1; width: 20px; position: relative; border-right: 1px dashed var(--decor-color); }
.tick-marks { position: absolute; inset: 0; background-image: linear-gradient(to bottom, var(--decor-color) 1px, transparent 1px); background-size: 100% 5%; opacity: 0.5; }
.gauge-cursor { position: absolute; right: -5px; transform: translateY(-50%); display: flex; align-items: center; gap: 5px; transition: top 0.3s ease-out; }
.cursor-arrow { color: var(--active-color); font-size: 0.6rem; animation: pulseArrow 1s infinite; }
.cursor-val { font-family: monospace; color: var(--text-main); background: var(--active-color); color: #000; padding: 2px 4px; border-radius: 2px; font-size: 0.7rem; font-weight: bold; }
@keyframes pulseArrow { 0% { transform: translateX(0); opacity: 0.5; } 50% { transform: translateX(-3px); opacity: 1; } 100% { transform: translateX(0); opacity: 0.5; } }

/* 🔥 Particles */
.particles { position: absolute; inset: 0; pointer-events: none; }
.particle { position: absolute; border-radius: 50%; background: radial-gradient(circle, var(--particle-base), transparent 80%); filter: blur(1px); animation: organicDrift linear infinite; mix-blend-mode: screen; }
.light-mode .particle { background: transparent; border: 1px solid rgba(0,0,0,0.08); border-radius: 0; mix-blend-mode: multiply; filter: none; }
.light-mode .particle.type-cross { border: none; background: transparent; width: 8px !important; height: 8px !important; }
.light-mode .particle.type-cross::before, .light-mode .particle.type-cross::after { content: ''; position: absolute; background: rgba(0,0,0,0.15); top: 50%; left: 50%; transform: translate(-50%, -50%); }
.light-mode .particle.type-cross::before { width: 100%; height: 1px; } .light-mode .particle.type-cross::after { width: 1px; height: 100%; }
@keyframes organicDrift { 0% { transform: translate(0, 0) rotate(0deg); opacity: 0; } 20% { opacity: 1; } 80% { opacity: 1; } 100% { transform: translate(var(--tx), var(--ty)) rotate(180deg); opacity: 0; } }

/* 🔥 核心修复区域:侧边栏 Side-HUD */
.side-hud { 
  position: absolute; 
  top: 50%; 
  transform: translateY(-50%); /* 垂直居中 */
  height: auto; 
  width: auto; /* 关键:自动宽度,防止切字 */
  min-width: 40px;
  display: flex; 
  flex-direction: column; 
  justify-content: center; 
  pointer-events: none; 
  z-index: 100; /* 关键:确保在所有背景层之上 */
}

.light-mode .side-hud { border-color: rgba(0,0,0,0.05); }

/* 左侧面板 */
.side-hud.left { 
  left: 40px; 
  padding-left: 15px; 
  border-left: 1px solid var(--active-color); 
}

/* 右侧面板 */
.side-hud.right { 
  right: 40px; 
  padding-right: 15px; 
  text-align: right; 
  border-right: 1px solid var(--active-color); 
  border-left: none; /* 清除可能继承的左边框 */
  align-items: flex-end; /* 文字靠右对齐 */
}

/* 隐藏那条多余的竖线 div,直接用 border 实现 */
.v-line { display: none; } 

.hud-text { 
  font-family: monospace; 
  font-size: 0.7rem; 
  color: var(--text-main); 
  opacity: 0.6; 
  line-height: 2; 
  display: flex; 
  flex-direction: column;
  white-space: nowrap; /* 关键:禁止文字换行 */
}

/* HUD Top Bar */
.hud-layer { pointer-events: none; z-index: 200; position: absolute; inset: 0; }
.top-bar-safe-zone { position: absolute; top: 100px; left: 0; width: 100%; padding: 0 60px; display: flex; justify-content: space-between; align-items: center; }
.back-btn { pointer-events: auto; background: var(--hud-bg); border: 1px solid var(--card-border); color: var(--text-main); padding: 8px 16px; border-radius: 4px; cursor: pointer; backdrop-filter: blur(4px); transition: 0.2s; box-shadow: 0 4px 10px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 8px; font-weight: bold; }
.back-btn:hover { background: var(--active-color); color: #fff; border-color: var(--active-color); }
.top-center-decor { display: flex; align-items: center; gap: 10px; opacity: 0.3; }
.top-center-decor .line { width: 50px; height: 1px; background: var(--text-main); }
.top-center-decor .box { width: 6px; height: 6px; background: var(--text-main); transform: rotate(45deg); }
.sys-status { font-family: monospace; color: var(--active-color); font-weight: bold; background: var(--hud-bg); padding: 6px 12px; border-radius: 4px; border: 1px solid var(--card-border); box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
.dot { display: inline-block; width: 8px; height: 8px; background: #2ecc71; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 5px #2ecc71; }
.header-info { position: absolute; top: 160px; width: 100%; text-align: center; pointer-events: none; }
.glitch { font-family: 'Impact', sans-serif; font-size: 3rem; color: var(--text-main); letter-spacing: 8px; margin: 0; opacity: 0.15; text-shadow: 2px 2px 0px rgba(125,125,125,0.1); }

/* Scene & Cards */
.scene { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; transform-style: preserve-3d; transform: translateY(-20px) rotateX(-2deg); }
.card-rail { position: relative; width: 100%; height: 400px; display: flex; justify-content: center; align-items: center; transform-style: preserve-3d; }
.card-wrapper { position: absolute; width: 240px; height: 340px; transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1), opacity 0.4s, filter 0.4s; }

/* ⚡️ Performance: Optimized Cards */
.glass-card { width: 100%; height: 100%; background: rgba(30, 30, 33, 0.95); border: 1px solid var(--card-border); box-shadow: 0 5px 15px rgba(0,0,0,0.3); display: flex; flex-direction: column; align-items: center; padding: 24px; position: relative; border-radius: 12px; transition: all 0.3s; }
.light-mode .glass-card { background: rgba(255, 255, 255, 0.95); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }

/* ⚡️ Only Active Card gets Heavy Effects */
.is-active .glass-card { background: var(--card-bg); border-color: var(--theme); box-shadow: 0 0 30px var(--theme); backdrop-filter: blur(10px); }
.card-wrapper.is-active { -webkit-box-reflect: below 2px linear-gradient(transparent 70%, rgba(0,0,0,0.2)); }
.light-mode .is-active .glass-card { box-shadow: 0 20px 60px rgba(var(--active-color), 0.2), 0 0 0 2px var(--theme); }

.status-bar { position: absolute; top: 0; left: 0; width: 100%; height: 4px; background: var(--theme); }
.card-body { flex: 1; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; overflow: hidden; }
.icon-wrap { font-size: 3.5rem; margin-bottom: 15px; flex-shrink: 0; filter: drop-shadow(0 0 10px var(--theme)); }
.title-container { width: 100%; max-height: 3.6rem; margin-bottom: 15px; display: flex; justify-content: center; }
.title { color: var(--text-main); font-weight: 700; font-size: 1.1rem; line-height: 1.3; text-align: center; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; word-break: break-all; }
.tag { font-size: 0.7rem; color: var(--theme); border: 1px solid var(--theme); padding: 3px 8px; border-radius: 4px; text-transform: uppercase; font-weight: bold; flex-shrink: 0; }
.enter-btn { margin-top: 15px; flex-shrink: 0; font-size: 0.8rem; color: var(--text-main); background: rgba(125,125,125,0.1); padding: 8px 20px; border-radius: 20px; border: 1px solid var(--card-border); font-weight: bold; }
.light-mode .enter-btn { background: #fff; border-color: rgba(0,0,0,0.1); }

/* Control & Floor */
.control-deck { position: absolute; bottom: 40px; left: 0; width: 100%; display: flex; flex-direction: column; align-items: center; z-index: 300; pointer-events: none; }
.timeline-box { display: flex; align-items: center; gap: 20px; background: var(--hud-bg); padding: 10px 30px; border-radius: 30px; border: 1px solid var(--active-color); backdrop-filter: blur(10px); pointer-events: auto; box-shadow: 0 5px 20px rgba(0,0,0,0.1); }
.index-indicator { color: var(--active-color); font-weight: bold; width: 30px; text-align: center; }
.total-indicator { color: var(--text-main); width: 30px; text-align: center; }
.cyber-range { -webkit-appearance: none; width: 300px; height: 4px; background: rgba(125,125,125,0.3); border-radius: 2px; outline: none; }
.cyber-range::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: var(--active-color); border-radius: 50%; cursor: pointer; box-shadow: 0 0 10px var(--active-color); transition: transform 0.2s; }
.floor-grid { position: absolute; width: 200vw; height: 100vh; top: 50%; background: linear-gradient(var(--grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--grid-color) 1px, transparent 1px); background-size: 80px 80px; transform: perspective(1000px) rotateX(80deg) translateY(60px) translateZ(-100px); mask-image: radial-gradient(circle, rgba(0,0,0,0.2) 0%, transparent 60%); pointer-events: none; opacity: 0.3; transition: background 0.5s; }

@media (max-width: 768px) {
  .top-bar-safe-zone { top: 90px; padding: 0 20px; }
  .header-info { top: 140px; }
  .glitch { font-size: 2rem; }
  .card-wrapper { width: 200px; height: 300px; }
  .cyber-range { width: 160px; }
  .timeline-box { padding: 8px 15px; gap: 10px; }
  /* 移动端隐藏侧边栏,防止挤压 */
  .side-hud, .mid-decor, .top-center-decor, .big-bg-text { display: none; }
  .radar-bg { width: 150vw; height: 150vw; }
}
</style>

全局注册 (index.js)

文件路径:docs/.vitepress/theme/index.js

javascript
import ChronoRing from './components/ChronoRing.vue'
// ...
app.component('ChronoRing', ChronoRing)

入口页面 (photowall.md)

文件路径:docs/util/chronoring.md

---
layout: doc
aside: false
title: 时空档案
---

<ChronoRing />

插入首页hero区(index.md)

文件路径:MyBlog/index.md

layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界"
  tagline: 

  actions:
    - theme: brand
      text: 🌌 探索时空档案
      link: /docs/util/chronoring.md

奇点引擎

我的需求

视觉风格:硬核科幻 (Hard Sci-Fi) / 赛博朋克 (Cyberpunk)。需要一个极具视觉冲击力的 3D 机械核心作为页面主体

核心功能

  • 全屏沉浸:页面需要填满整个浏览器视口,无缝衔接导航栏(无白条空隙)

  • 交互特效

    视差效果 (Parallax):核心随鼠标移动产生 3D 倾斜,增加空间感

    超频模式 (Overcharge):点击核心触发“狂暴状态”,转速飙升、红光爆闪、界面震动

  • 多主题适配

    🌙 黑夜模式:赛博青/深邃黑背景,发光霓虹质感

    ☀️ 白天模式:实验室白瓷/高达机甲风格,实体阴影质感,深灰/橙色点缀

  • 响应式布局

    PC 端:展示完整的战术 HUD(侧边数据栏、顶部状态、底部提示)

    移动端:精简 UI,隐藏易遮挡的侧边栏,改为底部紧凑仪表盘,确保核心不被遮挡


功能概述

Singularity Reactor (奇点反应堆)

  • 纯 CSS 构建的 3D 结构,包含多层旋转环(外壳、陀螺仪)和中心的能量球(Plasma Core)
  • 利用 dashed 虚线和实线环的差速旋转,在狂暴模式下产生“摩尔纹”视觉干涉效果

Tactical HUD (战术界面)

  • Thermal/Rotor Monitor:模拟的热量和转速表,数值会随机跳动
  • System Log:滚动的代码日志流
  • Mobile Dashboard:移动端专用的底部三栏数据展示

Visual Atmosphere (氛围渲染)

  • 背景包含动态噪点 (Noise)、网格 (Grid)、旋转雷达 (Radar) 和暗角 (Vignette)
  • 战术天球 (Tactical Sphere):外围巨大的经纬线框球,填补视觉空白

实现流程

布局策略 (Layout)

  • 放弃 fixed 定位,采用 Relative + Negative Margin 策略
  • PC 端:设置 margin-top: -160px 让容器上移“钻”入导航栏底部,同时增加容器高度 (calc(100vh + 70px)) 以补偿底部被拉起的部分
  • 移动端:重置 margin-top-60px(适应手机导航栏高度),并调整 hud-frametop 值,防止按钮溢出屏幕

3D 核心构建

  • 使用 transform-style: preserve-3d 构建 3D 舞台
  • 通过 rotateX, rotateYperspective 实现透视
  • 白天模式下,通过 box-shadow: inset ... 模拟陶瓷材质的内阴影和漫反射

交互逻辑 (Vue)

  • 监听 mousemove 计算鼠标相对于屏幕中心的坐标,动态更新 CSS transform 变量实现视差
  • isOvercharged 状态控制 CSS class .mode-danger,触发加速动画 (animation-duration 变短) 和震动关键帧 (violentShake)

代码详情

组件源码 (HoloCore.vue)

文件路径:docs/.vitepress/theme/components/HoloCore.vue

vue
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useData } from 'vitepress'
import { data as allPosts } from '../utils/posts.data.mjs'

const { isDark } = useData()
const containerRef = ref(null)
const mouseX = ref(0)
const mouseY = ref(0)
const isOvercharged = ref(false)
const timeStr = ref('00:00:00')
const uptime = ref(0)
let timer = null

// 📊 模拟数据
const stats = computed(() => {
  return {
    temp: isOvercharged.value ? 850 : 320, 
    rpm: isOvercharged.value ? 3600 : 860, 
    integrity: isOvercharged.value ? 'SURGE' : 'OPTIMAL',
    files: allPosts.length
  }
})

// 🖱️ 鼠标视差
const handleMouseMove = (e) => {
  const { innerWidth, innerHeight } = window
  const x = (e.clientX - innerWidth / 2) / (innerWidth / 2) * 2
  const y = (e.clientY - innerHeight / 2) / (innerHeight / 2) * 2
  mouseX.value = x
  mouseY.value = y
}

// ⚡️ 超频模式
const toggleOvercharge = () => {
  isOvercharged.value = !isOvercharged.value
  if (isOvercharged.value) {
    setTimeout(() => { isOvercharged.value = false }, 5000)
  }
}

const updateSystem = () => {
  const d = new Date()
  timeStr.value = d.toLocaleTimeString('en-GB', { hour12: false })
  uptime.value = (performance.now() / 1000).toFixed(1)
}

const goBack = () => window.location.href = '/'

onMounted(() => {
  window.addEventListener('mousemove', handleMouseMove)
  timer = setInterval(updateSystem, 100)
})
onUnmounted(() => {
  window.removeEventListener('mousemove', handleMouseMove)
  clearInterval(timer)
})
</script>

<template>
  <div 
    class="core-stage" 
    :class="{ 'mode-danger': isOvercharged, 'light-mode': !isDark }"
  >
    
    <div class="bg-noise"></div>
    <div class="bg-grid"></div>
    <div class="bg-radar"></div>
    <div class="bg-vignette"></div>

    <div class="hud-frame">
      
      <div class="hud-top">
        <div class="top-left">
          <button class="back-btn" @click.stop="goBack">◄ EJECT</button>
        </div>
        <div class="top-center mobile-hide">
          <div class="sys-title">SINGULARITY // MK-IX</div>
          <div class="sys-subtitle">HEAVY INDUSTRY PROTOCOL</div>
        </div>
        <div class="top-right">
          <div class="status-box">
            <span class="dot"></span> {{ stats.integrity }}
          </div>
        </div>
      </div>

      <div class="hud-side left pc-only">
        <div class="panel-box">
          <div class="panel-label">THERMAL</div>
          <div class="big-val">{{ stats.temp }}°K</div>
          <div class="wave-graph">
            <div class="wave-line" v-for="i in 10" :key="i" :style="{ animationDelay: i * 0.1 + 's' }"></div>
          </div>
        </div>
        <div class="panel-box">
          <div class="panel-label">ROTOR</div>
          <div class="big-val">{{ stats.rpm }} <span class="unit">RPM</span></div>
          <div class="dial-gauge">
            <div class="dial-fill" :style="{ width: (stats.rpm / 4000 * 100) + '%' }"></div>
          </div>
        </div>
      </div>

      <div class="hud-side right pc-only">
        <div class="panel-box">
          <div class="panel-label">MATRIX</div>
          <div class="hex-grid">
            <span v-for="i in 18" :key="i">{{ Math.floor(Math.random()*16).toString(16).toUpperCase() }}</span>
          </div>
        </div>
        <div class="panel-box">
          <div class="panel-label">LOG</div>
          <div class="log-stream">
            <div class="log">> INIT_SEQ... OK</div>
            <div class="log">> DATA... {{ stats.files }} FILES</div>
            <div class="log">> UPTIME... {{ uptime }}s</div>
            <div class="log opacity-50">> LINK... SECURE</div>
          </div>
        </div>
        <div class="panel-box">
          <div class="panel-label">LOC_TIME</div>
          <div class="digital-clock">{{ timeStr }}</div>
        </div>
      </div>

      <div class="mobile-stats mobile-only">
        <div class="m-stat">
          <span class="lbl">TEMP</span>
          <span class="val">{{ stats.temp }}</span>
        </div>
        <div class="m-stat">
          <span class="lbl">RPM</span>
          <span class="val">{{ stats.rpm }}</span>
        </div>
        <div class="m-stat">
          <span class="lbl">STS</span>
          <span class="val">{{ isOvercharged ? 'ERR' : 'OK' }}</span>
        </div>
      </div>

      <div class="hud-bottom pc-only">
        <div class="deco-line"></div>
        <div class="hint-text" v-if="!isOvercharged">/// CLICK CORE TO ENGAGE ///</div>
        <div class="hint-text warning" v-else>!!! CRITICAL VELOCITY !!!</div>
        <div class="deco-line"></div>
      </div>
    </div>

    <div class="scene-container" ref="containerRef" :style="{
      transform: `perspective(1000px) rotateY(${mouseX * 8}deg) rotateX(${-mouseY * 8}deg)`
    }" @click="toggleOvercharge">
      
      <div class="tactical-sphere">
        <div class="meridian m1"></div>
        <div class="meridian m2"></div>
        <div class="equator"></div>
      </div>

      <div class="connector-lines pc-only">
        <div class="line l-tl"></div>
        <div class="line l-tr"></div>
        <div class="line l-bl"></div>
        <div class="line l-br"></div>
      </div>

      <div class="reactor-container">
        <div class="ring outer-ring"></div>
        <div class="ring outer-dashed"></div>
        
        <div class="gyro-scope">
          <div class="ring gyro-1"></div>
          <div class="ring gyro-2"></div>
          <div class="ring gyro-3"></div>
        </div>

        <div class="core-sphere">
          <div class="plasma p1"></div>
          <div class="plasma p2"></div>
          <div class="plasma p3"></div>
        </div>

        <div class="holo-text">SYSTEM ONLINE</div>
      </div>

    </div>

  </div>
</template>

<style scoped>
/* =========================================
   全局舞台
   ========================================= */
.core-stage {
  /* 🔥 PC端配置:保持你满意的效果 */
  width: 100vw; 
  height: calc(100vh + 70px); 
  
  position: relative;
  left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw;
  
  /* 这个数值对 PC 很完美,但对手机太大 */
  margin-top: -160px; 
  padding-top: 0;
  
  background: #08080a;
  overflow: hidden;
  color: #fff;
  font-family: 'Courier New', monospace;
  user-select: none;
  cursor: crosshair;

  /* 🎨 黑夜 */
  --c-bg: #08080a;
  --c-prim: #00f2ff;
  --c-sec: #0055ff;
  --c-warn: #ff0055;
  --c-hud-bg: rgba(10, 20, 30, 0.5);
  --c-border: rgba(0, 242, 255, 0.3);
  --glow: 0 0 15px rgba(0, 242, 255, 0.4);
  
  transition: background 0.5s;
}

/* ☀️ 白天模式 */
.core-stage.light-mode {
  background: #f4f6f8;
  color: #333;
  
  --c-bg: #f4f6f8;
  --c-prim: #37474f; 
  --c-sec: #90a4ae;  
  --c-warn: #ff3d00; 
  --c-hud-bg: rgba(255, 255, 255, 0.85);
  --c-border: rgba(0, 0, 0, 0.1);
  --glow: none;
}

/* 🟥 狂暴模式 */
.core-stage.mode-danger {
  --c-prim: #ff0055;
  --c-hud-bg: rgba(50, 0, 10, 0.6);
  background: #110505;
}
.mode-danger .scene-container { animation: violentShake 0.1s infinite; }

/* =========================================
   1. 背景
   ========================================= */
.bg-grid {
  position: absolute; inset: -50%;
  background: 
    linear-gradient(var(--c-border) 1px, transparent 1px),
    linear-gradient(90deg, var(--c-border) 1px, transparent 1px);
  background-size: 100px 100px;
  transform: perspective(600px) rotateX(70deg);
  opacity: 0.15; pointer-events: none;
}
.light-mode .bg-grid { opacity: 0.3; }

.bg-radar {
  position: absolute; top: 50%; left: 50%; width: 150vw; height: 150vw;
  border: 1px dashed var(--c-border); border-radius: 50%;
  transform: translate(-50%, -50%); opacity: 0.1;
  background: repeating-radial-gradient(transparent, transparent 49px, var(--c-border) 50px);
  animation: spin 120s linear infinite; pointer-events: none;
}

.bg-vignette {
  position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(circle, transparent 40%, var(--c-bg) 130%);
}

/* =========================================
   2. HUD
   ========================================= */
.hud-frame {
  position: absolute; 
  top: 100px; 
  left: 20px; right: 20px; bottom: 20px;
  border: 1px solid var(--c-border);
  clip-path: polygon(20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%, 0 20px);
  pointer-events: none; z-index: 50;
  display: flex; flex-direction: column; justify-content: space-between;
}
.light-mode .hud-frame { border-width: 2px; }

/* 顶部 */
.hud-top {
  display: flex; justify-content: space-between; align-items: center;
  padding: 15px 30px;
  background: linear-gradient(to bottom, var(--c-hud-bg), transparent);
  border-bottom: 1px solid var(--c-border);
}
.back-btn {
  pointer-events: auto;
  background: var(--c-hud-bg); border: 1px solid var(--c-prim); color: var(--c-prim);
  padding: 8px 24px; font-weight: bold; cursor: pointer; transition: 0.2s;
  font-family: 'Courier New', monospace; letter-spacing: 2px;
}
.back-btn:hover { background: var(--c-prim); color: var(--c-bg); }

.sys-title { font-size: 1rem; letter-spacing: 4px; font-weight: bold; }
.status-box { border: 1px solid var(--c-prim); padding: 5px 15px; font-size: 0.8rem; display: flex; align-items: center; gap: 8px; }
.dot { width: 8px; height: 8px; background: var(--c-prim); border-radius: 50%; animation: blink 1s infinite; }

.hud-side {
  position: absolute; top: 100px; bottom: 100px; 
  width: auto; 
  min-width: 220px;
  display: flex; flex-direction: column; gap: 20px; justify-content: center;
  padding: 25px;
  background: var(--c-hud-bg);
  border: 1px solid var(--c-border);
  backdrop-filter: blur(8px);
  z-index: 60; 
  white-space: nowrap; 
}
.left { 
  left: 40px; 
  border-left: 4px solid var(--c-prim); 
}
.right { 
  right: 40px; 
  border-right: 4px solid var(--c-prim); text-align: right; 
}

.panel-label { font-size: 0.7rem; border-bottom: 1px solid var(--c-prim); margin-bottom: 10px; padding-bottom: 4px; opacity: 0.8; letter-spacing: 2px; font-weight: bold; }
.big-val { font-size: 2rem; font-weight: bold; color: var(--c-prim); font-family: Impact, sans-serif; }
.unit { font-size: 0.8rem; font-weight: normal; font-family: monospace; }

.wave-graph { display: flex; align-items: flex-end; gap: 3px; height: 30px; margin-top: 10px; opacity: 0.7; }
.wave-line { flex: 1; background: var(--c-prim); height: 50%; animation: wave 1s infinite ease-in-out; }
@keyframes wave { 0%, 100% { height: 20%; } 50% { height: 100%; } }

.dial-gauge { width: 100%; height: 6px; background: rgba(125,125,125,0.2); margin-top: 5px; }
.dial-fill { height: 100%; background: var(--c-prim); transition: width 0.2s; }

.hex-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; font-size: 0.6rem; opacity: 0.6; font-family: monospace; }
.log-stream { font-size: 0.65rem; line-height: 1.8; opacity: 0.9; }
.digital-clock { font-size: 1.5rem; font-weight: bold; font-family: Impact, sans-serif; letter-spacing: 2px; }

.hud-bottom { text-align: center; padding: 20px; }
.hint-text { font-size: 0.8rem; letter-spacing: 6px; animation: pulseText 3s infinite; opacity: 0.7; }
.warning { color: var(--c-warn); font-weight: 900; animation: blink 0.1s infinite; font-size: 1.2rem; }
.deco-line { height: 1px; width: 400px; background: var(--c-prim); margin: 15px auto; opacity: 0.5; }

.mobile-stats {
  position: absolute; bottom: 0; left: 0; width: 100%;
  display: flex; justify-content: space-evenly;
  padding: 20px 0; background: var(--c-hud-bg);
  border-top: 1px solid var(--c-border);
  backdrop-filter: blur(15px);
}
.m-stat { text-align: center; }
.m-stat .lbl { display: block; font-size: 0.6rem; opacity: 0.7; margin-bottom: 4px; letter-spacing: 1px; }
.m-stat .val { font-size: 1.2rem; font-weight: bold; color: var(--c-prim); font-family: Impact, sans-serif; }

/* =========================================
   3. 核心 3D 场景
   ========================================= */
.scene-container {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  transform-style: preserve-3d;
  transform: translateY(-100px);
}

.tactical-sphere { position: absolute; top: 50%; left: 50%; width: 0; height: 0; transform-style: preserve-3d; animation: spin 120s linear infinite; }
.meridian { position: absolute; top: 50%; left: 50%; border-radius: 50%; border: 1px solid var(--c-border); opacity: 0.2; transform: translate(-50%, -50%); }
.m1 { width: 80vh; height: 80vh; }
.m2 { width: 80vh; height: 80vh; transform: translate(-50%, -50%) rotateY(90deg); }
.equator { position: absolute; top: 50%; left: 50%; width: 90vh; height: 90vh; border: 1px dashed var(--c-border); border-radius: 50%; opacity: 0.15; transform: translate(-50%, -50%) rotateX(90deg); }

.connector-lines .line { position: absolute; background: var(--c-border); transform-origin: center; opacity: 0.4; }
.l-tl { top: 180px; left: 320px; width: 200px; height: 1px; transform: rotate(35deg); }
.l-tr { top: 180px; right: 320px; width: 200px; height: 1px; transform: rotate(-35deg); }
.l-bl { bottom: 180px; left: 320px; width: 200px; height: 1px; transform: rotate(-35deg); }
.l-br { bottom: 180px; right: 320px; width: 200px; height: 1px; transform: rotate(35deg); }

/* ☢️ 核心本体 */
.reactor-container {
  width: 400px; height: 400px;
  position: relative;
  transform-style: preserve-3d;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
  transition: transform 0.2s cubic-bezier(0.1, 0.7, 0.1, 1);
}
.reactor-container:hover .plasma { transform: scale(1.1); }

.ring {
  position: absolute; top: 50%; left: 50%; border-radius: 50%;
  border: 2px solid var(--c-prim);
  box-shadow: var(--glow);
  transform: translate(-50%, -50%); transform-style: preserve-3d;
}
.light-mode .ring { border-width: 3px; border-color: #90a4ae; box-shadow: none; }
.light-mode .outer-ring { border-color: var(--c-prim); border-width: 4px; }

.outer-ring { width: 380px; height: 380px; border-left: 0; border-right: 0; animation: spin 30s linear infinite; }
.outer-dashed { width: 340px; height: 340px; border: 2px dashed var(--c-border); animation: spin 45s linear infinite reverse; }

.gyro-scope { position: absolute; inset: 0; transform-style: preserve-3d; animation: float 6s ease-in-out infinite; }
.gyro-1 { width: 280px; height: 280px; border-top: 4px solid var(--c-prim); border-bottom: 4px solid var(--c-prim); animation: spin3D_1 15s linear infinite; }
.gyro-2 { width: 240px; height: 240px; border-left: 2px solid var(--c-prim); border-right: 2px solid var(--c-prim); animation: spin3D_2 20s linear infinite; }
.gyro-3 { width: 200px; height: 200px; border: 1px dotted var(--c-prim); animation: spin3D_3 25s linear infinite; }

.core-sphere { width: 100px; height: 100px; position: relative; transform-style: preserve-3d; }
.plasma {
  position: absolute; inset: 0; border-radius: 50%;
  background: radial-gradient(circle, #fff 0%, var(--c-prim) 60%, transparent 100%);
  mix-blend-mode: screen; opacity: 0.9;
  box-shadow: 0 0 50px var(--c-prim);
  animation: pulse 4s infinite;
}
.light-mode .plasma {
  background: radial-gradient(circle at 35% 35%, #fff, #eceff1, #b0bec5);
  mix-blend-mode: normal; 
  box-shadow: inset 0 -10px 20px rgba(0,0,0,0.1), 0 10px 30px rgba(0,0,0,0.15);
  border: 1px solid rgba(255,255,255,0.8);
  opacity: 1;
}

.p1 { transform: rotateY(0deg); }
.p2 { transform: rotateY(60deg); }
.p3 { transform: rotateY(-60deg); }

.holo-text {
  position: absolute; bottom: -50px; left: 50%; transform: translateX(-50%);
  font-size: 0.7rem; letter-spacing: 4px; color: var(--c-prim);
  text-shadow: var(--glow); font-weight: bold;
}
.light-mode .holo-text { text-shadow: none; color: #555; }

.mode-danger .outer-ring { animation: spin 2s linear infinite; }
.mode-danger .outer-dashed { animation: spin 2.3s linear infinite reverse; } 
.mode-danger .gyro-1 { animation: spin3D_1 1.5s linear infinite; border-width: 3px; }
.mode-danger .gyro-2 { animation: spin3D_2 1.8s linear infinite; border-width: 3px; }
.mode-danger .gyro-3 { animation: spin3D_3 1.2s linear infinite; }
.mode-danger .plasma { animation: flash 0.1s infinite; filter: brightness(1.5); }

@keyframes spin { from { transform: translate(-50%, -50%) rotate(0deg); } to { transform: translate(-50%, -50%) rotate(360deg); } }
@keyframes spin3D_1 { 0% { transform: translate(-50%, -50%) rotateX(0) rotateY(0); } 100% { transform: translate(-50%, -50%) rotateX(360deg) rotateY(180deg); } }
@keyframes spin3D_2 { 0% { transform: translate(-50%, -50%) rotateZ(0); } 100% { transform: translate(-50%, -50%) rotateZ(-360deg); } }
@keyframes spin3D_3 { 0% { transform: translate(-50%, -50%) rotateY(0) rotateX(45deg); } 100% { transform: translate(-50%, -50%) rotateY(360deg) rotateX(45deg); } }
@keyframes pulse { 0%, 100% { transform: translate(-50%, -50%) scale(0.95); } 50% { transform: translate(-50%, -50%) scale(1.05); } }
@keyframes pulseText { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
@keyframes flash { 0%, 100% { opacity: 1; scale: 1.1; } 50% { opacity: 0.7; scale: 0.9; } }
@keyframes violentShake {
  0% { transform: translate(0, 0); } 25% { transform: translate(3px, -3px); } 50% { transform: translate(-3px, 3px); } 75% { transform: translate(3px, 3px); } 100% { transform: translate(0, 0); }
}

@media (max-width: 900px) {
  /* 移动端适配 */
  .mobile-hide { display: none; }
  .pc-only { display: none; }
  .mobile-only { display: flex; }
  
  /* 🔥 移动端核心修复区域 🔥 */
  .core-stage {
    /* 1. 消除移动端过大的负边距,还原到正常位置 */
    margin-top: -50px; 
    height: 100vh;
  }

  .hud-frame { 
    /* 2. 调整顶部距离,让按钮稳稳显示在屏幕内 */
    top: 80px; 
    inset: 15px; clip-path: none; border-radius: 12px; border: 1px solid var(--c-border); 
  }
  .hud-top { padding: 10px 15px; border-bottom: none; }
  
  /* 3. 核心缩小上移 */
  .reactor-container { 
    transform: scale(0.8) translateY(-40px); 
  }
  .tactical-sphere { transform: scale(0.8); }
}
@media (min-width: 901px) {
  .mobile-only { display: none; }
}
</style>

全局注册 (index.js)

文件路径:docs/.vitepress/theme/index.js

javascript
import HoloCore from './components/HoloCore.vue'
// ...
app.component('HoloCore', HoloCore)

入口页面 (photowall.md)

文件路径:docs/util/holocore.md

---
layout: doc
aside: false
title: 奇点引擎
---

<HoloCore />

插入首页hero区(index.md)

文件路径:MyBlog/index.md

layout: home

hero:
  name: "PandaC's Space"
  text: "探索代码与光影的边界"
  tagline: 

  actions:
    - theme: alt
      text: ☢️ 启动奇点引擎
      link: /docs/util/holocore

集成 Mermaid 图表支持

1. 需求背景 本地使用 Typora 写作时经常使用 Mermaid 绘制流程图和思维导图。为了保证文章发布到博客后,这些图表能正确渲染(而不是显示为一坨代码),需要给 VitePress 安装 Mermaid 插件

2. 实现方案 使用插件:vitepress-plugin-mermaid

3. 操作步骤

Step 1: 安装依赖 在项目根目录下运行:npm install mermaid vitepress-plugin-mermaid -D

Step 2: 修改配置文件 编辑 docs/.vitepress/config.mjs,使用插件提供的 withMermaid 函数包裹原本的配置

javascript
import { withMermaid } from 'vitepress-plugin-mermaid'

export default withMermaid(
    defineConfig({ //原先的defineConfig也被withMermaid包裹一层
        xxx
    })
)

Step 3: 重启服务 修改配置后,必须重启本地开发服务器 (npm run docs:dev) 才能生效

4. 效果 博客网页端现在支持缩放、拖拽 Mermaid 图表,实现了与 Typora 本地写作体验的一致性

博客文字劫持变色

将Markdown中的高亮(<mark></mark>==x==)与下标(<sub>x</sub>~x~(截止260210,已选用删除线)分别调整为红色字体蓝色字体。这样就能让单调的文档,显示出一丁点儿的颜色,结合英语翻译,更好写文章

把 Markdown 的语义标签(高亮、删除线)转义为视觉标签(红色、蓝色),需要做的就是重写 CSS 样式

本地端配置 (Typora)

详见:Typora本地配置文字变色


网页端配置 (VitePress)

默认情况下,Markdown 标准语法不支持 == (高亮) 和 ~ (下标),我们需要先给 VitePress 安装插件让它识别这两个符号,然后用 CSS 改颜色

Step 1: 安装插件 在项目根目录终端运行:

bash
npm install markdown-it-mark markdown-it-sub -D
  • 卸载markdown-it-sub: npm uninstall markdown-it-sub,VitePress 默认支持 ~~删除线~~ 语法,所以不需要额外安装或注册 markdown-it-strike 之类的插件

Step 2: 在 config.mjs 中启用 修改 docs/.vitepress/config.mjs,引入并注册这两个插件:

javascript
import { defineConfig } from 'vitepress'
// ... 其他引入
import markdownItMark from 'markdown-it-mark'
// ❌ 删除下面这一行:
// import markdownItSub from 'markdown-it-sub'

export default withMermaid(
  defineConfig({
    // ... 其他配置
    
    markdown: {
      lineNumbers: true,
      config: (md) => {
        // 启用 ==高亮==
        md.use(markdownItMark)
        //删: 启用 ~下标~
        // md.use(markdownItSub)
      }
    },
    
    // ...
  })
)

Step 3: 添加“劫持” CSS 打开 docs/.vitepress/theme/custom.css (如果没有就新建,并在 index.js 引入),添加以下代码:

css
/* 🔴 劫持高亮 (mark/==) 为红色字体 */
mark {
  color: #d32f2f; /* 鲜艳的红色 */
  background-color: transparent; /* 去掉原本的黄色背景 */
  font-weight: bold; /* 加粗一点更醒目 */
  padding: 0 2px;
}


/* 🔵 劫持删除线 (s/~~) 为蓝色字体 */
/* 原理:把横线去掉,把字变蓝 */
s, del {
  color: #1976d2; /* 护眼的蓝色 */
  text-decoration: none; /* 【核心】去掉那条删除线 */
  background-color: transparent;
  font-weight: bold;
}

还有index.js中引入:import './custom.css'

文件下载卡片

功能简介

为了替代枯燥的文字链接(如 [点击下载](...)),开发了一个可视化的文件卡片组件 FileCard

  • 特性
    • 自动识别类型:根据 type 参数自动匹配图标(Word/PPT/PDF/Zip 等)和主题色。
    • 深色模式适配:完美融入 VitePress 的黑夜模式。
    • 零依赖:纯 Vue3 + CSS 实现,无额外图标库负担。
  • 配合服务:建议文件存储于 Cloudflare R2(配置方法见《Cloudflare R2 配置笔记》

开发实现

Step 1: 创建组件文件

新建 docs/.vitepress/theme/components/FileCard.vue

vue

Step 2: 注册全局组件

修改 docs/.vitepress/theme/index.mjs

javascript
import DefaultTheme from 'vitepress/theme'
import FileCard from './components/FileCard.vue' // 引入

export default {
  ...DefaultTheme,
  enhanceApp({ app }) {
    app.component('FileCard', FileCard) // 注册
  }
}

使用指南

在 Markdown 文章中直接使用 HTML 标签调用

基础语法

html
<FileCard 
  title="文件名称" 
  url="下载链接(推荐使用R2链接)" 
  size="文件大小" 
  type="文件类型" 
/>

参数说明 (Props)

参数说明示例必填
title卡片上显示的标题"复习提纲.docx"
url文件的直链"https://files.xxx.com/a.doc"
size文件大小说明"2.5MB"
type决定图标和颜色"word", "ppt", "zip", "pdf"❌ (默认灰色)

加密页面

我的需求

创建一个私密的日记/心得板块(例如 docs/diary

安全性:需要真正的访问拦截,而不是简单的前端密码(防君子不防小人)

隐私性:私密文章的标题、日期不能出现在首页的“近期文章”、“归档”或“热力图”中

源码保护:GitHub 仓库不能被公开查看

功能介绍

采用 “代码私有 + 访问控制 + 数据隔离” 的企业级安全方案

  • 物理隔绝:源码在 GitHub 私有仓库,外人无法查看 Markdown 源文件
  • 访问拦截:使用 Cloudflare Zero Trust,在网络层直接拦截未授权访问,通过邮箱验证码登录
  • 数据清洗:修改本地构建脚本,确保私密文章不会被打包进公共数据(JSON)中

实现方法

线上安全配置 (Cloudflare + GitHub)

这部分实现了“进不去、看不了源码”。既然已经用了 Cloudflare 部署,有一套零代码、企业级安全、且免费的终极方案:Cloudflare Access (Zero Trust) + 私有仓库

第一步:把 Github 仓库设为 Private (私有)

这是物理隔绝。只要仓库是私有的,没人能通过 Github 看到你的日记源码

  1. 去 Github 仓库页面 -> Settings

  2. 拉到最下面 Danger Zone -> Change repository visibility

  3. 改为 Private

    注:Cloudflare Pages 支持部署私有仓库,不影响你网站上线


第二步:配置 Cloudflare Access (给特定页面加锁)

Cloudflare 有一个功能叫 Zero Trust,可以给网站的特定路径(比如 /diary)加一个极其强大的验证层。

  1. 进入 Cloudflare 控制台

    点击左侧侧边栏的 Zero Trust(首次使用需开通,免费版支持 50 人,需设置 TeamName 并绑定 PayPal/信用卡,0扣费)

  2. 创建应用 (Application)

    • 在 Zero Trust 面板中,点击 Access -> Applications -> Add an application
    • 选择 Self-hosted
  3. 配置保护路径

    Application name: 随便填,比如 "My Diary"

    Session Duration: 比如 1 Month(输一次密码管一个月)

    Subdomain: 你的博客域名(如 pandac-blog.com

    Path: 填 docs/diary (这就是你要保护的目录)

  4. 设置访问规则 (Policy):这部分要先配置好,然后再进入“Add an application”页面进行选择Policy

    Rule name: "Only Me"

    Action: Allow

    Configure rules:

    • Selector: 选择 Emails
    • Value: 填你自己的 Gmail 邮箱(⚠️强烈建议:QQ/163 邮箱极易拦截 Cloudflare 验证码,务必使用 Gmail)

然后保存即可


本地数据隔离(VitePress Config + Markdown)

这部分实现了“搜不到、看不见标题”

第一步:目录与文件设置

  1. 建立目录:docs/diary/

  2. Frontmatter 设置:在私密文章头部添加 search: false,防止被站内搜索索引。

    yaml
    ---
    title: 我的日记
    date: 2026-01-01
    search: false  # <--- 关键!禁止搜索索引
    ---

第二步:修改数据加载器 (Data Loaders)

为了防止私密文章出现在“首页列表”、“归档页”和“热力图”中,需要修改 docs/utils/ 下的 .mjs 文件。

  • 修改对象posts.data.mjsactivity.data.mjs
  • 修改内容:统一使用双重过滤(Glob 排除 + JS 过滤)

posts.data.mjs 为例(activity.data.mjs 同理):

javascript
import { createContentLoader } from 'vitepress'

// 1. Glob 模式排除:使用 !**/private/** 通杀匹配
export default createContentLoader(['docs/**/*.md', '!**/private/**'], {
  includeSrc: false,
  render: true,
  transform(rawData) {
    return rawData
      .filter(page => {
        // ... 原有过滤逻辑 ...
        
        // 2. JS 双重保险:如果 URL 包含 private,直接踢出
        if (page.url.includes('/private/')) return false; 
        
        return true;
      })
      // ... 后续逻辑 ...
  }
})

最终效果

路人访问

  • 在首页、归档、热力图完全看不到私密文章的痕迹
  • 即使手动输入 pandac-blog.com/docs/diary/,页面会被 Cloudflare 拦截,显示登录框

本人访问

  • 输入链接 -> 拦截页面 -> 输入邮箱 -> 接收 6 位验证码 -> 验证通过
  • 成功进入,像浏览普通文章一样阅读日记

项目维护

基于 VitePress 搭建 | 记录生活与代码