个人博客空间
后续使用指南:博客使用指南
项目介绍
心痒痒:
还是对个人博客/作品网的搭建”贼心不死“
已经确定原始笔记采用Markdown形式,现在的问题是如何构建一个项目(网站),然后还有承载这个项目的服务器(要么给大型网站当孙子分一个服务器,要么自己买一个云服务器)
确认方案
我的需求
这是对AI说的重要Prompt,表达了我的初始需求与目标。
我有一个想法,做一个自己的博客网/作品网,因为我有用markdown写笔记和教程,包括编程,摄影,修图剪辑,office等等内容,还有我自己拍的照片和视频。我用的是typora,然后也有图床和阿里云oss存图片。我应该用什么方案我想做一个属于自己的网站,能够查看这些内容,尤其是好照片和markdown笔记博客。我以前也用hexo➕githubpages搞过博客网,但是随着内容的增加,每次上传都要更新所有文件太慢了,而且我的笔记在某个位置A,我为了上传git必须再搞一个目录B用来专门存放要上传的笔记,同样的内容我又复制了一份,对于内容管理来说太麻烦了。我想现在的情况大概是这样,一方面我需要搞定这个项目的网页显示,一方面我需要有一个挂载服务器。网页我大概率是用现有的漂亮的架构来做,服务器我暂时不太想自己买一个云服务器,可能还是挂载到github或者其他有保障的大平台。你见多识广,根据我的需求,你推荐有什么方法呢?
技术方案
这是一个非常“懂行”的配置组合。Typora (本地编辑体验天花板) + Aliyun OSS (解决了图床和宽带问题),这说明你已经解决了最麻烦的静态资源管理问题
现在的瓶颈纯粹在于**“内容同步”和“构建效率”:既然你是计科专业的,你应该拒绝“手动复制粘贴”这种低效操作。结合你的需求(代码、Office教程、摄影作品展示),我为你推荐“VitePress + 软链接 (Symlink) + Cloudflare Pages”** 的方案
为什么选择这个方案?
**VitePress (架构层):**速度快、适配优、扩展好
**速度够快:**基于 Vite 和 Vue 3,本地启动和热更新是毫秒级的。以前用过的Hexo框架,命令
hexo g随着文章变多会越来越慢,但是VitePress 不会气质契合: VitePress 也就是 Vue 官方文档的生成器。它的默认风格非常适合**“技术教程”**(你的编程和Office内容),左侧目录树,右侧内容,逻辑清晰
Vue 扩展性: 你提到有摄影作品。在 Hexo 里改相册很难受,但在 VitePress 里,你可以直接写一个简单的 Vue 组件(或者找个现成的 Lightbox 插件),在 Markdown 里像写 HTML 一样插入相册,配合你的阿里云 OSS 链接,加载速度飞快
**软链接 / Symbolic Link (解决复制痛点):**用文件链接实现笔记的双地址管理
这是计算机系学生该用的方法。欺骗文件系统,而不是移动文件
你可以保持你的笔记在
D:/MyKnowledge/不动,让博客项目的docs目录通过“软链接”直接指向你的笔记目录效果: 你在 Typora 里保存笔记,浏览器里的博客预览界面实时自动刷新。不需要复制,不需要点同步
Cloudflare Pages (服务器层):
免费且快: 比 GitHub Pages 快,且自带 CDN
解决了 HTTPS 和 域名: 自动分配二级域名,支持绑定自定义域名
我的疑惑
与AI再次交流:对于这个方案,我的问题是:
- 既然我要放基础的md文档、Office文件、图片(甚至是视频),那么vite框架有足够丰富的插件实现这些内容的展示放映吗?以及我是直接下载vite纯净文件搭建博客还是用别人的基于vite的二次开发的框架,哪个好?
- 对于我的博客文件的管理,比如说我把原始笔记放在D:\PandaC\StudyNotes\MyKnowledge 目录之下,然后博客网的目录是D:\MyBlog ,你说的软链接是指在MyBlog目录下将笔记指向MyKnowledge目录吗?如果说我的MyKnowledge目录里面有很多文件夹和文件,我要怎么做网站上笔记类型的区分?比如说这个是旅游板块,那个是编程板块,另一个又是作品展示模块,各自的板块里面又有好多分类,最终可以导向若干个笔记或者作品。这样的分类逻辑是要我在MyBlog目录下的网站文件中写好,还是在我本地的原始文件目录下规划好?以及为了不上传私人笔记,需要搞一个Public目录在MyKnowledge目录里面是吗?那里面的笔记和作品需要我分类吗?
- 还有,当我今天新写了几份笔记或者新建了几份作品,我要如何上传到博客网?需要用到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 配置文件)
最高效的方案:本地文件夹即分类
**目录结构建议:**示例如下(实际情况自行变动)
PlaintextD:\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)。这不是为了增加冗余,而是为了自动化“把笔记从私有库搬运到博客发布库”的过程
你的终极工作流(只需点击一次):
- 写笔记: 在
D:\PandaC\...\Blog_Public里写,Typroa 随便改,图片随便插 - 发布: 双击
D:\MyBlog里的deploy.bat(Windows 批处理脚本)
这个脚本里写什么?(核心逻辑)
这个脚本大概只有 5 行代码,它帮你在几秒钟内完成以下动作:
Robocopy (镜像同步): 把你的
Blog_Public文件夹的内容,物理复制(镜像)到D:\MyBlog\docs注:Robocopy 是 Windows 自带的神器,速度极快,只复制有变动的文件
Git Add/Commit/Push: 自动进入
D:\MyBlog,执行 git 命令推送到 GitHub
回答你的顾虑:
上传会不会很慢?
极快。 因为图片都在阿里云 OSS(图床),你上传到 GitHub 的只有纯文本 Markdown 代码。几百篇笔记也就几 MB,秒传
Cloudflare 检测到 GitHub 变动,自动在云端构建,通常 1-2 分钟网站就更新了
脚本是浪费时间吗?
它是节省时间的神器。 它可以让你完全不用管
MyBlog这个项目目录。你只需要关注你的笔记目录你想想,如果没有这个脚本,每次写完文章,你得:打开博客目录 -> 手动复制文件 -> 打开 Git 终端 -> 输入三行命令。这才是冗余
总结方案的可行性
这个方案对你(CS 大四学生)来说是性价比最高的:
- 内容层: 继续用 Typora + OSS,体验无敌
- 管理层: 本地建立
Blog_Public文件夹分类,物理隔离隐私笔记 - 技术层: 纯净版 VitePress,不够用了再加 Vue 组件
- 自动化层: 写一个
.bat脚本(用到 Robocopy 和 Git),实现一键发布
项目构建
这个方案的核心逻辑是:“本地双目录架构” + “脚本一键同步”
①初始化 VitePress
我们要先在本地把博客项目的“壳子”搭好
创建博客项目文件夹 不要在你的笔记目录里建,找一个干净的地方(比如
D:\MyBlog)。 打开终端(CMD 或 PowerShell),进入该目录:powershellmkdir D:\MyBlog cd D:\MyBlog初始化项目 依次执行以下命令(全默认回车即可):
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 BlogSite description:->A cool CS student's portfolioTheme:-> Default Theme (选默认的,最稳)Use TypeScript?-> No (除非你特别想用 TS,否则 JS 够用且简单)Add VitePress npm scripts to package.json?-> Yes
第一次启动预览 安装完成后,输入:
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 右键 -> 编辑,把下面的代码粘贴进去(注意修改里面的路径为你自己的实际路径!):
@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
初始化 Git 在
D:\MyBlog目录下打开终端:git init创建 .gitignore 文件 在
D:\MyBlog下新建一个文件叫.gitignore(没有文件名,只有后缀),用记事本打开,填入以下内容(防止把垃圾文件传上去):node_modules .DS_Store dist cache在 GitHub 上建仓,登录你的 GitHub,右上角
+->New repositoryRepository name 填
my-blog(或者任何你喜欢的名字)Public (必须是 Public,Cloudflare 免费版才好用),其他都不勾选,点
Create repository关联并推送 GitHub 会给你跳出一个页面,找到 "…or push an existing repository from the command line" 那一栏的代码。 在你的
D:\MyBlog终端里执行那几行代码:powershellgit remote add origin https://github.com/你的用户名/my-blog.git git branch -M main git push -u origin main(如果这一步报错,可能是你还没在本地登录 Git,需要配置
git config --global user.name等)
报错解决
一番战
报错代码:
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'解决方法:
暂存所有文件(装包):
git add .(注意 add 后面有一个空格和一个点,意思是把当前目录下所有文件放入暂存区)
提交文件 (封口):
git commit -m "首次提交:初始化博客"(这一步执行完,你会看到一大串提示 create mode...,说明文件被记录下来了,此时
main分支才正式诞生)再次推送(发货):
git push -u origin main
然后登录github即可,如果看到 Branch 'main' set up to track remote branch 'main' from 'origin'.,那就是大功告成!
二番战
报错代码:
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 登录密码)是不被接受的
解决方法:
去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_开头的乱码字符串。 立刻、马上把它复制下来,保存在你的记事本里! (因为如果你刷新页面,这串代码就再也看不到了,只能重新生成)清除旧的错误凭证 (防止电脑自作聪明)
因为你刚才失败了一次,Windows 可能会傻傻地记住那个错误的密码,导致你下次不再弹窗询问。我们先把它清掉
点击电脑左下角搜索框,搜 "凭据管理器" 或 "Credential Manager" 并打开
点击 "Windows 凭据" (Windows Credentials)
在列表里找有没有
git:https://github.com这一项- 如果有: 点击它,选择 删除 (Remove);如果没有: 直接跳过这一步
再次推送 (见证奇迹)
回到你的 CMD 黑框框(
D:\MyBlog目录下),再次输入:bashgit 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
- 打开 https://dash.cloudflare.com/
- 登录后,在左侧侧边栏找到并点击 "Workers & Pages"
第二步:创建 Pages 项目
点击页面上的蓝色按钮 "Create application" (创建应用)
在上方选项卡中,点击 "Pages"
点击 "Connect to Git" (连接到 Git)
选择 GitHub 页签,然后点击 "Connect to GitHub"
*如果是第一次,它会弹窗让你授权,选 All repositories (所有仓库) 即可
在列表里选中你刚才推上去的仓库
my-blog,点击 "Begin setup"
第三步:配置构建环境 (关键!别填错)
Cloudflare 会自动识别你的仓库,但我们需要告诉它这是一个 VitePress 项目
在 "Set up builds and deployments" 页面:
Project name: 保持默认(比如
my-blog),这决定了你的初始域名(例如my-blog.pages.dev)Production branch: 默认应该是
main,不用动Framework preset (框架预设):
点击下拉菜单,找到并选择 VitePress
Build command (构建命令):
选了预设后,这里应该会自动变成
npm run docs:build如果没有变,请手动填入:*
npm run docs:buildBuild output directory (构建输出目录):
选了预设后,这里可能会自动变成
docs/.vitepress/dist⚠️ 注意: 根据你刚才提交的文件结构(
package.json在根目录),你的输出目录大概率是在根目录下的。请把这里手动改为:.vitepress/dist(以此为准:因为你的
config.mjs是在.vitepress文件夹里,而不是docs/.vitepress)点击底部的 "Save and Deploy" (保存并部署)
第四步:等待起飞
Cloudflare 现在会开始疯狂工作:拉取代码 -> 安装 Node 环境 -> 执行构建命令 -> 分发到全球 CDN。
你会看到一个控制台日志在跑,大概等待 1-2 分钟。当看到 "Success! Your site is deployed" 的绿色提示时,点击那个链接(通常是 https://my-blog-xxx.pages.dev)
第五步:进阶优化(修复“最后更新时间”失效问题)
你可能会发现一个 BUG:当你查看网站时,所有文章的“最后更新时间”都显示为“刚刚”(即构建的那一刻),而不是你真正写文章的时间。
原因: Cloudflare 为了构建速度,默认只下载你仓库的最后一次提交记录(这叫浅克隆),导致它不知道文章的历史修改时间,以为所有文件都是新创建的。
解决方法:
在 Cloudflare 的项目页面,点击顶部的 Settings (设置) 选项卡。
在左侧菜单点击 Builds & deployments (构建和部署)。
找到 Build configurations (构建配置) 区域,点击右侧的 "Edit" (编辑) 按钮。
修改 Build command (构建命令):
- 将原来的
npm run docs:build - 修改为:
git fetch --unshallow && npm run docs:build - (这段代码的意思是:先强行把所有历史记录扒下来,然后再开始构建)
- 将原来的
点击 Save (保存)
手动触发一次部署:
你可以回到 Deployments (部署) 选项卡,点击最新的那次部署右边的三个点,选择 Retry deployment (重试部署)。
或者随便修改一下你本地的一个文件(比如加个空行),推送到 GitHub,Cloudflare 就会自动触发新的构建。
等待这次构建完成后,你文章底部的“最后更新时间”就会恢复正常,显示真实的修改日期了
验证成果
打开那个链接,你应该能看到一个简洁漂亮的 VitePress 默认页面,上面写着 "VitePress" 或者 "Hello World"。

⑥实际笔记上传
现在的状态是:网站骨架已经搭好了,但内容还是默认的。
接下来的任务就是让电脑里的真实笔记能自动跑到这个网站上去,并且在菜单栏里能点出来。
第一步:验证“一键同步脚本” (测试你的自动化工作流)
我们先别急着改网页配置,先确保你的那个 .bat 脚本能正常工作
准备测试弹药:
去你的“原始内容库”:
D:\PandaC\StudyNotes\MyKnowledge\Blog_Public随便建个文件夹,比如叫
Coding。在里面新建一个 Markdown 文件,叫Hello.md,随便写点什么(比如“这是我的第一篇自动同步笔记”)(如果你之前已经建了,就改几个字,确保有变动)
发射!
找到你放在
D:\MyBlog下的deploy.bat脚本。双击运行它。观察黑框框:
有没有显示
Files : X(复制了几个文件)?有没有显示
Pushing to GitHub?最后有没有显示
Deployment Complete?去 GitHub 查岗:
打开你的 GitHub
my-blog仓库页面。刷新一下,看看是不是多了一个
docs文件夹?点进去看看你的
Hello.md在不在里面。
如果这一步成功了,说明 原始笔记地址->博客项目地址->上传云端 已经打通了! Cloudflare 会自动感应到这次推送,并开始构建(大概 1 分钟后生效)
第二步:配置菜单栏 (让笔记能被看见)
虽然笔记传上去了,但网站导航栏(Home, Examples)里还没它的入口,访客找不到。我们需要修改配置文件
打开配置文件:
用 VS Code 或 记事本 打开
D:\MyBlog\.vitepress\config.mjs。修改导航栏 (nav):
找到
themeConfig下面的nav部分。我们需要加一个指向你刚才上传内容的链接。把代码改成类似这样(注意格式):(下面的示例是一个例子,实际不必照抄!!)
javascriptimport { 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文件夹结构是这样的:PlaintextBlog_Public └── 01.Coding └── Hello.md*如果你建的文件夹不叫
Coding,或者文件名不叫Hello.md,请务必把代码里的/docs/Coding/Hello改成你实际的路径!然后保存文件,再次运行
deploy.bat,再去网页查看即可!
第三步:见证奇迹
等待 1-2 分钟后,刷新你的网站页面
- 看标题: 左上角是不是变成了 "PandaC's Blog"?
- 看菜单: 右上角是不是多了一个 “编程笔记” 的按钮?
- 点击它: 能不能跳转到你写的那个
Hello.md页面?
到这里,一个基础的基于Vitepress框架的博客网已经搭建完成。接下来要做的,就是网站样式的装修,然后好好使用博客网O,o啦
网站装修
接下来我们要把这个“骨架”填上血肉,并把“门面”装修得漂亮一点。
修改首页 index.md
现在的首页还是 VitePress 默认的 "VitePress" 大标题。我们需要把它改成你的个人介绍,并把按钮指向你的真实内容。
打开文件:
D:\MyBlog\index.md全部替换: 用下面的代码替换里面的内容(我帮你设计了一个符合“程序员+摄影师”风格的首页)
此为一个首页内容的例子文件,不必照抄
--- 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>保存,然后运行
deploy.bat去网页刷新首页会变成一个很酷的渐变色标题,按钮直接通往笔记
自动化侧边栏
现在的 config.mjs 里的 sidebar 是手动写死的:(例子:)
items: [
{ text: 'Java 基础', link: '/docs/01.Coding/Hello' },
{ text: '我的猫', link: '/docs/02.Photography/中央公园' }
]这意味着:以后你每写一篇新笔记,除了要写文章,还得专门跑来改这个配置文件,加一行代码。 这太反人类了,也不符合“自动化”的初衷
解决方案:安装自动侧边栏插件 我们要让 VitePress 自动扫描你的文件夹,自动生成侧边栏。
安装插件: 在
D:\MyBlog目录下打开终端,输入:bashnpm install vitepress-sidebar --save-dev修改配置 (一劳永逸): 再次打开
D:\MyBlog\.vitepress\config.mjs,做最后一次大改:这一次,我们需要做两件事:
- 在文件最顶端引入插件
- 把原来的手动
sidebar删掉,换成自动配置
请直接复制下面的完整代码,覆盖你原来的
config.mjs:此为一个例子文件,不必照抄
javascriptimport { 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' } ] } })保存并运行
deploy.bat
三大板块直达目录
问题分析
你的需求: 导航栏放三个大按钮:技术、摄影、英语。点击直接进入该板块的“封面页/目录页”,而不是具体的某一篇文章
核心逻辑: VitePress(以及大多数 Web 服务器)的规则是:当你访问一个目录(例如 /docs/01.Coding/)时,它会自动寻找该目录下的 index.md 文件进行展示
行动方案: 我们需要为这三个板块,分别建立自己的“门户封面” (index.md)
解决方法
第一步:完善目录结构 (建立封面)
在 Blog_Public 文件夹(也就是 docs 对应的源目录),确保结构如下,并新建缺失的 index.md:
示例:
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 部分:
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 配置:
// 侧边栏配置:分板块扫描
// 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,步骤同下(#^.^#)
第一步:装修新分店 (创建文件)
先建立文件夹和封面页,不然配置好了也没东西显示。
- 在
docs下新建文件夹:04.Share。 - 在里面新建
index.md(封面)。 - (可选) 再建一个
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,替换 hero 和 features 部分:
---
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/下的
01.Coding02.Visuals(从Photography改为Visuals,更贴合“视觉工坊”)博客网相关内容,均由Photography改为Visuals!笔记中若有Photography,亦不影响阅读
03.English04.Share05.Growth
然后四个板块:docs/下的
- util:工具板块,比如说todolist、Heatmap、照片墙等等
- private:私密板块
- game:游戏成品展示板块
- blender:建模、动画成品展示板块
然后是一个总归档:docs/下的
- archive.md
增加搜索框
找到 themeConfig 下的 nav,替换为以下逻辑(注意路径要对应你真实的文件夹):
themeConfig: {
// 开启本地搜索 (这是让网站变高级的第一步)
search: {
provider: 'local'
}, provider: 'local'
}解决“Logo 是空的”
没有 Logo 的网站就像没有头像的微信,由于你不想自己设计复杂的 SVG,我们有两个方案:
方案 A:使用 Emoji 或 文字 Logo (最快) VitePress 支持直接用文字或 Emoji 当 Logo
方案 B:使用图片 Logo (推荐) 既然你有阿里云 OSS,找一张你满意的照片(比如你的摄影作品截取一个局部,或者你的头像),上传到 OSS,拿到链接
操作:修改 config.mjs
export default defineConfig({
// ...
themeConfig: {
// 在 themeConfig 的最上面添加
logo: 'https://你的阿里云OSS链接/logo.png', // 支持图片
// 或者用文字:
// siteTitle: 'PandaC Space', // 左上角的文字
// ...
}
})解决“首页太空”
首页默认的只有文字。加一张高大上的配图瞬间就不一样了
1. 给 Hero 区域加图 找一张你拍的最好的照片(或者找一张赛博朋克风格的代码图),上传 OSS。
操作:修改 index.md
---
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
// ... 在 themeConfig 里添加
footer: {
message: '基于 VitePress 搭建 | 记录生活与代码',
copyright: 'Copyright © 2024-present PandaC'
},
// ...解决“作品展示不好看”
作品和文档都是 .md。对于代码笔记,默认的布局(左侧目录,右侧文章)很好。但对于摄影作品,左边有个目录栏会很干扰看图
优化技巧:为摄影作品关闭侧边栏
你可以在你的摄影作品 Markdown 文件(比如 中央公园.md)的最开头(Frontmatter 区域)
加上特定配置:
---
# 使用 'doc' 布局,但隐藏侧边栏,让图片显示区域更宽
layout: doc
aside: false # 关闭右侧大纲
sidebar: false # 关闭左侧侧边栏 (可选,如果你想沉浸式展示图片)
---
# 中央公园摄影集

这样,摄影页面就会变得很宽敞,适合展示大图
增加“元数据” (显得像个活跃的博客)
在文章底部显示“最后更新时间”和“编辑此页”,是技术博客的标配。
操作:修改 config.mjs
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部分添加关于作者
// 优化后的导航栏
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文件:
---
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 里,而是最外层):
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:
---
layout: page
---
# 404 Not Found 🚧
**哎呀,迷路了!**
你寻找的页面可能去**中央公园**拍照了,或者正在**修 Bug**。
[👉 返回首页](/)开启代码块行号 (Code Line Numbers)
博客里肯定会有很多代码。默认的 VitePress 代码块是没有行号的。开启行号后,代码看起来会更像 IDE(VS Code),非常专业,方便别人引用(比如:“看第 5 行代码...”)
操作步骤: 打开 config.mjs,在 themeConfig 的外面(也就是和 title、description 平级的地方),添加 markdown 配置:
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格式的播放。
实现流程
这是一个从本地生产到云端存储再到前端展示的完整闭环:
后期导出:PR/达芬奇导出高画质母版(MP4),无需过度压缩,保全画质。
格式转换 (FFmpeg):使用 FFmpeg 将母版转码并切片为 HLS (
.m3u8+.ts碎片)。详见:插入钩子
上传云端 (R2):使用 Cyberduck 或 S3 Browser 将切片后的整个文件夹上传至 Cloudflare R2 存储桶。
详见:插入钩子
博客展示:在 Markdown 中调用
<MyPlayer />组件,填入.m3u8链接
配置详情
Step 1: 安装依赖 (NPM)
在项目根目录运行,安装播放器核心库及 HLS 解码引擎:
npm install artplayer
npm install artplayer hls.js -DStep 2: 创建组件 (Vue)
新建/更新 docs/.vitepress/theme/components/MyPlayer.vue,代码支持自动识别 m3u8:
<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 中已注册:
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 链接,组件会自动使用原生模式播放
<MyPlayer
title="简单的演示"
url="https://files.pandac-blog.com/demo.mp4"
poster="https://files.pandac-blog.com/img/cover.jpg"
/>场景 B:Vlog/大视频 (M3U8) - 推荐 使用 FFmpeg 转码后的索引链接,享受秒开体验
<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 规则:
- 登录 Cloudflare -> R2 -> 点击你的存储桶 (Bucket)。
- 点击 Settings (设置) 选项卡。
- 往下拉找到 CORS Policy (CORS 策略)。
- 点击 Add/Edit CORS Policy,粘贴以下 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),是你的博客从“玩具”变成“正规军”的最重要一步。
买域名的好处:
- 解决 R2 问题: 一旦你有了
xxx.com,你就可以轻松设置video.xxx.com给 R2 用。 - 专业且好记: 告别
my-blog-7iw...这种乱码,别人一眼就能记住。 - 完全掌控: 以后就算你不用 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)
第二步:安装 SDK
在博客项目根目录下运行:
npm install leancloud-storage第三步:编写 Todo 组件
在 .vitepress/theme/components/ 下新建 TodoList.vue
这个代码实现了:
- 密码验证: 只有输入正确密码,
isAuth才会变 true - 云端同步: 增删改查直接操作 LeanCloud,手机电脑秒同步
<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>第四步:注册并使用
全局注册组件: 在
.vitepress/theme/index.js中注册TodoList(你应该已经很熟练了)创建入口页面: 创建一个专门的页面,比如
docs/about/todolist.mdyaml--- layout: doc --- # 📅 个人待办事项 <TodoList />添加到导航栏(可选): 如果你不想让别人太容易找到,可以不加到导航栏,只把它作为一个“隐藏关卡”,你需要用的时候手动输入网址
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 文件。
<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
<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 注入到所有页面的最底部。
<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
作用:解决侧边栏遮挡和主页内容高度塌陷问题
/* 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
作用:引入新布局和新样式文件。
import DefaultTheme from 'vitepress/theme'
// 引入自定义的 Layout
import MyLayout from './Layout.vue'
// 引入自定义的样式 (修复布局用)
import './style.css'
export default {
...DefaultTheme,
// 替换默认 Layout
Layout: MyLayout,
enhanceApp({ app }) {
// 其他组件注册...
}
}部署检查清单
- 文件位置:确认
Layout.vue在theme根目录下,MagicFooter.vue在theme/components下。 - 样式引入:确认
index.js里写了import './style.css'。 - 性能验证:本地运行
npm run docs:dev,滚动到最底部查看效果,移开视口确认风扇是否安静。
博客 UI 视觉与交互重构
需求文档
项目名称:博客 UI 视觉与交互重构 (Visual & Interactive Overhaul) 参考风格:Trae.ai (暗黑科技风 / 沉浸式网格)
- **双模态适配 **:
- **暗黑模式 **:复刻 Trae 的深邃感,黑底 + 微光网格 + 动态光斑
- 明亮模式:保持干净整洁,使用浅色底 + 灰色网格,确保文字可读性,不强制使用黑色背景
- 全站沉浸式覆盖:
- 动态光影和网格背景必须覆盖整个网页(包括首页和文章页)
- 不能出现滚动页面时背景断层(截断)的情况
- UI 组件透明化:
- 顶部导航栏 (Navbar)、左侧侧边栏 (Sidebar)、底部页脚 (Footer) 不能是实色块,必须进行磨砂/半透明处理,让背景的网格和光效能透出来,形成一体感
- 动态效果优化:
- 这部分注释掉代码,影响观看
- 背景的光斑(光影)移动速度需加快,拒绝“死图感”,要呈现明显的流动呼吸效果
- 保留搜索框原有的背景底板,优化使用体验
设计思路与操作流程
为了实现“双模态 + 全覆盖”,我们不能简单暴力地给 div 加颜色,而要利用 VitePress 的CSS 变量系统和类名选择器(html.dark vs html:not(.dark))。
核心策略:
- 挂载点上移:将背景和网格挂载到
body标签上(通过 CSS 选择器),而不是里面的某个div,确保 100% 全屏覆盖。 - 变量控制:分别定义白天和黑夜两套背景变量。
- 透视处理:强制重写 VitePress 默认的导航栏和侧边栏背景变量,改为
rgba半透明色 +backdrop-filter高斯模糊。
实施代码
请打开 .vitepress/theme/style.css。复制以下代码(不删除原有的MagicFooter内容)
/* =======================================================
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; }验证
保存文件后,请进行以下操作来验收效果:
切换黑白模式:点击右上角的太阳/月亮图标。
期待效果:
- 黑模式:深黑背景,紫色蓝色光斑快速流动,白色网格清晰。
- 白模式:纯白背景,无光斑(或极淡),灰色网格,干净整洁。
滚动页面:在首页和文章页上下滚动。
期待效果:背景的光影是固定在屏幕上的(类似看窗外风景),不会随着文字滚动而断裂。
检查侧边栏:进入任意一篇文章。
期待效果:左侧目录栏不再是实心的灰色块,而是半透明的磨砂玻璃,你可以隐约看到背后的网格在滑动。
检查动画速度:
期待效果:背景的光晕现在应该有明显的“呼吸感”和位移,不再像一张静态图片。
这套代码实现了全站视觉语言的统一,既保留了 Trae 的酷炫,又照顾了日常阅读的舒适度。
首页粒子雪样式
我的需求
嗯,这次你说的内容就算是我的博客网UI重构了,然后我想单独再对首页进行样式的添加,和刚才的功能算区分开来吧,这次的首页添加样式,我希望像“雪”或者“雨”一样有粒子或者像素的移动,因为现在的首页看起来还是比较单调。然后对应两种观看模式,只在暗黑模式下,添加首页的效果,白天模式就不变动。在黑暗模式下添加粒子变化效果,也不修改刚才已经调教好的UI
这次的需求非常明确:仅在首页 + 暗黑模式下,增加一层**“数字雪/像素雨”的动态粒子效果。这属于“锦上添花”的视觉层,不变动刚才辛苦调好的 CSS 背景,而是通过新增一个 Vue 组件挂载到首页来实现。这样的好处是解耦**:如果你不喜欢了,删掉这个组件就行,完全不会破坏之前的样式
核心思路
组件化:创建一个名为
HomePixelSnow.vue的组件智能判断:利用 VitePress 的
useData()API,实时监听两个状态:frontmatter.layout === 'home'(是否在首页?)isDark === true(是否是暗黑模式?)只有当两者同时满足时,才启动 Canvas 动画;否则直接销毁或隐藏,节省性能
层级策略:
Previous Background (光斑/网格)
z-index: -1New Pixel Snow (本组件)
z-index: 1(位于背景之上,文字之下)Content (文字/卡片)
z-index: 10
操作流程
步骤 1:新建粒子组件
在 .vitepress/theme/components/ 目录下,新建文件 HomePixelSnow.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,修改为:
<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>效果验收
现在保存所有文件,查看页面:
场景 A:首页 + 暗黑模式
你会看到刚才设置好的深邃背景之上,有一些细小的、正方形的**“像素点”**在缓缓飘落
就像《黑客帝国》或者是赛博朋克城市里的电子雨,非常有科技感
它位于文字下方,不会挡住你的标题
场景 B:首页 + 白天模式
粒子瞬间消失,背景变回干净的浅灰网格
场景 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 的运行逻辑是这样的:
- 用户访问网页。
- VitePress 读取
Layout.vue。 - 它看到
<HomePixelSnow />—— “哦,这里要挂个投影仪。” - 它看到
<Content />—— “哦,这里显示文章内容。” - 它看到
<MagicFooter />—— “哦,这里放个跑步机。” - 最后,它读取
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):
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):
<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
代码详情:
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):
---
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. 部署流程
- 创建组件:编写 Vue 组件
Archives.vue,负责数据清洗、分组和渲染 - 全局注册:在 VitePress 主题入口注册该组件
- 创建页面:新建
archive.md,作为归档组件的容器页 - 关联入口:(已在首页完成) 确保首页的 "Archive" 按钮指向该页面
3. 代码详情
第一步:创建归档组件 docs/.vitepress/theme/components/Archives.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
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 />现在的效果
- 首页红框:依然只显示“最近发布的 6 篇”,保持页面简洁。
- 点击 "Archive":会跳转到
/docs/archive页面。 - 归档页内:
- 你会看到类似 2025、2024 的大标题。
- 标题下面列出了那一年你写的所有文章,按日期排好了。
- 右边还贴心地显示了我们之前做好的
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 变换下依然锐利清晰。
- 智能光感:根据鼠标位置实时计算光斑坐标,模拟真实材质的反光效果。
部署流程
创建组件:
docs/.vitepress/theme/components/HomeNews.vue(左侧新闻)docs/.vitepress/theme/components/HomeProfile.vue(右侧名片)docs/.vitepress/theme/components/HomeSidebars.vue(总容器)全局注册:在
theme/index.js中注册HomeSidebars引入页面:在
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,失败则显示静态的高价值技术链接
<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)
<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)显示,解决笔记本屏幕显示不全的问题
<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项目依赖)
部署流程
- 环境准备:安装 Three.js 库 (
npm install three) - 组件封装:创建
Saturn3D.vue,在onMounted中初始化 WebGL 渲染器,绘制土星本体、光环及星空背景,并实现动画循环 - 布局集成:在
Layout.vue中引入该组件,并通过 Vue 的插槽 (#home-hero-before) 将其放置在首页 Hero 区域 - 性能优化(最终方案):在
Layout.vue中增加 JS 判断,仅当屏幕宽度大于 960px 时才将showSaturn设为true,否则组件压根不挂载
代码详情
(1) Saturn3D.vue (Three.js 核心组件)
<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 时 showSaturn 为 false,彻底阻断组件加载。
<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 |
搭建流程
第一步:依赖更替
在终端执行以下命令,清理旧库并安装新库:
npm uninstall medium-zoom
npm install viewerjs第二步:核心逻辑配置 (index.js)
文件路径:docs/.vitepress/theme/index.js
// .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
/* =======================================================
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
/* =======================================================
📋 右侧大纲自动编号 (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 写了大量的《新概念英语》翻译和纠错笔记。每次遇到生词,我都要面临一个两难选择:
- 记在 Markdown 里:写起来快,但写完就“沉底”了,很少会专门翻出来背,缺乏复习机制
- 录入背单词 App:功能强,但太累了!我刚在笔记里打了一遍,又要去 App 里再输一遍。而且,App 里的单词往往脱离了语境,我只记得 Epiphany 是“顿悟”,却忘了当时是在哪篇课文中看到它的
既然我的博客是基于 VitePress 构建的,而 VitePress 在打包时会扫描所有 Markdown 文件。那我为什么不写个脚本,让它在打包时顺便把我的笔记“读”一遍,把里面的生词和句子抓出来,自动生成一个复习题库呢?
我的核心需求:
- 零冗余:绝不维护两份数据。笔记写完,题库即更新
- 就地取材:不搞复杂的数据库,Markdown 原文就是数据库
- 场景回溯:背单词时忘了怎么用?点一下卡片,直接跳转回那篇笔记的原文位置
- 格式宽容:无论我习惯用
-还是*做列表,程序都得认
实现流程
制定数据规范:
为了让程序能精准抓取内容,我们需要约定简单的 Markdown 书写格式
书写规则:
- 触发标题:任意层级的标题包含
单词卡片或句子卡片(如## 单词卡片、#### 句子卡片) - 列表格式:支持
-或*开头的无序列表 - 内容分隔:使用
::分隔问题和答案
✅ 示例 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
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
<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>注册与部署:
全局注册 (
docs/.vitepress/theme/index.js):vueimport FlashCard from './components/FlashCard.vue' export default { // ... enhanceApp({ app }) { app.component('FlashCard', FlashCard) } }创建入口页 (
docs/utils/flashcard.md):--- title: 英语记忆胶囊 --- # 🧠 英语记忆胶囊 每天刷 5 分钟,积少成多。 <FlashCard />重启服务: 修改了
.data.mjs后,必须重启终端 (Ctrl+C然后npm run docs:dev) 才能生效
每日灵感胶囊
我的需求
在这个信息过载的时代,我存了太多的“好东西”在笔记里——某本书里的一句话、某个大佬的观点、或者深夜的一点感悟。 把它们扔在文件夹里吃灰太可惜了。 我需要一个**“灵感胶囊”**:它像老式日历一样,每天只给我看一条。不多,不少,刚刚好。它藏在我的工具箱里,不喧宾夺主,只等待有缘(其实就是我自己)的点击
使用规则
为了让程序能精准地从我的笔记海洋里捞出这些金句,我们约定一个简单的“暗号”
📝 书写规则:
文件位置:
docs/05.Growth/素材积累/名言金句.md抓取锚点:任意层级标题包含
名言金句(例如## 名言金句)内容格式:
无序列表(支持
-或*)格式:
内容 :: 出处(出处可选,没有就默认是你自己)
✅ 示例 (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
这个脚本负责精准定位到 名言金句 标题,把它下面的列表“偷”出来
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
这里我们加一点“私密感”。考虑到你不想太直接地展示,我设计了一个**“磨砂模糊”**的初始状态。鼠标放上去(或点击)才会清晰显示
<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,给它发个“准生证”
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
<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
import PixelHud from './components/PixelHud.vue'
export default {
// ...
enhanceApp({ app }) {
app.component('PixelHud', PixelHud)
}
}部署页面
文件路径:docs/05.Growth/index.md
# 🌏 认识世界
xxxyyy介绍
<br>
<PixelHud />
---
## 📚 文章列表配置指南
直接修改 PixelHud.vue 顶部的常量区:
- 基础信息:修改
username(ID)、profession(职业)、birth(生日)。 - 属性六维:修改
stats数组,每个对象包含label(属性名,建议单字) 和value(数值 0-100)。 - 任务进度:修改
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
逻辑:精准锁定 摄影作品 中文目录,递归抓取所有子文件夹
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,支持视频图标
<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
import ChinaMap from './components/ChinaMap.vue'
export default {
// ...
enhanceApp({ app }) {
app.component('ChinaMap', ChinaMap)
}页面使用
文件:docs/02.Photography/index.md
---
title: 视觉工坊
---
# 📷 视觉工坊
<ChinaMap />录入示例
在 docs/02.Photography/摄影作品/ 下的任意子目录中创建文件:
📷 普通照片:
---
title: 洪崖洞夜景
date: 2025-01-15
location: 重庆(只能用中文!)
cover: https://...
aside: false # 关闭右侧大纲
sidebar: true # 不关闭左侧侧边栏
---🎬 视频内容:
---
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 数组供组件使用
- 扫描:在构建阶段(Build Time),利用 VitePress 的
③视图渲染与交互: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
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
<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
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 在物理刚体上实时绘制文字
注册与挂载:全局注册组件并替换首页旧内容
代码详情
第一步:安装依赖
请在项目根目录下运行终端命令:
npm install matter-js第二步:准备数据抓取脚本
我们要写一个脚本,扫描 01.Coding 目录下所有的文章,统计 Tags(标签) 的出现频率
请在 docs/.vitepress/theme/utils/ 下新建 radar.data.mjs
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:
<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>第四步:注册并使用
注册组件: 确保在
docs/.vitepress/theme/index.js中注册了这个新组件:javascriptimport TechGravity from './components/TechGravity.vue' // ... 其他组件引入 export default { // ... enhanceApp({ app }) { // ... 其他组件注册 app.component('TechGravity', TechGravity) } }挂载到页面: 打开
docs/01.Coding/index.md,清空或添加以下内容:# 💻 技术博客 <TechGravity />
如何让它动起来?
现在你只需要去 01.Coding 下的随便几篇文章里,确保 Frontmatter 有 tags 字段:
---
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。如果之前没装过,请运行:
npm install three第二步:编写数据抓取脚本
我们需要一个“侦察兵”,去扫描你的文件夹,看看你到底学了多少
创建文件:docs/.vitepress/theme/utils/monument.data.mjs
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
<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,注册它:
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 Grid和Flexbox绘制 52列 x 7行 的网格 - 编写
getLevel函数根据文章数映射 GitHub 风格颜色 - 实现 混合交互逻辑:区分 Hover(仅桌面)和 Click(通用锁定)
- 实现 拖拽逻辑:监听
mousedown,mousemove,mouseup实现容器的滚动
应用层:
- 在
theme/index.js中注册组件 - 在 Markdown 文件中调用
代码详情
数据加载脚本
文件路径:docs/.vitepress/theme/utils/activity.data.mjs
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
<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
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-width和margin: auto,强制组件占满宽度并左对齐。 - 去除
padding,消除左侧的人为缝隙,与页面边缘对齐。
全局注册:在 theme/index.js 中注册该组件。
页面挂载:在 index.md 的 Hero 配置下方直接调用组件。
代码详情
Vue 组件 (HomeDock.vue)
文件路径:docs/.vitepress/theme/components/HomeDock.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
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
功能期望:
- 智能关怀:根据不同时间段(早安、午休、深夜)显示不同的暖心文案
- 生活提醒:特别关注喝水、吃早餐、运动和周末拍照
- 数据联动:最好能根据我的更文状态(热力图)给出反馈
交互体验:
- 平时保持极简(类似“灵动岛”的胶囊形态)
- 点击后展开“打卡面板”,可以记录喝水杯数和运动状态
- 移动端适配:在手机上要紧凑美观,不能留白太多
功能概述
多维智能决策系统:组件内部有一套优先级逻辑,决定当前显示什么内容:
- 🚨 深夜警告 (最高级):凌晨 0-5 点访问,显示红色警告,催促睡觉
- ✍️ 更文表扬:检测到今日热力图有数据且为晚间,显示绿色表扬
- 📷 周末模式:周六/日白天,提示出门摄影或放松
- 🕰️ 日常轮播:根据早/中/晚随机抽取文案库中的问候语
双模式交互:
- 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
<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 解决了移动端顶部大片留白的问题
<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
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
<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
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文件(数据提取)
<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
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-frame的top值,防止按钮溢出屏幕
3D 核心构建:
- 使用
transform-style: preserve-3d构建 3D 舞台 - 通过
rotateX,rotateY和perspective实现透视 - 白天模式下,通过
box-shadow: inset ...模拟陶瓷材质的内阴影和漫反射
交互逻辑 (Vue):
- 监听
mousemove计算鼠标相对于屏幕中心的坐标,动态更新 CSStransform变量实现视差 isOvercharged状态控制 CSS class.mode-danger,触发加速动画 (animation-duration变短) 和震动关键帧 (violentShake)
代码详情
组件源码 (HoloCore.vue)
文件路径:docs/.vitepress/theme/components/HoloCore.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
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 函数包裹原本的配置
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)
网页端配置 (VitePress)
默认情况下,Markdown 标准语法不支持 == (高亮) 和 ~ (下标),我们需要先给 VitePress 安装插件让它识别这两个符号,然后用 CSS 改颜色
Step 1: 安装插件 在项目根目录终端运行:
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,引入并注册这两个插件:
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 引入),添加以下代码:
/* 🔴 劫持高亮 (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:
Step 2: 注册全局组件
修改 docs/.vitepress/theme/index.mjs:
import DefaultTheme from 'vitepress/theme'
import FileCard from './components/FileCard.vue' // 引入
export default {
...DefaultTheme,
enhanceApp({ app }) {
app.component('FileCard', FileCard) // 注册
}
}使用指南
在 Markdown 文章中直接使用 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 看到你的日记源码
去 Github 仓库页面 -> Settings
拉到最下面 Danger Zone -> Change repository visibility
改为 Private
注:Cloudflare Pages 支持部署私有仓库,不影响你网站上线
第二步:配置 Cloudflare Access (给特定页面加锁)
Cloudflare 有一个功能叫 Zero Trust,可以给网站的特定路径(比如 /diary)加一个极其强大的验证层。
进入 Cloudflare 控制台
点击左侧侧边栏的 Zero Trust(首次使用需开通,免费版支持 50 人,需设置 TeamName 并绑定 PayPal/信用卡,0扣费)
创建应用 (Application)
- 在 Zero Trust 面板中,点击 Access -> Applications -> Add an application
- 选择 Self-hosted
配置保护路径:
Application name: 随便填,比如 "My Diary"
Session Duration: 比如
1 Month(输一次密码管一个月)Subdomain: 你的博客域名(如
pandac-blog.com)Path: 填
docs/diary(这就是你要保护的目录)设置访问规则 (Policy):这部分要先配置好,然后再进入“Add an application”页面进行选择Policy
Rule name: "Only Me"
Action:
AllowConfigure rules:
- Selector: 选择
Emails - Value: 填你自己的 Gmail 邮箱(⚠️强烈建议:QQ/163 邮箱极易拦截 Cloudflare 验证码,务必使用 Gmail)
- Selector: 选择
然后保存即可
本地数据隔离(VitePress Config + Markdown)
这部分实现了“搜不到、看不见标题”
第一步:目录与文件设置
建立目录:
docs/diary/Frontmatter 设置:在私密文章头部添加
search: false,防止被站内搜索索引。yaml--- title: 我的日记 date: 2026-01-01 search: false # <--- 关键!禁止搜索索引 ---
第二步:修改数据加载器 (Data Loaders)
为了防止私密文章出现在“首页列表”、“归档页”和“热力图”中,需要修改 docs/utils/ 下的 .mjs 文件。
- 修改对象:
posts.data.mjs和activity.data.mjs - 修改内容:统一使用双重过滤(Glob 排除 + JS 过滤)
以 posts.data.mjs 为例(activity.data.mjs 同理):
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 位验证码 -> 验证通过
- 成功进入,像浏览普通文章一样阅读日记
