feat: init Astro blog with wikilinks, KaTeX and local search

- Astro 5 static blog, zero runtime JS
- Obsidian wikilink remark plugin (slug matches Astro content collection)
- KaTeX math rendering, local search, TOC, archives, tags
- 5 posts on math/physics/ML topics
- deploy scripts for nginx + certbot + rsync
This commit is contained in:
yukun-hh 2026-08-14 00:19:33 +08:00
commit 12dd13a76f
39 changed files with 19371 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# 依赖与构建产物
node_modules/
dist/
.astro/
# 日志与系统文件
*.log
.DS_Store
# 环境变量(避免误传密钥)
.env
.env.*
# 编辑器
.vscode/
.idea/

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# Yukun's Blog
一个基于 [Astro](https://astro.build) 5 的静态博客。液态玻璃质感、淡蓝配色、零运行时 JS支持 KaTeX 公式、Obsidian wikilink、本地搜索、文章目录与归档时间轴。
## 环境要求
- Node.js ≥ 20.3(建议 20 或 22 LTS
- npm ≥ 10
## 快速开始
```bash
# 1. 安装依赖
npm install
# 2. 本地开发http://localhost:4321
npm run dev
# 3. 生产构建(输出到 dist/
npm run build
# 4. 本地预览构建产物
npm run preview
# 5. 类型检查
npm run check
```
## 写作
文章放在 `src/content/posts/` 下,格式为 Markdown`.md`。frontmatter 字段:
```yaml
---
title: "文章标题"
date: 2026-08-13 # 必填,发布日期
updatedDate: 2026-08-14 # 可选,更新日期
description: "摘要" # 可选
tags: ["数学", "AI"] # 可选
draft: false # true 时本地可见、构建不发布
pinned: false # true 时首页置顶大卡片
heroGradient: ["#7fb8ff", "#2f8df0"] # 可选,封面渐变色
---
```
### Obsidian wikilink
正文支持 Obsidian 风格的双链语法(由 `src/remark-wikilinks.mjs` 处理):
```markdown
[[#页内锚点]]
[[笔记名]] → /posts/笔记名
[[笔记名#锚点]] → /posts/笔记名#锚点
[[笔记名|显示文字]] → 自定义显示文字
```
> ⚠️ **重要**Astro 内容集合会用 github-slugger 生成 URLASCII 大写自动转小写),
> 例如 `变分下界ELBO笔记.md` 的实际路径是 `/posts/变分下界elbo笔记`
> 链接指向的**标题必须与文章内实际标题完全一致**,且**页内锚点必须指向真实存在的标题**(加粗段落不是标题,不会被生成锚点)。
### 数学公式
支持 `$...$` 行内公式与 `$$...$$` 块级公式,使用 KaTeX 渲染。
## 目录结构
```
├── astro.config.mjs # 构建配置remark/rehype 插件、site
├── deploy/ # 服务器部署脚本nginx + rsync + certbot
├── public/ # 静态资源
└── src/
├── components/ # UI 组件(导航、卡片、目录、搜索弹窗等)
├── content/posts/ # 文章Markdown 源)
├── layouts/ # 页面布局
├── lib/utils.ts # 文章读取、格式化、阅读时间等工具
├── pages/ # 页面路由(首页、文章列表、归档、标签、关于)
├── remark-wikilinks.mjs # Obsidian wikilink 转换插件
└── styles/ # 全局样式
```
## 部署到服务器
`deploy/` 目录提供一键部署脚本(构建 → rsync → reload nginx
```bash
# 首次部署(上传 nginx 配置)
./deploy/deploy.sh setup-nginx
# 申请 HTTPS 证书
./deploy/deploy.sh certbot
# 以后每次发布
./deploy/deploy.sh
```
部署脚本的服务器信息SSH 用户、域名、站点目录)在 `deploy/deploy.sh` 顶部「配置区」中修改。
## 常见问题
**改了 `remark-wikilinks.mjs` 或 Markdown 渲染逻辑后构建没生效?**
Astro 5 会把内容渲染结果缓存在 `node_modules/.astro/data-store.json`,修改渲染插件后需先删除缓存再构建:
```bash
rm -rf node_modules/.astro
npm run build
```

38
astro.config.mjs Normal file
View file

@ -0,0 +1,38 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import remarkWikilinks from './src/remark-wikilinks.mjs';
// https://astro.build/config
export default defineConfig({
site: 'https://sausagetoast.cloud',
// 构建产物目录nginx 指向此目录)
build: {
format: 'directory',
},
// 不使用 Astro 图片优化(环境无法编译 sharp用 noop 服务兜底
image: {
service: { entrypoint: 'astro/assets/services/noop' },
},
integrations: [
mdx(),
sitemap({
filter: (page) => !page.includes('/draft'),
}),
],
markdown: {
// 代码高亮:淡蓝底 + 柔和色调
shikiConfig: {
themes: {
light: 'catppuccin-latte',
dark: 'catppuccin-latte',
},
wrap: true,
},
// 数学公式:$...$ 行内,$$...$$ 块级,用 KaTeX 渲染
remarkPlugins: [remarkMath, remarkWikilinks],
rehypePlugins: [[rehypeKatex, { throwOnError: false, strict: false, output: 'html' }]],
},
});

307
deploy/deploy.sh Executable file
View file

@ -0,0 +1,307 @@
#!/usr/bin/env bash
# Yukun's Blog · 部署脚本
#
# 用法:
# ./deploy/deploy.sh 博客:构建 → rsync → reload nginx
# ./deploy/deploy.sh setup-nginx 博客上传并启用站点配置HTTP 直通)
# ./deploy/deploy.sh certbot 博客:签 HTTPS 证书并自动启用 443 + 80 跳转
# ./deploy/deploy.sh doctor 诊断并清理残留的 baidu 反代配置
#
# 子域名反代请用同目录的 proxy.sh如 ./deploy/proxy.sh db 7000
# 首次使用前改下面「配置区」。建议以 root 运行;非 root 需配好免密 sudo。
set -euo pipefail
# ====== 配置区(按你的 VPS 修改) ======
REMOTE_USER="root" # SSH 用户
REMOTE_HOST="sausagetoast.cloud" # VPS 地址 / 主域名
REMOTE_DIR="/var/www/yukun" # 博客站点根目录
NGINX_RELOAD=1 # 1=博客发布后 reload nginx
# ========================================
SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SSH_OPTS="-o StrictHostKeyChecking=accept-new"
# 通用远端执行(支持 &&root 免 sudo非 root 自动 sudo
remote_run() {
local cmd="$1"
ssh $SSH_OPTS "$SSH_TARGET" "bash -s" <<REMOTE
set -euo pipefail
SUDO=""
[ "\$(id -u)" -ne 0 ] && SUDO="sudo"
if [ -n "\$SUDO" ]; then exec \$SUDO bash -c '$cmd'; else $cmd; fi
REMOTE
}
# 启用 HTTPS 的 awk 程序(取消 443 段注释 + 80 段切跳转 + 注释 include
# 同时适用于博客与 db共用 @@HTTPS@@ / @@HTTPS-REDIRECT@@ / @@HTTP-SERVE@@ 标记)
read -r -d '' ENABLE_HTTPS_AWK <<'AWKPROG' || true
BEGIN { in443=0; inredir=0; inserv=0 }
/@@HTTPS-START@@/ { in443=1; next }
/@@HTTPS-END@@/ { in443=0; next }
/@@HTTPS-REDIRECT-START@@/ { inredir=1; next }
/@@HTTPS-REDIRECT-END@@/ { inredir=0; next }
/@@HTTP-SERVE-START@@/ { inserv=1; next }
/@@HTTP-SERVE-END@@/ { inserv=0; next }
in443 && /^# / { sub(/^# ?/, ""); print; next }
in443 && /^#/ { sub(/^#/, ""); print; next }
inredir && /^[[:space:]]*#.*return 301 https:/ { sub(/^[[:space:]]*#[[:space:]]*/, ""); print; next }
inserv && /^[[:space:]]*include / && !/^[[:space:]]*#/ { $0 = "#" $0; print; next }
{ print }
AWKPROG
export ENABLE_HTTPS_AWK
# ----------------------------------------------------------------
# 通用:上传并启用一个站点(主配置 + 共享片段)
# $1 site 域名,如 sausagetoast.cloud / blog.sausagetoast.cloud
# $2 main_conf deploy/ 下的主配置文件名
# $3 serve_conf deploy/ 下的片段文件名
# $4 snippet 片段在远端 /etc/nginx/snippets/ 下的名字
# ----------------------------------------------------------------
do_setup_site() {
local site="$1" main_conf="$2" serve_conf="$3" snippet="$4"
echo "==> 上传 $main_conf + $serve_conf${SSH_TARGET} ..."
scp $SSH_OPTS "$ROOT_DIR/deploy/$main_conf" "$SSH_TARGET:/tmp/yukun-main.conf"
scp $SSH_OPTS "$ROOT_DIR/deploy/$serve_conf" "$SSH_TARGET:/tmp/yukun-serve.conf"
echo "==> 远端启用站点 $site ..."
ssh $SSH_OPTS "$SSH_TARGET" "SITE='$site' SNIPPET='$snippet' ENABLE_HTTPS_AWK=\"$(printf '%s' "$ENABLE_HTTPS_AWK" | base64)\" bash -s" <<'REMOTE'
set -euo pipefail
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
SITE="$SITE"; SNIPPET="$SNIPPET"
ENABLE_HTTPS_AWK_b64="$ENABLE_HTTPS_AWK"; unset ENABLE_HTTPS_AWK
_enable_https() {
local conf="$1"
printf '%s' "$ENABLE_HTTPS_AWK_b64" | base64 -d > /tmp/enable-https.awk
$SUDO bash -c 'f="$1"; awk -f /tmp/enable-https.awk "$f" > "$f.tmp" && mv "$f.tmp" "$f"' _ "$conf"
rm -f /tmp/enable-https.awk
}
# 1) 共享片段 → /etc/nginx/snippets/<snippet>
$SUDO mkdir -p /etc/nginx/snippets
$SUDO cp /tmp/yukun-serve.conf "/etc/nginx/snippets/$SNIPPET"
echo " 片段: /etc/nginx/snippets/$SNIPPET"
# 2) 站点配置:优先 sites-available(Debian),其次 conf.d(CentOS)
CONF=""
if [ -d /etc/nginx/sites-enabled ] || $SUDO [ -d /etc/nginx/sites-enabled ]; then
AVAIL=/etc/nginx/sites-available; EN=/etc/nginx/sites-enabled
$SUDO mkdir -p "$AVAIL" "$EN"
$SUDO cp /tmp/yukun-main.conf "$AVAIL/$SITE"
$SUDO ln -sfn "$AVAIL/$SITE" "$EN/$SITE" # 幂等
if ! $SUDO grep -q "sites-enabled" /etc/nginx/nginx.conf 2>/dev/null; then
echo " nginx.conf 缺 include sites-enabled/*,自动追加"
$SUDO sed -i '/http {/a\ include /etc/nginx/sites-enabled/*;' /etc/nginx/nginx.conf
fi
CONF="$AVAIL/$SITE"
echo " 站点: $CONF (+软链接 $EN/$SITE)"
else
$SUDO mkdir -p /etc/nginx/conf.d
$SUDO cp /tmp/yukun-main.conf "/etc/nginx/conf.d/$SITE.conf"
CONF="/etc/nginx/conf.d/$SITE.conf"
echo " 站点: $CONF"
fi
# 3) 清理另一位置的重复配置(防止 conflicting server name 警告)
if [ "$CONF" = "/etc/nginx/sites-available/$SITE" ]; then
if $SUDO [ -f "/etc/nginx/conf.d/$SITE.conf" ]; then
$SUDO rm -f "/etc/nginx/conf.d/$SITE.conf"
echo " 已移除重复配置 /etc/nginx/conf.d/$SITE.conf"
fi
else
if $SUDO [ -f "/etc/nginx/sites-available/$SITE" ]; then
$SUDO rm -f "/etc/nginx/sites-available/$SITE" "/etc/nginx/sites-enabled/$SITE"
echo " 已移除重复配置 sites-available/$SITE"
fi
fi
# 4) 若证书已存在(重跑场景),自动启用 HTTPS
if $SUDO [ -f "/etc/letsencrypt/live/$SITE/fullchain.pem" ]; then
echo "==> 检测到已有证书,自动启用 HTTPS ..."
_enable_https "$CONF"
fi
echo "==> 测试 nginx 配置 ..."
$SUDO nginx -t
echo "==> reload nginx ..."
$SUDO systemctl reload nginx || $SUDO systemctl restart nginx
rm -f /tmp/yukun-main.conf /tmp/yukun-serve.conf
echo "✓ 站点 $SITE 已启用HTTP 直通)"
echo " 下一步(可选):申请 HTTPS 证书"
REMOTE
}
# ----------------------------------------------------------------
# 通用:为一个站点签 HTTPS 证书并自动启用 443 + 80 跳转
# $1 site 域名
# ----------------------------------------------------------------
do_certbot_site() {
local site="$1"
echo "==> 远端为 $site 申请证书webroot 方式)..."
echo " 前置:站点已 setup$site 已解析到本机、80 端口可达"
ssh $SSH_OPTS "$SSH_TARGET" "SITE='$site' ENABLE_HTTPS_AWK=\"$(printf '%s' "$ENABLE_HTTPS_AWK" | base64)\" bash -s" <<'REMOTE'
set -euo pipefail
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
SITE="$SITE"
ENABLE_HTTPS_AWK_b64="$ENABLE_HTTPS_AWK"; unset ENABLE_HTTPS_AWK
_enable_https() {
local conf="$1"
printf '%s' "$ENABLE_HTTPS_AWK_b64" | base64 -d > /tmp/enable-https.awk
$SUDO bash -c 'f="$1"; awk -f /tmp/enable-https.awk "$f" > "$f.tmp" && mv "$f.tmp" "$f"' _ "$conf"
rm -f /tmp/enable-https.awk
}
# 没装 certbot 就装
if ! command -v certbot >/dev/null 2>&1; then
echo "==> 安装 certbot ..."
if command -v apt >/dev/null 2>&1; then $SUDO apt update && $SUDO apt install -y certbot python3-certbot-nginx
elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y certbot python3-certbot-nginx
elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y certbot python3-certbot-nginx
else echo "✗ 未识别的包管理器,请手动装 certbot"; exit 1; fi
fi
$SUDO mkdir -p /var/www/html
echo "==> 签发证书 ..."
$SUDO certbot certonly --webroot -w /var/www/html \
-d "$SITE" \
--non-interactive --agree-tos --register-unsafe --email "root@$SITE"
# 找到站点配置文件
CONF=""
for p in "/etc/nginx/sites-available/$SITE" "/etc/nginx/conf.d/$SITE.conf"; do
if $SUDO [ -f "$p" ]; then CONF="$p"; break; fi
done
if [ -z "$CONF" ]; then echo "✗ 找不到 $SITE 的配置,请先 setup"; exit 1; fi
echo "==> 启用 HTTPS取消 443 段注释 + 80 段切跳转)..."
_enable_https "$CONF"
echo "==> 测试并重载 nginx ..."
$SUDO nginx -t && $SUDO systemctl reload nginx
echo "$SITE 的 HTTPS 已启用,访问 https://$SITE"
echo " 证书: /etc/letsencrypt/live/$SITE/ (certbot 已自动配置续期)"
REMOTE
}
# ----------------------------------------------------------------
# 博客专用命令
# ----------------------------------------------------------------
cmd_setup_nginx() { do_setup_site "sausagetoast.cloud" "nginx.conf" "nginx-serve.conf" "yukun.conf"; }
cmd_certbot() { do_certbot_site "sausagetoast.cloud"; }
# ----------------------------------------------------------------
# 诊断 + 清理:找出并禁用残留的 baidu 反代配置
# ----------------------------------------------------------------
cmd_doctor() {
echo "==> 远端诊断:搜索 baidu 反代残留 ..."
ssh $SSH_OPTS "$SSH_TARGET" "bash -s" <<'REMOTE'
set +e
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
echo "########## 1. 含 baidu 的配置/脚本nginx/宝塔/caddy/apache/定时任务)##########"
hits=$($SUDO grep -rln "baidu" /etc/nginx/ /www/server/ /etc/caddy/ /etc/apache2/ /etc/httpd/ 2>/dev/null)
if [ -n "$hits" ]; then
for f in $hits; do
echo ">> $f"
$SUDO grep -nE "baidu|server_name|proxy_pass|return 30|listen " "$f" 2>/dev/null
echo ""
done
else
echo "(配置文件里没有 baidu)"
fi
echo "-- crontab / cron.d 里的 baidu --"
($SUDO crontab -l 2>/dev/null | grep -n "baidu"; $SUDO grep -rn "baidu" /etc/cron.d/ /var/spool/cron/ 2>/dev/null) || echo "(定时任务里没有 baidu)"
echo "########## 2. 生效配置里 sausagetoast.cloud 的去向 ##########"
$SUDO nginx -T 2>/dev/null | grep -B2 -A10 "sausagetoast" || echo "(nginx -T 无相关配置)"
echo "########## 3. 80/443 监听者 + 其他 web 服务 ##########"
ss -tlnp 2>/dev/null | grep -E ':80|:443' || netstat -tlnp 2>/dev/null | grep -E ':80|:443'
systemctl list-units --type=service --state=running 2>/dev/null | grep -iE 'caddy|apache|httpd|traefik|nginx|bt|baota' || true
echo ""
echo "########## 4. 清理:禁用已启用且含 baidu 的配置 ##########"
TS=$(date +%Y%m%d-%H%M%S)
BK="/tmp/yukun-doctor-backup-$TS"
mkdir -p "$BK"
disabled=0
# 检查 sites-enabled可能是软链接和 conf.d
for d in /etc/nginx/sites-enabled /etc/nginx/conf.d; do
[ -d "$d" ] || continue
for f in "$d"/*; do
[ -e "$f" ] || continue
if $SUDO grep -ql "baidu" "$f" 2>/dev/null; then
echo ">> 发现 baidu 配置: $f"
$SUDO cp -a "$f" "$BK/$(basename "$f").bak"
if [ -L "$f" ]; then
echo " 软链接,删除链接(保留源文件)"
$SUDO rm -f "$f"
else
echo " 普通文件,改名为 .disabled"
$SUDO mv "$f" "$f.disabled"
fi
disabled=$((disabled+1))
fi
done
done
echo "备份目录: $BK"
if [ "$disabled" -gt 0 ]; then
echo "==> 测试 nginx ..."
if $SUDO nginx -t 2>/dev/null; then
$SUDO systemctl reload nginx
echo "✓ 已禁用 $disabled 个 baidu 配置并 reload nginx"
else
echo "✗ nginx -t 失败,恢复备份 ..."
# 恢复(简化:提示手动从 $BK 恢复)
echo " 请从 $BK 手动恢复,或检查 nginx -t 报错"
fi
else
echo "(已启用的配置里没有 baidu若仍跳转 baidu看上面第 2/3 项排查其他服务或 DNS)"
fi
REMOTE
}
# ----------------------------------------------------------------
# 博客发布:构建 + rsync + reload
# ----------------------------------------------------------------
cmd_deploy() {
echo "==> 工作目录: $ROOT_DIR"
echo "==> [1/3] 构建站点 (astro build)..."
cd "$ROOT_DIR"
npm run build
[ -d "$ROOT_DIR/dist" ] || { echo "✗ 构建失败dist 不存在"; exit 1; }
echo "==> [2/3] 同步到 ${SSH_TARGET}:${REMOTE_DIR} ..."
rsync -avz --delete --human-readable \
"$ROOT_DIR/dist/" "${SSH_TARGET}:${REMOTE_DIR}/"
if [ "$NGINX_RELOAD" = "1" ]; then
echo "==> [3/3] reload nginx..."
remote_run "nginx -t && systemctl reload nginx"
fi
echo ""
echo "✓ 部署完成!访问 https://${REMOTE_HOST}"
}
# ----------------------------------------------------------------
case "${1:-deploy}" in
setup-nginx) cmd_setup_nginx ;;
certbot) cmd_certbot ;;
doctor) cmd_doctor ;;
deploy|"") cmd_deploy ;;
*)
echo "用法: $0 [setup-nginx|certbot|doctor|deploy]"
echo " setup-nginx 博客:上传并启用站点配置"
echo " certbot 博客:签 HTTPS 证书并启用 443"
echo " doctor 诊断并清理残留的 baidu 反代配置"
echo " deploy 博客:构建并同步到 VPS默认"
echo " 子域名反代请用: $0 所在目录的 proxy.sh"
exit 1 ;;
esac

98
deploy/ip-preview.sh Executable file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env bash
# 备案期间:临时用 http://<服务器IP>:8321 访问博客(不走域名/80/443
#
# 用法: ./deploy/ip-preview.sh
# 前置: ① 服务器安全组/防火墙放行 TCP 8321
# ② 本机配好到服务器的 SSH 免密REMOTE_HOST 连不通时改成服务器 IP
# 幂等可重跑:每次重新构建同步 + 重写 8321 配置。
set -euo pipefail
# ====== 配置区(与 deploy.sh 保持一致ssh 不通时 REMOTE_HOST 填服务器 IP ======
REMOTE_USER="root" # SSH 用户(服务器是 ubuntu 用户的话改成 ubuntu
REMOTE_HOST="sausagetoast.cloud" # VPS 地址(备案期间建议填服务器 IP
REMOTE_DIR="/var/www/yukun" # 博客站点根目录
# ==============================================================================
SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SSH_OPTS="-o StrictHostKeyChecking=accept-new"
echo "==> [1/3] 构建站点 (astro build)..."
cd "$ROOT_DIR"
npm run build
[ -d "$ROOT_DIR/dist" ] || { echo "✗ 构建失败dist 不存在"; exit 1; }
echo "==> [2/3] 同步到 ${SSH_TARGET}:${REMOTE_DIR} ..."
rsync -avz --delete --human-readable \
"$ROOT_DIR/dist/" "${SSH_TARGET}:${REMOTE_DIR}/"
echo "==> 生成 8321 访问配置并上传 ..."
cat > /tmp/yukun-ip-preview.conf <<'NGINX'
# 备案期间:临时用 http://<服务器IP>:8321 访问博客(由 deploy/ip-preview.sh 生成)
# 不监听 80/443与正式站点配置互不干扰备案完成后删除此文件即可。
server {
listen 8321;
server_name _;
root /var/www/yukun;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
location ~* \.(?:css|js|woff2?|ttf|otf|svg|png|jpg|jpeg|gif|webp|ico|avif)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location = /sitemap-index.xml { add_header Cache-Control "no-cache"; }
location ~ ^/sitemap.* { add_header Cache-Control "no-cache"; }
location / {
try_files $uri $uri/ $uri.html =404;
}
error_page 404 /404.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
access_log /var/log/nginx/yukun.access.log;
error_log /var/log/nginx/yukun.error.log;
}
NGINX
scp $SSH_OPTS /tmp/yukun-ip-preview.conf "$SSH_TARGET:/tmp/yukun-ip-preview.conf"
rm -f /tmp/yukun-ip-preview.conf
echo "==> [3/3] 远端启用 8321 配置 ..."
ssh $SSH_OPTS "$SSH_TARGET" "bash -s" <<'REMOTE'
set -euo pipefail
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO cp /tmp/yukun-ip-preview.conf /etc/nginx/conf.d/ip-preview.conf
rm -f /tmp/yukun-ip-preview.conf
echo "==> 测试 nginx 配置 ..."
$SUDO nginx -t
echo "==> reload nginx ..."
$SUDO systemctl reload nginx || $SUDO systemctl restart nginx
REMOTE
echo ""
echo "✓ 完成!访问 http://<服务器IP>:8321"
echo " 备案完成后ssh 到服务器执行 sudo rm /etc/nginx/conf.d/ip-preview.conf && sudo systemctl reload nginx 即可移除"

47
deploy/nginx-serve.conf Normal file
View file

@ -0,0 +1,47 @@
# Yukun's Blog · nginx 共享服务配置片段
# 由 deploy/deploy.sh setup-nginx 上传到 /etc/nginx/snippets/yukun.conf
# 80 与 443 段都 include 此文件,避免重复
root /var/www/yukun;
index index.html;
# gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
# 静态资源长缓存Astro 构建产物带 hash可放心强缓存
location ~* \.(?:css|js|woff2?|ttf|otf|svg|png|jpg|jpeg|gif|webp|ico|avif)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# sitemap 不缓存
location = /sitemap-index.xml { add_header Cache-Control "no-cache"; }
location ~ ^/sitemap.* { add_header Cache-Control "no-cache"; }
# Astro 目录式 URL确保 index.html 能解析
location / {
try_files $uri $uri/ $uri.html =404;
}
# 自定义 404
error_page 404 /404.html;
# 安全头
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

68
deploy/nginx.conf Normal file
View file

@ -0,0 +1,68 @@
# Yukun's Blog · nginx 站点配置
# deploy/deploy.sh setup-nginx 自动上传到 sites-available(Debian) conf.d(CentOS)
# 共享服务配置在 /etc/nginx/snippets/yukun.conf nginx-serve.conf 上传)。
#
# 站点根目录:/var/www/yukundeploy.sh rsync 到这里)
#
# 首次部署:只用 80 HTTP 直接服务站点nginx -t 可直接通过。
# 申请证书后:./deploy/deploy.sh certbot 会自动 启用 443 80 段切为跳转。
# ============================================================
# 80 端口HTTP
# · 首次无证书include 片段,直接服务站点
# · 启用 HTTPS 后:改为 return 301 跳转到 443
# ============================================================
server {
listen 80;
listen [::]:80;
server_name sausagetoast.cloud www.sausagetoast.cloud;
# Let's Encrypt 证书验证certbot webroot 用,无论是否启用 HTTPS 都需保留)
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# @@HTTPS-REDIRECT-START@@
# 启用 HTTPS certbot 会取消下面这行注释,把 80 段变成跳转:
# location / { return 301 https://$host$request_uri; }
# @@HTTPS-REDIRECT-END@@
# @@HTTP-SERVE-START@@
# 启用 HTTPS certbot 会把这行注释掉(跳转段接管):
include /etc/nginx/snippets/yukun.conf;
# @@HTTP-SERVE-END@@
access_log /var/log/nginx/yukun.access.log;
error_log /var/log/nginx/yukun.error.log;
}
# ============================================================
# 443 端口HTTPS
# 证书由 deploy/deploy.sh certbot 签发到 /etc/letsencrypt/live/sausagetoast.cloud/
# 首次部署此段被注释(无证书时 nginx -t 才能过certbot 会自动取消注释。
# @@HTTPS-START@@
# server {
# listen 443 ssl http2;
# listen [::]:443 ssl http2;
# server_name sausagetoast.cloud www.sausagetoast.cloud;
#
# ssl_certificate /etc/letsencrypt/live/sausagetoast.cloud/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/sausagetoast.cloud/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 1d;
#
# # www 跳主域名
# if ($host = www.sausagetoast.cloud) {
# return 301 https://sausagetoast.cloud$request_uri;
# }
#
# include /etc/nginx/snippets/yukun.conf;
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
#
# access_log /var/log/nginx/yukun.access.log;
# error_log /var/log/nginx/yukun.error.log;
# }
# @@HTTPS-END@@

273
deploy/proxy.sh Executable file
View file

@ -0,0 +1,273 @@
#!/usr/bin/env bash
# Yukun's Blog · 通用子域名反代 + HTTPS 脚本
#
# 用法:
# ./deploy/proxy.sh <子域名> <端口> 反代子域名 → http://127.0.0.1:<端口>,自动签 HTTPS
# ./deploy/proxy.sh remove <子域名> 移除该子域名的反代配置(证书保留)
#
# 示例:
# ./deploy/proxy.sh db 7000 → https://db.sausagetoast.cloud → 127.0.0.1:7000
# ./deploy/proxy.sh remove db
#
# 子域名可传前缀(自动拼主域名)或完整域名(含点,直接使用)。
# 前置条件:子域名 DNS 已解析到本机、80 端口可达。幂等可重跑。
set -euo pipefail
# ====== 配置区(与 deploy.sh 保持一致) ======
REMOTE_USER="root" # SSH 用户
REMOTE_HOST="sausagetoast.cloud" # VPS 地址
MAIN_DOMAIN="sausagetoast.cloud" # 主域名(拼接子域名用)
# ============================================
SSH_OPTS="-o StrictHostKeyChecking=accept-new"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# 启用 HTTPS 的 awk 程序(与 deploy.sh 共用同一套标记):
# ① 取消 443 段注释 ② 80 段切 301 跳转 ③ 移除 80 段反代块
read -r -d '' ENABLE_HTTPS_AWK <<'AWKPROG' || true
BEGIN { in443=0; inredir=0; inserv=0 }
/@@HTTPS-START@@/ { in443=1; next }
/@@HTTPS-END@@/ { in443=0; next }
/@@HTTPS-REDIRECT-START@@/ { inredir=1; next }
/@@HTTPS-REDIRECT-END@@/ { inredir=0; next }
/@@HTTP-SERVE-START@@/ { inserv=1; next }
/@@HTTP-SERVE-END@@/ { inserv=0; next }
in443 && /^# / { sub(/^# ?/, ""); print; next }
in443 && /^#/ { sub(/^#/, ""); print; next }
inredir && /^[[:space:]]*#.*return 301 https:/ { sub(/^[[:space:]]*#[[:space:]]*/, ""); print; next }
inserv { next }
{ print }
AWKPROG
usage() {
echo "用法: $0 <子域名> <端口> 或 $0 remove <子域名>"
echo " 例: $0 db 7000 → https://db.${MAIN_DOMAIN} → 127.0.0.1:7000"
echo " $0 remove db"
exit 1
}
# 生成反代 location 块80/443 两处共用,缩进 $1
proxy_block() {
local indent="$1"
echo "${indent}location / {"
echo "${indent} proxy_pass http://127.0.0.1:${PORT};"
echo "${indent} proxy_http_version 1.1;"
echo "${indent} proxy_set_header Host \$host;"
echo "${indent} proxy_set_header X-Real-IP \$remote_addr;"
echo "${indent} proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;"
echo "${indent} proxy_set_header X-Forwarded-Proto \$scheme;"
echo "${indent} proxy_set_header Upgrade \$http_upgrade;"
echo "${indent} proxy_set_header Connection \"upgrade\";"
echo "${indent} proxy_connect_timeout 60s;"
echo "${indent} proxy_send_timeout 300s;"
echo "${indent} proxy_read_timeout 300s;"
echo "${indent} proxy_buffering off;"
echo "${indent} client_max_body_size 100m;"
echo "${indent}}"
}
# 本地生成站点配置 → /tmp/yukun-proxy-<FQDN>.conf
gen_conf() {
local f="/tmp/yukun-proxy-${FQDN}.conf"
{
echo "# ${FQDN} · 反代到本地 ${PORT} 端口服务(由 deploy/proxy.sh 生成)"
echo "# 首次部署80 段直接反代HTTP 通);证书签发后自动启用 443 + 80 跳转。"
echo ""
echo "server {"
echo " listen 80;"
echo " listen [::]:80;"
echo " server_name ${FQDN};"
echo ""
echo " # Let's Encrypt 证书验证certbot webroot 用,无论是否启用 HTTPS 都需保留)"
echo " location /.well-known/acme-challenge/ {"
echo " root /var/www/html;"
echo " }"
echo ""
echo " # @@HTTPS-REDIRECT-START@@"
echo " # 启用 HTTPS 后取消下面这行注释,把 80 段变成跳转:"
echo " # location / { return 301 https://\$host\$request_uri; }"
echo " # @@HTTPS-REDIRECT-END@@"
echo ""
echo " # @@HTTP-SERVE-START@@"
echo " # 启用 HTTPS 后,此段会被移除(跳转段接管):"
proxy_block " "
echo " # @@HTTP-SERVE-END@@"
echo "}"
echo ""
echo "# 证书由 proxy.sh 签发到 /etc/letsencrypt/live/${FQDN}/"
echo "# @@HTTPS-START@@"
echo "# server {"
echo "# listen 443 ssl http2;"
echo "# listen [::]:443 ssl http2;"
echo "# server_name ${FQDN};"
echo "#"
echo "# ssl_certificate /etc/letsencrypt/live/${FQDN}/fullchain.pem;"
echo "# ssl_certificate_key /etc/letsencrypt/live/${FQDN}/privkey.pem;"
echo "# ssl_protocols TLSv1.2 TLSv1.3;"
echo "# ssl_ciphers HIGH:!aNULL:!MD5;"
echo "# ssl_prefer_server_ciphers on;"
echo "# ssl_session_cache shared:SSL:10m;"
echo "# ssl_session_timeout 1d;"
echo "#"
proxy_block "# "
echo "# add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;"
echo "# }"
echo "# @@HTTPS-END@@"
} > "$f"
echo "$f"
}
# 校验并规范化子域名参数
normalize_fqdn() {
local raw="$1"
[[ -n "$raw" ]] || { echo "✗ 缺少子域名参数"; usage; }
[[ "$raw" =~ ^[a-zA-Z0-9.-]+$ ]] && [[ "$raw" != *..* ]] && [[ "$raw" != -* ]] && [[ "$raw" != *- ]] \
|| { echo "✗ 非法子域名: $raw"; exit 1; }
if [[ "$raw" == *.* ]]; then
FQDN="$raw"
else
FQDN="${raw}.${MAIN_DOMAIN}"
fi
}
# ----------------------------------------------------------------
# 子命令:配置反代 + 签证书 + 启用 HTTPS
# ----------------------------------------------------------------
cmd_setup() {
local SUB="$1" PORT="$2"
[[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 1 && PORT <= 65535 )) || { echo "✗ 非法端口: $PORT"; exit 1; }
normalize_fqdn "$SUB"
local CONF_TMP
CONF_TMP="$(gen_conf)"
echo "==> 上传 ${FQDN} 的 nginx 配置(反代 → 127.0.0.1:${PORT})到 ${REMOTE_USER}@${REMOTE_HOST} ..."
scp $SSH_OPTS "$CONF_TMP" "${REMOTE_USER}@${REMOTE_HOST}:/tmp/yukun-proxy.conf"
rm -f "$CONF_TMP"
echo "==> 远端安装配置 + 签证书 + 启用 HTTPS ..."
ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" \
"SITE='$FQDN' PORT='$PORT' ENABLE_HTTPS_AWK=\"$(printf '%s' "$ENABLE_HTTPS_AWK" | base64)\" bash -s" <<'REMOTE'
set -euo pipefail
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
SITE="$SITE"; PORT="$PORT"
ENABLE_HTTPS_AWK_b64="$ENABLE_HTTPS_AWK"; unset ENABLE_HTTPS_AWK
# 1) 安装站点配置:优先 sites-available(Debian),其次 conf.d(CentOS)
CONF=""
if [ -d /etc/nginx/sites-enabled ] || $SUDO [ -d /etc/nginx/sites-enabled ]; then
AVAIL=/etc/nginx/sites-available; EN=/etc/nginx/sites-enabled
$SUDO mkdir -p "$AVAIL" "$EN"
$SUDO cp /tmp/yukun-proxy.conf "$AVAIL/$SITE"
$SUDO ln -sfn "$AVAIL/$SITE" "$EN/$SITE"
if ! $SUDO grep -q "sites-enabled" /etc/nginx/nginx.conf 2>/dev/null; then
echo " nginx.conf 缺 include sites-enabled/*,自动追加"
$SUDO sed -i '/http {/a\ include /etc/nginx/sites-enabled/*;' /etc/nginx/nginx.conf
fi
CONF="$AVAIL/$SITE"
echo " 站点: $CONF (+软链接 $EN/$SITE)"
else
$SUDO mkdir -p /etc/nginx/conf.d
$SUDO cp /tmp/yukun-proxy.conf "/etc/nginx/conf.d/$SITE.conf"
CONF="/etc/nginx/conf.d/$SITE.conf"
echo " 站点: $CONF"
fi
# 1.5) 清理另一位置的重复配置(防止 conflicting server name 警告)
if [ "$CONF" = "/etc/nginx/sites-available/$SITE" ]; then
if $SUDO [ -f "/etc/nginx/conf.d/$SITE.conf" ]; then
$SUDO rm -f "/etc/nginx/conf.d/$SITE.conf"
echo " 已移除重复配置 /etc/nginx/conf.d/$SITE.conf"
fi
else
if $SUDO [ -f "/etc/nginx/sites-available/$SITE" ]; then
$SUDO rm -f "/etc/nginx/sites-available/$SITE" "/etc/nginx/sites-enabled/$SITE"
echo " 已移除重复配置 sites-available/$SITE"
fi
fi
# 2) 签 HTTPS 证书(已存在则跳过)
CERT="/etc/letsencrypt/live/$SITE/fullchain.pem"
if $SUDO [ -f "$CERT" ]; then
echo "==> 已有证书 $CERT,跳过签发"
else
if ! command -v certbot >/dev/null 2>&1; then
echo "==> 安装 certbot ..."
if command -v apt >/dev/null 2>&1; then $SUDO apt update && $SUDO apt install -y certbot python3-certbot-nginx
elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y certbot python3-certbot-nginx
elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y certbot python3-certbot-nginx
else echo "✗ 未识别的包管理器,请手动装 certbot"; exit 1; fi
fi
$SUDO mkdir -p /var/www/html
echo "==> 签发证书 ..."
$SUDO certbot certonly --webroot -w /var/www/html \
-d "$SITE" \
--non-interactive --agree-tos --register-unsafe --email "root@$SITE"
fi
# 3) 启用 HTTPS取消 443 段注释 + 80 段切跳转 + 移除反代块)
echo "==> 启用 HTTPS ..."
printf '%s' "$ENABLE_HTTPS_AWK_b64" | base64 -d > /tmp/enable-https.awk
$SUDO bash -c 'f="$1"; awk -f /tmp/enable-https.awk "$f" > "$f.tmp" && mv "$f.tmp" "$f"' _ "$CONF"
rm -f /tmp/enable-https.awk
echo "==> 测试 nginx 配置 ..."
$SUDO nginx -t
echo "==> reload nginx ..."
$SUDO systemctl reload nginx || $SUDO systemctl restart nginx
rm -f /tmp/yukun-proxy.conf
echo "✓ 完成https://$SITE → http://127.0.0.1:$PORT"
REMOTE
}
# ----------------------------------------------------------------
# 子命令:移除反代配置
# ----------------------------------------------------------------
cmd_remove() {
normalize_fqdn "$1"
echo "==> 远端移除 $FQDN 的反代配置 ..."
ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "SITE='$FQDN' bash -s" <<'REMOTE'
set -euo pipefail
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
SITE="$SITE"
removed=0
# sites-enabled 软链接
if $SUDO [ -L "/etc/nginx/sites-enabled/$SITE" ]; then
$SUDO rm -f "/etc/nginx/sites-enabled/$SITE"; removed=1
fi
# sites-available 源文件
if $SUDO [ -f "/etc/nginx/sites-available/$SITE" ]; then
$SUDO rm -f "/etc/nginx/sites-available/$SITE"; removed=1
fi
# conf.d 配置
if $SUDO [ -f "/etc/nginx/conf.d/$SITE.conf" ]; then
$SUDO rm -f "/etc/nginx/conf.d/$SITE.conf"; removed=1
fi
if [ "$removed" -eq 0 ]; then
echo "(未找到 $SITE 的 nginx 配置,可能已移除)"
else
echo "==> 测试 nginx 配置 ..."
$SUDO nginx -t
echo "==> reload nginx ..."
$SUDO systemctl reload nginx || $SUDO systemctl restart nginx
echo "✓ 已移除 $SITE 的反代配置"
fi
if $SUDO [ -d "/etc/letsencrypt/live/$SITE" ]; then
echo " 证书已保留续期任务仍在certbot delete --cert-name $SITE 可清理"
fi
REMOTE
}
# ----------------------------------------------------------------
case "${1:-}" in
remove)
[ $# -eq 2 ] || { echo "✗ 用法: $0 remove <子域名>"; exit 1; }
cmd_remove "$2" ;;
"")
usage ;;
*)
[ $# -eq 2 ] || { echo "✗ 用法: $0 <子域名> <端口>"; exit 1; }
cmd_setup "$1" "$2" ;;
esac

7514
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "yukun-blog",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"deploy": "./deploy/deploy.sh"
},
"dependencies": {
"@astrojs/mdx": "^4.0.0",
"@astrojs/sitemap": "^3.4.0",
"astro": "^5.13.0",
"katex": "^0.16.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.0",
"typescript": "^5.7.0"
},
"allowScripts": {
"sharp@0.34.5": true,
"sharp@0.33.5": true,
"esbuild@0.27.7": true,
"esbuild@0.25.12": true
}
}

53
public/favicon.svg Normal file
View file

@ -0,0 +1,53 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<defs>
<!-- 背景径向渐变 -->
<radialGradient id="bgGrad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#1e293b" />
<stop offset="100%" stop-color="#0f172a" />
</radialGradient>
<!-- 中心光晕 -->
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.15" />
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0" />
</radialGradient>
<!-- 文字渐变(蓝→紫) -->
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#60a5fa" />
<stop offset="100%" stop-color="#a78bfa" />
</linearGradient>
<!-- 文字投影滤镜 -->
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#000" flood-opacity="0.6" />
</filter>
</defs>
<!-- 圆形背景 -->
<circle cx="256" cy="256" r="240" fill="url(#bgGrad)" />
<!-- 光晕 -->
<circle cx="256" cy="256" r="200" fill="url(#glow)" />
<!-- 神经网络连接线6条辐射线 -->
<g stroke="#3b82f6" stroke-width="2" opacity="0.3">
<line x1="256" y1="256" x2="406" y2="256" />
<line x1="256" y1="256" x2="331" y2="386" />
<line x1="256" y1="256" x2="181" y2="386" />
<line x1="256" y1="256" x2="106" y2="256" />
<line x1="256" y1="256" x2="181" y2="126" />
<line x1="256" y1="256" x2="331" y2="126" />
</g>
<!-- 神经网络节点6个小圆 -->
<g fill="#60a5fa" opacity="0.8">
<circle cx="406" cy="256" r="10" />
<circle cx="331" cy="386" r="10" />
<circle cx="181" cy="386" r="10" />
<circle cx="106" cy="256" r="10" />
<circle cx="181" cy="126" r="10" />
<circle cx="331" cy="126" r="10" />
</g>
<!-- 核心文字:<Σ> 代表代码、数学与AI的融合 -->
<text x="256" y="296" font-family="'Courier New', monospace" font-size="140" font-weight="bold" fill="url(#textGrad)" text-anchor="middle" filter="url(#shadow)">
&lt;Σ&gt;
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,8 @@
---
// 固定背景层:渐变底 + 漂浮光斑 + 极淡网格
---
<div class="bg-layers" aria-hidden="true">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>

View file

@ -0,0 +1,71 @@
---
const year = new Date().getFullYear();
const startYear = 2024;
const range = year - startYear;
---
<footer class="footer">
<div class="container">
<div class="glass footer-card">
<div class="footer-brand">
<span class="brand-mark">Y</span>
<div>
<div class="footer-name">Yukun&rsquo;s Blog</div>
<div class="footer-sig">记录代码与生活 · 淡蓝色的液态玻璃</div>
</div>
</div>
<nav class="footer-nav" aria-label="页脚导航">
<a href="/">首页</a>
<a href="/posts">文章</a>
<a href="/tags">标签</a>
<a href="/archives">归档</a>
<a href="/about">关于</a>
</nav>
<div class="footer-bottom">
<span>© {startYear}{range > 0 ? `${year}` : ''} Yukun · sausagetoast.cloud</span>
<span class="built">由 Astro 静态生成 · 喵~</span>
</div>
</div>
</div>
</footer>
<style>
.footer { padding: 40px 0 32px; }
.footer-card {
border-radius: var(--r-lg);
padding: clamp(24px, 4vw, 40px);
display: flex;
flex-direction: column;
gap: 20px;
}
.footer-brand { display: flex; align-items: center; gap: 14px; }
.footer-brand .brand-mark {
display: grid;
place-items: center;
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, var(--blue-500), var(--cyan-400));
color: #fff;
font-family: var(--font-display);
font-weight: 800;
box-shadow: 0 4px 14px rgba(47, 127, 224, 0.35);
}
.footer-name { font-family: var(--font-display); font-weight: 800; font-size: 1.1rem; color: var(--ink); }
.footer-sig { color: var(--ink-faint); font-size: 0.85rem; margin-top: 2px; }
.footer-nav { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.footer-nav a { color: var(--ink-soft); font-weight: 600; font-size: 0.9rem; }
.footer-nav a:hover { color: var(--blue-600); }
.footer-bottom {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6px 14px;
padding-top: 16px;
border-top: 1px solid rgba(79, 163, 255, 0.18);
color: var(--ink-faint);
font-size: 0.82rem;
}
@media (max-width: 768px) {
.footer-nav { gap: 6px 14px; }
}
</style>

222
src/components/Nav.astro Normal file
View file

@ -0,0 +1,222 @@
---
const navItems = [
{ href: '/', label: '首页', match: 'index' },
{ href: '/posts', label: '文章', match: 'posts' },
{ href: '/tags', label: '标签', match: 'tags' },
{ href: '/archives', label: '归档', match: 'archives' },
{ href: '/about', label: '关于', match: 'about' },
];
const path = Astro.url.pathname.replace(/\/+$/, '').replace(/^\//, '');
const current = path === '' ? 'index' : path.split('/')[0];
---
<header id="nav" class="nav glass" data-scrolled="0">
<div class="container nav-inner">
<a href="/" class="brand">
<span class="brand-mark">Y</span>
<span class="brand-text">Yukun<span class="apos">&rsquo;</span>s Blog</span>
</a>
<nav class="nav-links" aria-label="主导航">
{
navItems.map((item) => (
<a
href={item.href}
class:list={['nav-link', { active: current === item.match }]}
aria-current={current === item.match ? 'page' : undefined}
>
{item.label}
</a>
))
}
</nav>
<button id="search-trigger" class="nav-search" aria-label="搜索文章" title="搜索 (Ctrl+K)">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.5" y2="16.5" />
</svg>
<span class="kbd">Ctrl K</span>
</button>
<button id="menu-toggle" class="menu-toggle" aria-label="菜单" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
<div id="mobile-menu" class="mobile-menu" data-open="0">
{navItems.map((item) => (
<a href={item.href} class:list={['mobile-link', { active: current === item.match }]}>{item.label}</a>
))}
<button id="search-trigger-mobile" class="mobile-search">搜索文章</button>
</div>
</header>
<style>
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 90;
height: var(--nav-h);
display: flex;
align-items: center;
background: transparent;
border: 0;
box-shadow: none;
transition: background 0.35s, box-shadow 0.35s, border-color 0.35s, backdrop-filter 0.35s;
}
.nav[data-scrolled='1'] {
background: var(--glass);
backdrop-filter: blur(22px) saturate(160%);
-webkit-backdrop-filter: blur(22px) saturate(160%);
border-bottom: 1px solid var(--glass-border);
box-shadow: 0 4px 24px rgba(31, 96, 160, 0.08);
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 100%;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 800;
font-size: 1.1rem;
color: var(--ink);
letter-spacing: -0.02em;
}
.brand:hover { color: var(--ink); }
.brand-mark {
display: grid;
place-items: center;
width: 30px; height: 30px;
border-radius: 9px;
background: linear-gradient(135deg, var(--blue-500), var(--cyan-400));
color: #fff;
font-size: 1rem;
box-shadow: 0 4px 12px rgba(47, 127, 224, 0.4);
}
.brand-text .apos { color: var(--blue-400); }
.nav-links {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.nav-link {
position: relative;
padding: 8px 14px;
border-radius: 999px;
font-weight: 600;
font-size: 0.95rem;
color: var(--ink-soft);
transition: color 0.2s, background 0.2s;
}
.nav-link:hover { color: var(--blue-600); background: rgba(132, 194, 255, 0.14); }
.nav-link.active { color: var(--blue-600); background: rgba(132, 194, 255, 0.2); }
.nav-link.active::after {
content: '';
position: absolute;
left: 50%;
bottom: 1px;
transform: translateX(-50%);
width: 5px; height: 5px;
border-radius: 50%;
background: var(--blue-500);
}
.nav-search {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.4);
color: var(--ink-soft);
cursor: pointer;
transition: all 0.2s;
}
.nav-search:hover { border-color: var(--blue-300); color: var(--blue-600); }
.kbd {
font-size: 0.72rem;
font-family: var(--font-mono);
padding: 2px 6px;
border-radius: 5px;
background: rgba(132, 194, 255, 0.18);
color: var(--blue-700);
}
.menu-toggle {
display: none;
flex-direction: column;
gap: 5px;
padding: 10px;
background: none;
border: 0;
cursor: pointer;
}
.menu-toggle span {
width: 22px; height: 2px;
background: var(--ink);
border-radius: 2px;
transition: transform 0.3s, opacity 0.3s;
}
.mobile-menu {
display: none;
flex-direction: column;
gap: 4px;
padding: 12px 16px 16px;
}
.mobile-link {
padding: 12px 14px;
border-radius: 12px;
font-weight: 600;
color: var(--ink);
}
.mobile-link.active { background: rgba(132, 194, 255, 0.2); color: var(--blue-600); }
.mobile-search {
margin-top: 6px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.5);
text-align: left;
cursor: pointer;
color: var(--ink-soft);
}
/* 移动端:折叠菜单 */
@media (max-width: 768px) {
.nav-links, .nav-search { display: none; }
.menu-toggle { display: flex; }
.nav[data-scrolled='1'] .mobile-menu,
.mobile-menu[data-open='1'] {
display: flex;
border-top: 1px solid var(--glass-border);
background: var(--glass-strong);
backdrop-filter: blur(22px) saturate(160%);
-webkit-backdrop-filter: blur(22px) saturate(160%);
}
.menu-toggle[aria-expanded='true'] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.menu-toggle[aria-expanded='true'] span:nth-child(2) { opacity: 0; }
.menu-toggle[aria-expanded='true'] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.mobile-menu[data-open='0'] { display: none; }
}
</style>
<script>
// 菜单展开
const toggle = document.getElementById('menu-toggle');
const menu = document.getElementById('mobile-menu');
toggle?.addEventListener('click', () => {
const open = menu?.dataset.open === '1';
menu!.dataset.open = open ? '0' : '1';
toggle!.setAttribute('aria-expanded', String(!open));
});
</script>

View file

@ -0,0 +1,118 @@
---
import type { Post } from '../lib/utils';
import { formatDate, readingTime, heroCss } from '../lib/utils';
interface Props {
post: Post;
variant?: 'default' | 'featured';
class?: string;
}
const { post, variant = 'default', class: cls = '' } = Astro.props;
const { id, data } = post;
const href = `/posts/${id.replace(/\/index$/, '')}`;
const rt = readingTime(post.body ?? '');
const date = formatDate(data.date);
---
<article class:list={['card', 'glass', variant, 'reveal', cls]}>
<a href={href} class="card-link" aria-label={data.title}>
<div class="hero" style={`background:${heroCss(post)}`}>
<span class="hero-emoji">{data.pinned ? '★' : variant === 'featured' ? '✦' : '◆'}</span>
</div>
<div class="body">
<div class="meta">
<time datetime={data.date.toISOString()}>{date}</time>
<span class="dot"></span>
<span>{rt}阅读</span>
</div>
<h3 class="title">{data.title}</h3>
{data.description && <p class="desc">{data.description}</p>}
<div class="tags">
{data.tags.slice(0, 3).map((t) => <span class="chip">{t}</span>)}
{data.tags.length > 3 && <span class="chip more">+{data.tags.length - 3}</span>}
</div>
</div>
</a>
</article>
<style>
.card {
border-radius: var(--r-lg);
overflow: hidden;
transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.4s;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 24px 50px rgba(31, 96, 160, 0.2), 0 6px 16px rgba(31, 96, 160, 0.1);
}
.card-link {
display: flex;
flex-direction: column;
height: 100%;
color: inherit;
}
.hero {
position: relative;
aspect-ratio: 16 / 7;
display: grid;
place-items: center;
overflow: hidden;
}
.hero::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.4), transparent 50%),
linear-gradient(180deg, transparent 40%, rgba(0, 60, 120, 0.18));
}
.hero-emoji {
position: relative;
font-size: 1.8rem;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 2px 12px rgba(0, 40, 80, 0.3);
z-index: 1;
}
.body {
padding: clamp(16px, 2.5vw, 22px);
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.meta { color: var(--ink-faint); font-size: 0.82rem; display: flex; align-items: center; gap: 8px; }
.meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: 0.5; }
.title {
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(1.15rem, 1rem + 0.8vw, 1.4rem);
line-height: 1.35;
letter-spacing: -0.02em;
color: var(--ink);
}
.card:hover .title { color: var(--blue-600); }
.desc {
color: var(--ink-soft);
font-size: 0.92rem;
line-height: 1.7;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 4px; }
.chip.more { background: rgba(107, 135, 163, 0.14); color: var(--ink-faint); border-color: rgba(107, 135, 163, 0.2); }
/* featured 加大 */
.card.featured .hero { aspect-ratio: 16 / 6; }
.card.featured .title { font-size: clamp(1.5rem, 1rem + 2vw, 2rem); }
.card.featured .desc { font-size: 1rem; -webkit-line-clamp: 3; line-clamp: 3; }
/* featured 横向布局(桌面) */
@media (min-width: 768px) {
.card.featured .card-link { flex-direction: row; }
.card.featured .hero { aspect-ratio: unset; width: 42%; flex-shrink: 0; }
.card.featured .body { padding: 28px 30px; justify-content: center; }
}
</style>

View file

@ -0,0 +1,273 @@
---
// 本地搜索弹窗:构建期生成 /search-index.json客户端 fuzzy 匹配
---
<div id="search-overlay" class="search-overlay" data-open="0" aria-hidden="true">
<div class="search-panel glass-strong">
<div class="search-bar">
<svg class="search-ico" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.5" y2="16.5" />
</svg>
<input id="search-input" type="text" placeholder="搜索文章标题、标签…(回车跳转)" autocomplete="off" aria-label="搜索关键词" />
<button id="search-close" class="search-close" aria-label="关闭">Esc</button>
</div>
<div id="search-results" class="search-results" role="listbox" aria-label="搜索结果"></div>
<div class="search-foot">
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
<span><kbd>↵</kbd> 跳转</span>
<span><kbd>Esc</kbd> 关闭</span>
</div>
</div>
</div>
<style>
.search-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: flex-start;
justify-content: center;
padding: clamp(20px, 8vh, 90px) 16px 20px;
background: rgba(20, 50, 80, 0.28);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s;
}
.search-overlay[data-open='1'] { opacity: 1; visibility: visible; }
.search-panel {
width: 100%;
max-width: 620px;
border-radius: var(--r-lg);
overflow: hidden;
transform: translateY(-12px) scale(0.98);
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.search-overlay[data-open='1'] .search-panel { transform: none; }
.search-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid rgba(79, 163, 255, 0.18);
}
.search-ico { color: var(--blue-500); flex-shrink: 0; }
#search-input {
flex: 1;
border: 0;
background: none;
outline: none;
font-size: 1.05rem;
color: var(--ink);
}
#search-input::placeholder { color: var(--ink-faint); }
.search-close {
font-family: var(--font-mono);
font-size: 0.72rem;
padding: 4px 9px;
border-radius: 6px;
border: 1px solid var(--blue-200);
background: rgba(132, 194, 255, 0.14);
color: var(--blue-700);
cursor: pointer;
}
.search-results {
max-height: min(52vh, 460px);
overflow-y: auto;
padding: 8px;
}
.result {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
cursor: pointer;
transition: background 0.15s;
}
.result:hover, .result.active { background: rgba(132, 194, 255, 0.2); }
.result-title { font-weight: 600; color: var(--ink); font-size: 0.96rem; }
.result-desc { color: var(--ink-faint); font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.result-tags .chip { padding: 1px 8px; font-size: 0.72rem; }
.search-empty { text-align: center; padding: 32px 16px; color: var(--ink-faint); font-size: 0.9rem; }
.search-foot {
display: flex;
gap: 18px;
padding: 10px 18px;
border-top: 1px solid rgba(79, 163, 255, 0.18);
font-size: 0.74rem;
color: var(--ink-faint);
}
.search-foot kbd {
font-family: var(--font-mono);
padding: 1px 6px;
border-radius: 5px;
background: rgba(132, 194, 255, 0.18);
color: var(--blue-700);
font-size: 0.72rem;
margin: 0 2px;
}
@media (max-width: 560px) {
.search-foot { display: none; }
}
</style>
<script>
// @ts-nocheck
(function () {
const overlay = document.getElementById('search-overlay');
const input = document.getElementById('search-input');
const results = document.getElementById('search-results');
const closeBtn = document.getElementById('search-close');
if (!overlay || !input || !results) return;
let index = null;
let active = -1;
let current = [];
const open = () => {
overlay.dataset.open = '1';
overlay.setAttribute('aria-hidden', 'false');
requestAnimationFrame(() => input.focus());
if (!index) loadIndex();
};
const close = () => {
overlay.dataset.open = '0';
overlay.setAttribute('aria-hidden', 'true');
};
async function loadIndex() {
try {
const res = await fetch('/search-index.json');
index = await res.json();
} catch (e) {
results.innerHTML = '<div class="search-empty">索引加载失败</div>';
}
}
// 简单模糊匹配:子序列 + 分词加权
function score(text, q) {
if (!q) return 0;
const t = text.toLowerCase();
let score = 0;
if (t === q) return 100;
if (t.includes(q)) score += 40 + (q.length / t.length) * 20;
// 分词匹配
const words = q.split(/\s+/).filter(Boolean);
for (const w of words) {
if (t.includes(w)) score += 18;
}
// 子序列
let i = 0;
for (const ch of t) {
if (ch === q[i]) i++;
if (i >= q.length) break;
}
if (i >= q.length) score += 4;
return score;
}
function search(q) {
if (!index) return;
q = q.trim().toLowerCase();
if (!q) {
results.innerHTML = '<div class="search-empty">输入关键词,搜索文章标题与标签</div>';
current = [];
active = -1;
return;
}
const ranked = index
.map((item) => {
const s =
score(item.title, q) * 2 +
score((item.tags || []).join(' '), q) +
score(item.description || '', q);
return { item, s };
})
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 12)
.map((x) => x.item);
current = ranked;
active = ranked.length ? 0 : -1;
render();
}
function render() {
if (current.length === 0) {
results.innerHTML = '<div class="search-empty">没有匹配的文章喵~</div>';
return;
}
results.innerHTML = current
.map((item, i) => {
const tags = (item.tags || [])
.slice(0, 3)
.map((t) => `<span class="chip">${escapeHtml(t)}</span>`)
.join('');
return `<a class="result ${i === active ? 'active' : ''}" href="${item.url}" role="option">
<span class="result-title">${escapeHtml(item.title)}</span>
${item.description ? `<span class="result-desc">${escapeHtml(item.description)}</span>` : ''}
${tags ? `<span class="result-tags">${tags}</span>` : ''}
</a>`;
})
.join('');
results.querySelectorAll('.result').forEach((el, i) => {
el.addEventListener('mouseenter', () => { active = i; updateActive(); });
el.addEventListener('click', close);
});
}
function updateActive() {
results.querySelectorAll('.result').forEach((el, i) =>
el.classList.toggle('active', i === active)
);
const act = results.querySelector('.result.active');
act?.scrollIntoView({ block: 'nearest' });
}
function move(dir) {
if (current.length === 0) return;
active = (active + dir + current.length) % current.length;
updateActive();
}
input.addEventListener('input', () => search(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if (e.key === 'Enter') {
e.preventDefault();
const item = current[active];
if (item) { location.href = item.url; }
} else if (e.key === 'Escape') { close(); }
});
closeBtn.addEventListener('click', close);
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + K
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
open();
} else if (e.key === 'Escape' && overlay.dataset.open === '1') {
close();
}
});
document.getElementById('search-trigger')?.addEventListener('click', open);
document.getElementById('search-trigger-mobile')?.addEventListener('click', () => {
const menu = document.getElementById('mobile-menu');
if (menu) menu.dataset.open = '0';
open();
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
})();
</script>

View file

@ -0,0 +1,55 @@
---
interface Props {
tag: string;
count?: number;
href?: string;
active?: boolean;
}
const { tag, count, href, active = false } = Astro.props;
const target = href ?? `/tags/${tag}`;
---
<a href={target} class:list={['tag-chip', { active }]} data-count={count}>
<span class="hash">#</span>{tag}
{count !== undefined && <span class="count">{count}</span>}
</a>
<style>
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 999px;
font-weight: 600;
font-size: 0.9rem;
color: var(--ink-soft);
background: var(--glass-soft);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
transition: all 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tag-chip .hash { color: var(--blue-400); font-weight: 800; }
.tag-chip:hover {
color: var(--blue-600);
transform: translateY(-3px);
box-shadow: 0 8px 18px rgba(47, 127, 224, 0.16);
background: var(--glass-strong);
}
.tag-chip.active {
color: #fff;
background: linear-gradient(135deg, var(--blue-500), var(--blue-600));
border-color: transparent;
box-shadow: 0 6px 16px rgba(47, 127, 224, 0.3);
}
.tag-chip.active .hash { color: rgba(255, 255, 255, 0.85); }
.count {
margin-left: 4px;
font-size: 0.75rem;
padding: 1px 7px;
border-radius: 999px;
background: rgba(132, 194, 255, 0.25);
color: var(--ink-faint);
}
.tag-chip.active .count { background: rgba(255, 255, 255, 0.25); color: #fff; }
</style>

139
src/components/Toc.astro Normal file
View file

@ -0,0 +1,139 @@
---
// 文章目录:客户端扫描 .prose 内的 h2/h3自动生成移动端可折叠
---
<aside class="toc-wrap glass" aria-label="文章目录">
<div class="toc-head">
<span class="toc-title">目录</span>
<button id="toc-toggle" class="toc-toggle" aria-expanded="true" aria-label="折叠目录">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 9l6 6 6-6" /></svg>
</button>
</div>
<nav id="toc" class="toc" data-open="1"></nav>
</aside>
<style>
.toc-wrap {
border-radius: var(--r-md);
padding: 16px 16px 12px;
position: sticky;
top: calc(var(--nav-h) + 24px);
max-height: calc(100vh - var(--nav-h) - 48px);
overflow: auto;
}
.toc-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.toc-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--ink-faint);
}
.toc-toggle {
display: none;
background: none;
border: 0;
cursor: pointer;
color: var(--ink-soft);
padding: 4px;
border-radius: 6px;
transition: transform 0.3s;
}
.toc-toggle[aria-expanded='false'] { transform: rotate(-90deg); }
.toc { display: flex; flex-direction: column; gap: 2px; }
.toc[data-open='0'] { display: none; }
.toc a {
display: block;
padding: 5px 10px;
border-left: 2px solid transparent;
color: var(--ink-soft);
font-size: 0.84rem;
line-height: 1.5;
border-radius: 0 6px 6px 0;
transition: all 0.2s;
}
.toc a.level-3 { padding-left: 22px; font-size: 0.8rem; color: var(--ink-faint); }
.toc a:hover { color: var(--blue-600); background: rgba(132, 194, 255, 0.12); }
.toc a.active {
color: var(--blue-600);
border-left-color: var(--blue-500);
background: rgba(132, 194, 255, 0.16);
font-weight: 600;
}
/* 移动端:折叠 */
@media (max-width: 1024px) {
.toc-toggle { display: block; }
.toc-wrap {
position: static;
max-height: none;
margin-bottom: 20px;
}
}
</style>
<script>
function buildToc() {
const toc = document.getElementById('toc');
const prose = document.querySelector('.prose');
if (!toc || !prose) return;
const heads = Array.from(prose.querySelectorAll('h2, h3'));
if (heads.length === 0) {
const wrap = toc.closest('.toc-wrap') as HTMLElement | null;
if (wrap) wrap.style.display = 'none';
return;
}
const frag = document.createDocumentFragment();
heads.forEach((h) => {
if (!h.id) {
// 兜底:手动生成 id
const txt = h.textContent || '';
h.id = txt
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim()
.replace(/\s+/g, '-');
}
const a = document.createElement('a');
a.href = '#' + h.id;
a.textContent = h.textContent;
a.dataset.id = h.id;
a.className = h.tagName === 'H3' ? 'level-3' : '';
frag.appendChild(a);
});
toc.appendChild(frag);
// 当前标题高亮
const links = Array.from(toc.querySelectorAll('a'));
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
links.forEach((l) => l.classList.toggle('active', l.dataset.id === e.target.id));
}
}
},
{ rootMargin: '-80px 0px -75% 0px', threshold: 0 }
);
heads.forEach((h) => io.observe(h));
// 折叠
const tog = document.getElementById('toc-toggle');
tog?.addEventListener('click', () => {
const open = toc.dataset.open === '1';
toc.dataset.open = open ? '0' : '1';
tog.setAttribute('aria-expanded', String(!open));
});
}
// MDX/异步内容就绪后执行
if (document.readyState !== 'loading') buildToc();
else document.addEventListener('DOMContentLoaded', buildToc);
</script>

22
src/content.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
// 文章集合:从 src/content/posts 下加载所有 .md / .mdx 文件
const posts = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
// frontmatter 校验:少写字段构建会直接报错,防止手滑
schema: z.object({
title: z.string(),
date: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
description: z.string().default(''),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
// 文章封面渐变:可选,给两端的色值,不传则用默认主题色
heroGradient: z.tuple([z.string(), z.string()]).optional(),
// 是否置顶(首页大卡片展示)
pinned: z.boolean().default(false),
}),
});
export const collections = { posts };

View file

@ -0,0 +1,178 @@
---
title: "变分下界ELBO笔记"
date: 2026-08-13
tags: ["数学", "概率论","人工智能"]
description: "变分下界ELBO笔记"
draft: false # true 时本地可见、构建不发布(写草稿用)
pinned: false # true 时首页置顶
heroGradient: ["#7fb8ff", "#2f8df0"] # 可选,封面渐变色
---
# 变分下界 ELBO
## 预备:概率记号与期望
> 不熟悉概率记号?先读本节,再回到 [[#边缘分布的困难]]。
### 随机变量与分布 $p(x)$、$p(z)$
随机变量是"结果不确定的量":掷骰子的点数、明天气温;分布 $p(x)$ 描述它取各个值的可能性大小。
- 离散情形(如骰子):$p(x) = P(X = x)$,例如 $p(3) = \frac{1}{6}$
- 连续情形(如气温):$p(x)$ 是密度函数,"$X$ 落在 $[x, x + dx]$ 的概率 $\approx p(x)\, dx$"。
分布必须归一化(所有可能性的总概率为 1
$$
\sum_x p(x) = 1 \quad \text{(离散)} \qquad \int p(x)\, dx = 1 \quad \text{(连续)}
$$
本笔记中 $x$ 是观测数据(看得见的,如图片、文本),$z$ 是隐变量(看不见的,如图片的物体类别);$p(z)$ 叫**先验**,是"对 $z$ 事先的认识"。
### 联合、条件、边缘与贝叶斯公式
- $p(x, z)$**联合分布**$x$ 与 $z$ 同时取该值的概率;
- $p(x \mid z)$**条件分布**,给定 $z$ 之后 $x$ 的概率,竖线读作"在……条件下"
- 乘法公式:$p(x, z) = p(x \mid z)\, p(z)$
- **边缘化**:把不关心的 $z$ 积分掉,$p(x) = \int p(x, z)\, dz$,这正是正文要算的量。
把乘法公式换一种排列就是贝叶斯公式:
$$
p(z \mid x) = \frac{p(x \mid z)\, p(z)}{p(x)}
$$
四个角色:先验 $p(z)$、似然 $p(x \mid z)$、后验 $p(z \mid x)$、证据 $p(x)$。
例:$p(\text{厨师} \mid \text{菜刀})$ 大,即"看到菜刀后,这是位厨师"的概率高;背景中菜刀极少与螺丝刀、雨伞同时出现,后两者的贡献可忽略。
### 期望:按概率加权平均
期望就是"以概率为权重求平均"。掷骰子:$E[X] = \frac{1 + 2 + \dots + 6}{6} = 3.5$。
$$
E[X] = \sum_x x\, p(x) \quad \text{(离散)} \qquad E[X] = \int x\, p(x)\, dx \quad \text{(连续)}
$$
直观上 $E[X]$ 是"重复实验无穷多次的平均结果"。任意函数 $f$ 的期望同理:
$$
E[f(X)] = \int f(x)\, p(x)\, dx
$$
期望满足**线性性**$E[aX + b] = a E[X] + b$、$E[X + Y] = E[X] + E[Y]$(正文把常数 $\log p(x)$ 提出期望用的就是这条)。
注意 $E[\log X] \neq \log E[X]$$\log$ 是凹函数,只有 Jensen 不等式 $E[\log X] \leq \log E[X]$(下文推导会用到)。
### 记号 E_{zq}[·]
读作"$z$ 按分布 $q$ 抽取,再对括号内取平均"
$$
E_{z \sim q}[g(z)] = \int g(z)\, q(z)\, dz
$$
与 $E[X]$ 的区别只是显式写明平均所用的分布。本笔记主要在四处使用它:
1. **KL 散度**$D_{\mathrm{KL}}[q \,\|\, p] = E_{z \sim q}\left[\log \frac{q(z)}{p(z)}\right]$,即按 $q$ 平均两个分布对数之比;
2. **指数例子中的矩**$E_q[z] = \frac{1}{\theta}$、$E_q[z^2] = \frac{2}{\theta^2}$,代入即可闭式计算 ELBO
3. **重要性采样恒等式**$E_{z \sim q}\left[\frac{p(x, z)}{q(z)}\right] = \int p(x, z)\, dz = p(x)$,只要 $q$ 的支撑覆盖 $z$ 就成立,是 Jensen 路线的基础;
4. **重构项**$E_{z \sim q}[\log p(x \mid z)]$,按变分分布平均"重构对数似然"。
## 边缘分布的困难
> 概率记号不熟?先看 [[#预备:概率记号与期望]]。
### 隐变量与后验
我们想对观测数据 $x$ 的分布 $p(x)$ 建模,但直接建模十分困难。
引入简单隐变量 $z$,把边缘分布写成
$$
p(x) = \int p(x \mid z)\, p(z)\, dz
$$
该积分一般无法解析计算;注意 $p(x)$ 不含 $z$,在关于 $z$ 的期望中可视为常数。
由贝叶斯公式
$$
p(z \mid x) = \frac{p(x \mid z)\, p(z)}{p(x)}
$$
困难集中在后验 $p(z \mid x)$ 上,故引入参数化的变分分布 $q_\theta(z)$ 来近似它。
为记号简洁,推导中先写与 $x$ 无关的 $q_\theta(z)$VAE 一节再推广为编码器 $q_\phi(z \mid x)$;相应地,生成过程为 $z \sim p(z)$、$x \sim p(x \mid z)$(解码器)。
### 直观理解
由 $p(z \mid x) \propto p(x \mid z)\, p(z)$ 可知,被积函数只在 $p(z \mid x)$ 大的区域显著,其余 $z$ 几乎没有贡献。
因此近似 $p(x)$ 时只需覆盖后验集中的区域,这正是 $q_\theta$ 应集中的地方。
例如 $p(\text{厨师} \mid \text{菜刀})$ 大,菜刀对 $p(\text{厨师})$ 的计算贡献大;而螺丝刀、雨伞之类可以舍弃。
## ELBO 的推导
思路:$p(z \mid x)$ 难算,就用 KL 散度度量 $q_\theta$ 与后验的差距,把 $\log p(x)$ 从该距离中反解出来。
### KL 散度分解
从变分分布与后验的 KL 散度出发:
$$
D_{\mathrm{KL}}[q_\theta(z) \,\|\, p(z \mid x)] = \mathbb{E}_{z \sim q}[\log q_\theta(z) - \log p(z \mid x)]
$$
代入 $\log p(z \mid x) = \log p(z, x) - \log p(x)$,并把常数 $\log p(x)$ 提出期望:
$$
D_{\mathrm{KL}}[q_\theta \,\|\, p(z \mid x)] = \mathbb{E}_{z \sim q}[\log q_\theta(z) - \log p(z, x)] + \log p(x)
$$
移项即得基本恒等式:
$$
\log p(x) = \underbrace{\mathbb{E}_{z \sim q}[\log p(z, x) - \log q_\theta(z)]}_{\text{ELBO } \mathcal{L}_q} + D_{\mathrm{KL}}[q_\theta(z) \,\|\, p(z \mid x)]
$$
### Jensen 不等式路线
同一下界也可由重要性采样加 Jensen 得到:$\frac{p(x, z)}{q_\theta(z)}$ 是 $p(x)$ 的无偏估计,
$$
p(x) = \int p(x, z)\, dz = \mathbb{E}_{z \sim q}\left[\frac{p(x, z)}{q_\theta(z)}\right]
$$
取对数后由 $\log$ 的凹性Jensen 不等式):
$$
\log p(x) \geq \mathbb{E}_{z \sim q}\left[\log \frac{p(x, z)}{q_\theta(z)}\right] = \mathcal{L}_q
$$
两边缺口恰为 $D_{\mathrm{KL}}[q_\theta \,\|\, p(z \mid x)] \geq 0$,与 KL 路线一致。
### “下界”的含义
由 $D_{\mathrm{KL}} \geq 0$ 得 $\mathcal{L}_q \leq \log p(x)$,即 ELBO 是对数似然(对数证据)的下界。
最大化 $\mathcal{L}_q$ 同时最小化了 KL 散度,使 $q_\theta$ 更接近真实后验,也把下界抬向 $\log p(x)$。
## 等价形式与两项解读
### 重构项 正则项
把 $\log p(x, z) = \log p(x \mid z) + \log p(z)$ 拆开:
$$
\mathcal{L}_q = \mathbb{E}_{z \sim q}[\log p(x \mid z)] - D_{\mathrm{KL}}[q_\theta(z) \,\|\, p(z)]
$$
第一项为重构项(对应解码器),要求由 $z$ 能准确重构 $x$;第二项为正则项,使近似后验不坍缩成点、保持在先验附近。
## 例子:指数先验与高斯似然
### 联合分布与真实后验
先验取简单分布 $p(z) = e^{-z}\, \mathcal{I}(z \geq 0)$,似然为 $p(x \mid z) = \mathcal{N}(x;\, z,\, 1) = \frac{1}{\sqrt{2\pi}} e^{-\frac{1}{2}(x - z)^2}$.
$$
p(x, z) = \frac{1}{\sqrt{2\pi}}\, e^{-\frac{1}{2}(x - z)^2}\, e^{-z}\, \mathcal{I}(z \geq 0)
$$
指数配方 $-\frac{1}{2}(x - z)^2 - z = -\frac{1}{2}(z - (x - 1))^2 + C$,故后验为截断正态:
$$
p(z \mid x) \propto e^{-\frac{1}{2}(z - (x - 1))^2}\, \mathcal{I}(z \geq 0)
$$
归一化常数 $p(x)$ 随观测 $x$ 变化且难以解析计算,正是变分近似的动机。
### 优化 ELBO
取变分分布为指数分布 $q_\theta(z) = \theta e^{-\theta z}\, \mathcal{I}(z \geq 0)$,则 $\log q_\theta(z) = \log \theta - \theta z$.
利用指数分布矩 $\mathbb{E}_q[z] = \frac{1}{\theta}$、$\mathbb{E}_q[z^2] = \frac{2}{\theta^2}$,展开 $\log p(x, z) - \log q_\theta(z)$:把不含 $z$ 且与 $\theta$ 无关的项并入 $C$,而 $-\log\theta$ 必须保持显式(它含 $\theta$,求导时不能丢):
$$
\mathcal{L}_q = \mathbb{E}_{z \sim q}\left[-\frac{1}{2} z^2 + (x - 1 + \theta) z\right] - \log \theta + C = -\frac{1}{\theta^2} + \frac{x - 1 + \theta}{\theta} - \log \theta + C
$$
令导数为零:
$$
\frac{\partial \mathcal{L}_q}{\partial \theta} = \frac{2}{\theta^3} - \frac{x - 1}{\theta^2} - \frac{1}{\theta} = 0 \;\Longrightarrow\; \theta^2 + (x - 1)\theta - 2 = 0
$$
取 $x = 1.5$,解得 $\theta = \frac{-0.5 + \sqrt{8.25}}{2} \approx 1.186$.
### 结果讨论
此时变分分布均值 $1/\theta^* \approx 0.84$,与真实后验(中心在 $x - 1 = 0.5$ 的截断正态)形态相近,验证了"最大化 ELBO 使 $q_\theta$ 逼近后验"。
本例中所有期望均可闭式计算,无需采样;$p(x)$ 的归一化常数从头到尾没有算过,这正是变分近似的价值。
## 与 VAE 的联系
### 从自由变分到编码器
前面 $q_\theta(z)$ 对所有 $x$ 共享一组参数换一个观测就要重新优化VAE 改用神经网络编码器 $q_\phi(z \mid x)$,对每个 $x$ 直接输出变分参数(均值与方差),实现参数共享和快速推断。
### 编码器—解码器与重参数化
VAE 中由编码器输出 $\mu$、$\log \sigma^2$,得 $q_\phi(z \mid x) = \mathcal{N}(z;\, \mu, \sigma^2)$$p_\theta(x \mid z)$ 对应解码器。
取高斯先验时,$D_{\mathrm{KL}}[q_\phi \,\|\, p(z)]$ 有解析表达式,只需用蒙特卡洛估计重构项。
采样操作不可导,故用重参数技巧:$\varepsilon \sim \mathcal{N}(0, 1)$$z = \mu + \sigma \varepsilon$,使梯度能经 $\mu$、$\sigma$ 回传。
损失即 ELBO 取反:$\mathcal{L} = -\mathbb{E}_{q}[\log p_\theta(x \mid z)] + D_{\mathrm{KL}}[q_\phi(z \mid x) \,\|\, p(z)]$,即重构误差加正则项。
## 主线回顾
1. **动机**$p(x)$ 的积分难算,根源是后验 $p(z \mid x)$ 未知;
2. **推导**:由 KL 分解或 Jensen 不等式得恒等式 $\log p(x) = \mathcal{L}_q + D_{\mathrm{KL}}$
3. **解读**ELBO 拆为重构项 + 正则项,两项均可计算;
4. **例证**:指数先验 × 高斯似然下闭式优化 $\theta$,验证 $q_\theta \to p(z \mid x)$
5. **应用**VAE 用编码器输出变分参数,重参数化保证梯度可回传。
> **相关**[[#KL 散度分解]] | [[#重构项 正则项]] | [[#优化 ELBO]] | [[#结果讨论]] | [[#从自由变分到编码器]]

View file

@ -0,0 +1,193 @@
---
title: "变分自编码器VAE"
date: 2026-08-13
tags: ["数学", "概率论","人工智能"]
description: "变分自编码器VAE"
draft: false # true 时本地可见、构建不发布(写草稿用)
pinned: false # true 时首页置顶
heroGradient: ["#7fb8ff", "#2f8df0"] # 可选,封面渐变色
---
# 变分自编码器 VAE
> **相关**[[变分下界ELBO笔记]]ELBO 推导与直觉)
## 从 ELBO 到 VAE
回顾 ELBO 恒等式(见 [[变分下界ELBO笔记#KL 散度分解]]
$$
\log p(x) \geq \underbrace{\mathbb{E}_{z \sim q}[\log p(x \mid z)]}_{\text{重构项}} - \underbrace{D_{\mathrm{KL}}[q(z) \,\|\, p(z)]}_{\text{正则项}}
$$
VAE 把两个分布都交给神经网络参数化:
- **编码器**(推断网络)$q_\phi(z \mid x)$:输入 $x$,输出变分后验的参数;
- **解码器**(生成网络)$p_\theta(x \mid z)$:输入 $z$,输出重构 $x$ 的参数。
两者取最常用的高斯形式:$q_\phi(z \mid x) = \mathcal{N}(z;\, \mu(x),\, \sigma^2(x))$$p_\theta(x \mid z)$ 由解码器输出决定。下面逐行对照 [[main.py]] 讲。
### 把 q_θ(z) 换成 q_φ(z | x),等式还成立吗
成立。回看 ELBO 推导([[变分下界ELBO笔记#KL 散度分解]]),恒等式
$$
\log p(x) = \mathbb{E}_{z \sim q}[\log p(z, x) - \log q(z)] + D_{\mathrm{KL}}[q(z) \,\|\, p(z \mid x)]
$$
从头到尾没有用到 $q$ 的具体形式,对**任意**变分分布都成立;$q_\phi(z \mid x)$ 不过是"对当前观测 $x$ 选定一个分布"。逐处替换 $q_\theta(z) \to q_\phi(z \mid x)$ 即可:
$$
\log p(x) = \mathbb{E}_{z \sim q_\phi(z \mid x)}[\log p(z, x) - \log q_\phi(z \mid x)] + D_{\mathrm{KL}}[q_\phi(z \mid x) \,\|\, p(z \mid x)]
$$
推导中唯一的技巧仍然只是"$\log p(x)$ 不含 $z$、可提出期望",与 $q$ 的形式无关。
语义变化:原来一个 $q_\theta$ 服务所有 $x$(换观测要重新优化参数),现在网络 $q_\phi$ 对每个 $x$ 直接输出专属的变分分布。训练目标是整个数据集上各样本 ELBO 的平均:
$$
\frac{1}{N}\sum_{n=1}^{N} \log p(x^{(n)}) \geq \frac{1}{N}\sum_{n=1}^{N} \mathcal{L}(x^{(n)}), \qquad
\mathcal{L}(x^{(n)}) = \mathbb{E}_{z \sim q_\phi(z \mid x^{(n)})}[\log p(x^{(n)} \mid z)] - D_{\mathrm{KL}}[q_\phi(z \mid x^{(n)}) \,\|\, p(z)]
$$
采样时只需把重参数化中的 $\mu$、$\sigma$ 换成关于 $x$ 的函数:$z = \mu_\phi(x) + \sigma_\phi(x) \cdot \varepsilon$。
## 编码器:输出 μ 与 log σ²
### 网络结构
```python
self.encoder = nn.Sequential(
nn.Linear(784, 256), nn.ReLU(),
nn.Linear(256, 64), nn.ReLU(),
nn.Linear(64, 20),
)
```
- 输入 784 维 = 28 × 28 像素展平;
- 最后一层输出 20 维,**前 10 维是 $\mu$,后 10 维是 $\log \sigma^2$**
```python
mu, log_var = hidden.chunk(2, dim=1)
```
潜在空间只有 10 维,远小于输入 784 维,这是**信息瓶颈**$z$ 必须压缩出最关键的特征,才能重构好图像。
### 为什么存 log σ² 而不是 σ²
方差必须非负,直接输出 $\sigma^2$ 需要额外约束;而 $\log \sigma^2$ 可以取任意实数,网络自由输出,使用时再指数还原:
$$
\sigma = e^{\frac{1}{2} \log \sigma^2}, \qquad q_\phi(z \mid x) = \mathcal{N}(z;\, \mu,\, \sigma^2 I)
$$
数值上也更稳定:极小的方差在 $\log$ 空间不会下溢成负值。
## 重参数化:让"采样"变得可导
训练需要关于 $\mu$、$\sigma$ 的梯度,但直接采样不可导:
$$
z \sim \mathcal{N}(\mu, \sigma^2) \qquad \text{采样操作没有梯度}
$$
重参数化把采样拆成"确定性变换 + 标准噪声"
$$
z = \mu + \sigma \cdot \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, 1)
$$
随机性全部由与参数无关的 $\varepsilon$ 承担,梯度可以经 $\mu$、$\sigma$ 正常回传($\frac{\partial z}{\partial \mu} = 1$$\frac{\partial z}{\partial \sigma} = \varepsilon$)。
```python
std = torch.exp(0.5 * log_var)
eps = torch.randn_like(std)
z = mu + eps * std
```
## 解码器:从 z 重构图像
```python
self.decoder = nn.Sequential(
nn.Linear(10, 64), nn.ReLU(),
nn.Linear(64, 256), nn.ReLU(),
nn.Linear(256, 784), nn.Sigmoid(),
)
```
- 输入是 10 维隐变量 $z$
- 输出 784 维后接 **Sigmoid**,把值压到 $[0, 1]$,对应像素灰度(数据归一化后也在 $[0, 1]$)。
解码器输出的是 $p_\theta(x \mid z)$ 的**参数**:把每个像素 $x_i$ 视为独立的 Bernoulli 分布,
$$
p_\theta(x_i \mid z) = \hat{x}_i^{x_i}\, (1 - \hat{x}_i)^{1 - x_i}
$$
$\hat{x}_i$ 就是"从 $z$ 重构第 $i$ 个像素的概率",这正是 ELBO 笔记里"重构项"的实现。
## 重构损失二元交叉熵BCE
把全部像素的对数似然加起来:
$$
\log p_\theta(x \mid z) = \sum_{i=1}^{784} \left[x_i \log \hat{x}_i + (1 - x_i) \log(1 - \hat{x}_i)\right]
$$
训练取负号(最大化似然 ⟺ 最小化负对数似然):
$$
\text{BCE} = -\sum_i \left[x_i \log \hat{x}_i + (1 - x_i) \log(1 - \hat{x}_i)\right]
$$
```python
criterion = nn.BCELoss(reduction="sum")
recon_loss = criterion(x_hat, data)
```
$x̂$ 与 $x$ 逐像素对应BCE 大 ⟺ 重构图与输入图差异大。这就是 $- \mathbb{E}_q[\log p_\theta(x \mid z)]$ 的蒙特卡洛估计(每步只抽一个样本 $z$)。
## KL 项:闭式推导
标准先验 $p(z) = \mathcal{N}(0, I)$ 与高斯后验 $q_\phi(z \mid x) = \mathcal{N}(\mu, \sigma^2 I)$ 的 KL 有解析式,无需采样:
$$
D_{\mathrm{KL}}[q_\phi \,\|\, p(z)] = -\frac{1}{2}\sum_{j=1}^{10}\left(1 + \log \sigma_j^2 - \mu_j^2 - \sigma_j^2\right)
$$
```python
KL = -0.5 * torch.sum(1 + log_var - mu.pow(2) - log_var.exp())
```
### 推导过程
对一维 $q = \mathcal{N}(\mu, \sigma^2)$、$p = \mathcal{N}(0, 1)$,由定义
$$
D_{\mathrm{KL}}[q \,\|\, p] = \mathbb{E}_q[\log q(z)] - \mathbb{E}_q[\log p(z)]
$$
第一项($q$ 的负熵,用到 $\mathbb{E}_q[(z - \mu)^2] = \sigma^2$
$$
\mathbb{E}_q[\log q(z)] = -\frac{1}{2}\log(2\pi e \sigma^2)
$$
第二项(用到 $\mathbb{E}_q[z^2] = \mu^2 + \sigma^2$
$$
\mathbb{E}_q[\log p(z)] = -\frac{1}{2}\log(2\pi) - \frac{1}{2}(\mu^2 + \sigma^2)
$$
相减得
$$
D_{\mathrm{KL}} = \frac{1}{2}(\mu^2 + \sigma^2 - 1 - \log \sigma^2)
$$
写成代码的形式($\log \sigma^2$ 就是 `log_var`$\sigma^2$ 就是 `log_var.exp()`
$$
-\frac{1}{2}(1 + \log \sigma^2 - \mu^2 - \sigma^2)
$$
10 个维度各自算完再求和,就是那一行 `KL`。逐项看它如何起作用:
- 若编码器输出 $\mu = 0$、$\sigma^2 = 1$KL = 0$q_\phi$ 恰好等于先验;
- $\mu \neq 0$ 或 $\sigma^2 \neq 1$ 都会使 KL > 0产生"偏离先验"的惩罚。
### 与重构损失的尺度对齐
```python
KL = KL / (x.size(0) * 28 * 28)
recon_loss = recon_loss / (data.size(0) * 28 * 28)
```
- BCE 用 `reduction="sum"` 按 batch 求和;
- 两者再除以 batch × 784统一成"每个像素的平均损失"再相加,避免 KL 因维度求和而压过重构项。
## 训练与生成
### 训练循环
```python
loss = recon_loss + kl
loss.backward()
optimizer.step()
```
最小化 $\text{loss} = -\text{ELBO}$(重构误差 + KL 正则),即把 ELBO 笔记里的下界往上抬:重构项让 $q_\phi$ 选能还原 $x$ 的 $z$,正则项让 $q_\phi$ 靠近标准正态。
### 生成新样本
训练完成后不再需要编码器,直接从先验采样、解码:
```python
sample_z = torch.randn(16, 10)
generated = model.decoder(sample_z)
```
数学上这就是用蒙特卡洛估计边缘分布
$$
p(x) = \int p_\theta(x \mid z)\, p(z)\, dz \approx \frac{1}{N}\sum_{k=1}^{N} p_\theta(x \mid z^{(k)}), \qquad z^{(k)} \sim \mathcal{N}(0, I)
$$
KL 项保证了编码器把训练数据映射到标准正态附近,所以从先验随机采 $z$ 也能落在数据的分布区域,生成的图像才像 MNIST。
## 与 ELBO 笔记的对应
| VAE 组件 | 代码位置 | 对应 ELBO 概念 |
|---|---|---|
| 编码器 $q_\phi(z \mid x)$ | `encoder` + `chunk` | 变分分布 $q_\theta$(推广为依赖 $x$ |
| 解码器 $p_\theta(x \mid z)$ | `decoder` + `Sigmoid` | $p(x \mid z)$ |
| 重构损失 BCE | `nn.BCELoss` | $- \mathbb{E}_q[\log p(x \mid z)]$ |
| KL 正则 | `KL` 一行 | $D_{\mathrm{KL}}[q_\phi \,\|\, p(z)]$ |
| 重参数化 | `z = mu + eps * std` | 让 ELBO 的梯度能回传 |

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,878 @@
---
title: "物理电磁学笔记"
date: 2026-07-23
tags: ["物理", "电磁学"]
description: "物理电磁学笔记"
draft: false # true 时本地可见、构建不发布(写草稿用)
pinned: false # true 时首页置顶
heroGradient: ["#7fb8ff", "#2f8df0"] # 可选,封面渐变色
---
# 1.角动量 力矩
这个知识虽然不算电磁学,但是考虑到有点忘了而且后面可能用到,补上来。
## 角动量:
$$
\mathbf{L}=\mathbf{r} \times m \mathbf{v}=\mathbf{r} \times \mathbf{p}
$$
单位$kg\cdot m^{2} \cdot s^{-1}$ 方向由右手螺旋定律确定。
## 力矩:
$$
\mathbf{M}=\mathbf{r}\times\mathbf{F}=\frac{d\mathbf{L}}{dt}
$$
力矩的功
$$
d\mathbf{W}=\mathbf{F} \cdot d\mathbf{s}=\mathbf{F}\sin{\varphi}\mathbf{r}d\theta
$$
我们注意到$M=\mathbf{F}\mathbf{r}\sin{\varphi}$ 所以有
$$
W=\int_{0}^{\theta}{Md{\theta}}
$$
至于刚体的转动惯量这些,就无所谓了,关系不大。
> **相关**[[#载流线圈的磁力矩]]
# 2.静电场
### 2.1 库仑定律
$$
\mathbf{F}=\frac{1}{4\pi \varepsilon_{0}}\frac{q_{1}q_{2}}{r^{2}}\mathbf{e_r}
$$
其中的$\varepsilon_{0}$ 称作真空介电常量,又称作真空电容率。$\varepsilon_0\approx9.0\times10^{9} N\cdot m^{2}\cdot C^{-2}$
重点在于与高中之间的区别。注意方向。
> **相关**[[#2.2 电场 电场强度]] | [[#点电荷的电势]] | [[#有介质时的高斯定理]]
### 2.2 电场 电场强度
跟高中一样
$$\mathbf{E}=\frac{\mathbf{F}}{q_{0}}$$
叠加是矢量和
> **相关**[[#2.1 库仑定律]] | [[#2.3 高斯定理及其应用]] | [[#洛伦兹力]]
### 2.3 高斯定理及其应用
#### E通量
电场通过某一曲面的 E 通量,即穿过该曲面的电场线条数。
$$
\Phi_{E}=\int_{S}{\mathbf{E}\cdot d\mathbf{S}}
$$
闭合曲面的 E 通量则用 $\oint$ 表示。
#### 高斯定理:
真空中,通过任一闭合曲面 S 的 E 通量等于该闭合曲面内所有电荷代数和除以 $\varepsilon_{0}$,与曲面外的电荷无关。
$$
\oint_{S}{\mathbf{E}\cdot d\mathbf{S}}=\frac{1}{\varepsilon_{0}}\sum_{S_{内}}{q_{i}}
$$
注意E 是空间所有电荷(包括曲面外电荷)共同产生的总电场,但通量只取决于内部电荷。这为了后面有介质的高斯定理做了铺垫
> **相关**[[#D通量与有介质时的高斯定理]] | [[#静电平衡]] | [[#均匀带电球面的电势]]
### 2.4静电场的环路定理 电势
#### 静电场的环路定理:
静电场力做功与路径无关,静电场是保守场。
$$
\oint_{L}{\mathbf{E}\cdot d\mathbf{l}}=0
$$
#### 电势:
静电场是保守场,可引入标量势函数——电势。将单位正电荷从某点 $a$ 移到无穷远电场力做的功称为该点的电势。
$$
V_{a}=\int_{a}^{\infty}{\mathbf{E}\cdot d\mathbf{l}}
$$
通常取无穷远处为零电势参考点。
#### 电势能:
试探电荷 $q_{0}$ 在电场中某点的电势能等于 $q_{0}$ 乘以该点的电势。
$$
W_{ab}=q_{0}(V_{a}-V_{b})
$$
#### 点电荷的电势:
由库仑定律 $E=\dfrac{1}{4\pi\varepsilon_{0}}\dfrac{q}{r^{2}}$,沿着径向积分:
$$
V(r)=\int_{r}^{\infty}{\frac{1}{4\pi\varepsilon_{0}}\frac{q}{r'^{2}}dr'}=\frac{1}{4\pi\varepsilon_{0}}\left[-\frac{1}{r'}\right]_{r}^{\infty}q=\frac{1}{4\pi\varepsilon_{0}}\frac{q}{r}
$$
即点电荷的电势与距离成反比,正电荷周围电势为正,负电荷周围为负。
#### 均匀带电球面的电势:
设球面半径为 $R$,总电荷为 $Q$。
由高斯定理已求得场强分布:
$$
E=
\begin{cases}
0, & r<R \\[2mm]
\dfrac{1}{4\pi\varepsilon_{0}}\dfrac{Q}{r^{2}}, & r>R
\end{cases}
$$
球面外 $(r>R)$:积分路径在球外,场强与点电荷相同。
$$
V(r)=\int_{r}^{\infty}{\frac{1}{4\pi\varepsilon_{0}}\frac{Q}{r'^{2}}dr'}=\frac{1}{4\pi\varepsilon_{0}}\frac{Q}{r}
$$
球面内 $(r<R)$需分两段积分球外段和球内段球内 $E=0$)。
$$
V(r)=\int_{r}^{\infty}{\mathbf{E}\cdot d\mathbf{l}}=\int_{r}^{R}{0\cdot dr'}+\int_{R}^{\infty}{\frac{1}{4\pi\varepsilon_{0}}\frac{Q}{r'^{2}}dr'}=0+\frac{1}{4\pi\varepsilon_{0}}\frac{Q}{R}
$$
因此均匀带电球面内电势为常数,等于球面处电势;球面外电势与点电荷相同。
$$
V(r)=
\begin{cases}
\dfrac{1}{4\pi\varepsilon_{0}}\dfrac{Q}{R}, & r\le R \\[2mm]
\dfrac{1}{4\pi\varepsilon_{0}}\dfrac{Q}{r}, & r>R
\end{cases}
$$
> **相关**[[#静电场的环路定理:]] | [[#电势能]] | [[#电场能量与能量密度]]
### 2.5静电平衡
导体在电场中达到静电平衡时满足以下条件:
1. 导体内部任意点的场强处处为零。
2. 导体表面附近场强方向垂直于表面。
3. 整个导体是等势体,导体表面是等势面。
由高斯定理证明:在导体内部取任意闭合高斯面 $S$,静电平衡时内部无净电荷(自由电荷已移动至表面),故 $\oint_{S}\mathbf{E}\cdot d\mathbf{S}=0$。由于 $S$ 是任意的,推出导体内各处 $\mathbf{E}=0$。
表面电荷面密度与场强关系:取一跨越导体表面的扁圆柱形高斯面,底面 $\Delta S$ 平行于表面,一部分在导体内($\mathbf{E}=0$),一部分在导体外。忽略侧面通量,有:
$$
\oint\mathbf{E}\cdot d\mathbf{S}=E\Delta S=\frac{1}{\varepsilon_{0}}\sigma\Delta S
$$
$$
\boxed{E=\frac{\sigma}{\varepsilon_{0}}}
$$
即导体表面附近场强大小与该点电荷面密度成正比。
#### 空腔导体:
**腔内无带电体:**
空腔导体达到静电平衡时,腔内无电场,内表面无电荷,电荷全部分布在外表面。取高斯面包围空腔(整体在导体内部),由于导体内 $\mathbf{E}=0$$\oint\mathbf{E}\cdot d\mathbf{S}=0$,故内表面总电荷为零。若内表面某处有正电荷,则必有电力线终止于负电荷处,产生电势差,与静电平衡矛盾,因此内表面处处无电荷。
**腔内有带电体:**
设腔内有电荷 $+q$,则空腔内表面感应出 $-q$,外表面感应出 $+q$(若导体原本不带电)。由高斯定理:取高斯面包围空腔(穿过导体内部),内表面电荷与腔内电荷代数和为零,故内表面感应 $-q$,由电荷守恒推出外表面为 $+q$。
注意:腔内电荷的位置、分布仅影响内表面感应电荷的分布,**不影响外表面感应电荷的分布**——只要腔内总电荷不变,外表面的电荷总量和分布都不变。
#### 静电屏蔽:
**不接地的情况:**
- **屏蔽外电场**:空腔导体在外电场中达到静电平衡时,腔内电场处处为零,腔内物体不受外部电场影响。即使不接地也能实现对外电场的屏蔽(外表面感应电荷不会影响腔内)。
- **不能屏蔽内电场**:若腔内存在带电体但不接地,外表面会感应出等量同号电荷,外部空间仍能感受到内部电荷的电场,**不能**屏蔽内电场。
**接地的情况:**
- **屏蔽外电场**:接地后导体与大地等势,屏蔽效果不变,且外表面电荷可流入大地,避免导体外表面电荷积累带来的影响。
- **屏蔽内电场**:接地后,外表面感应电荷被导走至大地,导体外部不再有电场,实现**完全屏蔽内电场**。
**总结**:不接地的空腔导体只能屏蔽外电场(需导体封闭),不能屏蔽内电场;接地的空腔导体可同时屏蔽内外电场。
> **相关**[[#高斯定理:]] | [[#有介质时的高斯定理]]
### 2.6有介质时的高斯定理
#### 电位移矢量 $\mathbf{D}$
在有电介质的空间中,为简化计算引入辅助场量——电位移矢量:
$$
\mathbf{D}=\varepsilon_{0}\mathbf{E}+\mathbf{P}
$$
其中 $\mathbf{P}$ 为极化强度,反映电介质极化程度。对各向同性线性介质,$\mathbf{P}=\varepsilon_{0}\chi_{e}\mathbf{E}$$\chi_{e}$ 为极化率,此时有:
$$
\mathbf{D}=\varepsilon_{0}(1+\chi_{e})\mathbf{E}=\varepsilon_{0}\varepsilon_{r}\mathbf{E}\equiv\varepsilon\mathbf{E}
$$
$\varepsilon_{r}=1+\chi_{e}$ 为相对介电常数,$\varepsilon=\varepsilon_{0}\varepsilon_{r}$ 为介电常数。
#### D通量与有介质时的高斯定理
通过闭合曲面的 $\mathbf{D}$ 通量只与该曲面内**自由电荷**有关,与极化电荷无关。
$$
\oint_{S}{\mathbf{D}\cdot d\mathbf{S}}=\sum_{S_{内}}{Q_{\text{自由}}}
$$
这是有介质时的高斯定理的积分形式,注意其中只包含自由电荷,不包含极化电荷。且注意一下右侧没有$\varepsilon_{0}$
#### 点电荷在均匀电介质中的电场强度和电势:
设点电荷 $q$ 置于无限大均匀电介质(相对介电常数 $\varepsilon_{r}$)中。由有介质高斯定理,取以 $q$ 为球心的球面:
$$
\oint_{S}{\mathbf{D}\cdot d\mathbf{S}}=D\cdot4\pi r^{2}=q
\;\Rightarrow\;
D=\frac{q}{4\pi r^{2}}
$$
再由 $\mathbf{D}=\varepsilon\mathbf{E}=\varepsilon_{0}\varepsilon_{r}\mathbf{E}$ 得:
$$
E=\frac{D}{\varepsilon_{0}\varepsilon_{r}}=\frac{1}{4\pi\varepsilon_{0}\varepsilon_{r}}\frac{q}{r^{2}}
$$
沿径向积分得电势:
$$
V(r)=\int_{r}^{\infty}{E\,dr'}=\int_{r}^{\infty}{\frac{1}{4\pi\varepsilon_{0}\varepsilon_{r}}\frac{q}{r'^{2}}dr'}=\frac{1}{4\pi\varepsilon_{0}\varepsilon_{r}}\frac{q}{r}
$$
可见电介质中的场强和电势均比真空中缩小为原来的 $1/\varepsilon_{r}$ 倍。
#### 一对无限大均匀带电平板间的电场强度和电势差:
设两平行板面电荷密度分别为 $+\sigma$ 和 $-\sigma$,板间充满相对介电常数为 $\varepsilon_{r}$ 的均匀电介质。取一跨越下极板的扁圆柱形高斯面,由有介质高斯定理:
$$
\oint_{S}{\mathbf{D}\cdot d\mathbf{S}}=D\Delta S=\sigma\Delta S
\;\Rightarrow\;
D=\sigma
$$
因此板间电位移大小恒为 $\sigma$,方向由正极板指向负极板。板间场强为:
$$
E=\frac{D}{\varepsilon_{0}\varepsilon_{r}}=\frac{\sigma}{\varepsilon_{0}\varepsilon_{r}}
$$
两板间电势差(板间距 $d$
$$
U=Ed=\frac{\sigma}{\varepsilon_{0}\varepsilon_{r}}d=\frac{Qd}{\varepsilon_{0}\varepsilon_{r}S}
$$
其中 $Q=\sigma S$ 为极板电荷量。与真空相比,板间电势差降为原来的 $1/\varepsilon_{r}$ 倍。
> **相关**[[#高斯定理:]] | [[#平行板电容器]] | [[#电容器的能量]]
### 2.7 电容和电容器
#### 电容的定义:
电容是导体储存电荷能力的度量,定义为导体所带电荷量 $Q$ 与其电势 $V$ 的比值:
$$
C=\frac{Q}{V}
$$
对于电容器(两导体组成的系统),定义为其中一个极板所带电荷量 $Q$ 与两极板间电势差 $U$ 的比值:
$$
C=\frac{Q}{U}
$$
单位法拉F$1\,\text{F}=1\,\text{C/V}$。
#### 平行板电容器:
设两极板面积 $S$,间距 $d$$d\ll\sqrt{S}$),板间充满 $\varepsilon_{r}$ 的电介质。由前述推导:
$$
E=\frac{\sigma}{\varepsilon_{0}\varepsilon_{r}}=\frac{Q}{\varepsilon_{0}\varepsilon_{r}S},\qquad U=Ed=\frac{Qd}{\varepsilon_{0}\varepsilon_{r}S}
$$
$$
\boxed{C=\frac{Q}{U}=\frac{\varepsilon_{0}\varepsilon_{r}S}{d}}
$$
#### 球形电容器:
由两个同心球壳构成,内球半径 $R_{1}$,外球内半径 $R_{2}$,其间充满 $\varepsilon_{r}$ 电介质。由高斯定理:
$$
E=\frac{1}{4\pi\varepsilon_{0}\varepsilon_{r}}\frac{Q}{r^{2}},\quad
U=\int_{R_{1}}^{R_{2}}{E\,dr}=\frac{Q}{4\pi\varepsilon_{0}\varepsilon_{r}}\left(\frac{1}{R_{1}}-\frac{1}{R_{2}}\right)
$$
$$
\boxed{C=\frac{Q}{U}=4\pi\varepsilon_{0}\varepsilon_{r}\frac{R_{1}R_{2}}{R_{2}-R_{1}}}
$$
#### 圆柱形电容器:
由两个同轴圆柱面构成,内半径 $R_{1}$,外半径 $R_{2}$,长度 $L$$L\gg R_{2}$),其间充满 $\varepsilon_{r}$ 电介质,线电荷密度 $\lambda=Q/L$
$$
E=\frac{\lambda}{2\pi\varepsilon_{0}\varepsilon_{r}r},\quad
U=\int_{R_{1}}^{R_{2}}{E\,dr}=\frac{\lambda}{2\pi\varepsilon_{0}\varepsilon_{r}}\ln\frac{R_{2}}{R_{1}}
$$
$$
\boxed{C=\frac{Q}{U}=\frac{2\pi\varepsilon_{0}\varepsilon_{r}L}{\ln(R_{2}/R_{1})}}
$$
#### 电容器的连接:
**串联:**
各电容器电荷量相同,总电压等于各电容电压之和。
$$
\frac{1}{C}=\frac{1}{C_{1}}+\frac{1}{C_{2}}+\cdots+\frac{1}{C_{n}}
$$
串联后总电容减小。
**并联:**
各电容器电压相同,总电荷量等于各电容电荷量之和。
$$
C=C_{1}+C_{2}+\cdots+C_{n}
$$
并联后总电容增大。
#### 特例:左右介质不同的平行板电容器:
平行板电容器极板面积 $S$,间距 $d$,左右两侧分别填充相对介电常数为 $\varepsilon_{r1}$ 和 $\varepsilon_{r2}$ 的电介质(各占面积 $S/2$)。两区域电压相同,相当于两个电容器的并联:
$$
C_{1}=\frac{\varepsilon_{0}\varepsilon_{r1}(S/2)}{d},\qquad
C_{2}=\frac{\varepsilon_{0}\varepsilon_{r2}(S/2)}{d}
$$
$$
\boxed{C=C_{1}+C_{2}=\frac{\varepsilon_{0}S}{2d}(\varepsilon_{r1}+\varepsilon_{r2})}
$$
若两介质上下分层(厚度各为 $d_{1}$、$d_{2}$$d_{1}+d_{2}=d$),则相当于串联:
$$
\frac{1}{C}=\frac{1}{C_{1}}+\frac{1}{C_{2}}
\quad\Rightarrow\quad
\boxed{C=\frac{\varepsilon_{0}S}{\displaystyle\frac{d_{1}}{\varepsilon_{r1}}+\frac{d_{2}}{\varepsilon_{r2}}}}
$$
> **相关**[[#有介质时的高斯定理]] | [[#电容器的能量]] | [[#一对无限大均匀带电平板间的电场强度和电势差]]
### 2.8 静电场的能量
#### 点电荷系的电能:
将点电荷系从无限远移到现在位置,外力克服电场力所做的功转化为电能。
两个点电荷:设 $q_{1}$ 固定,$q_{2}$ 从无穷远移到距 $q_{1}$ 为 $r$ 处,$q_{1}$ 在该处的电势 $\displaystyle V_{2}=\frac{1}{4\pi\varepsilon_{0}}\frac{q_{1}}{r}$,外力做功即为电能:
$$
W=q_{2}V_{2}=\frac{1}{4\pi\varepsilon_{0}}\frac{q_{1}q_{2}}{r}
$$
对称形式:
$$
W=\frac{1}{2}(q_{1}V_{1}+q_{2}V_{2})
$$
对 $n$ 个点电荷推广:
$$
\boxed{W=\frac{1}{2}\sum_{i=1}^{n}{q_{i}V_{i}}}
$$
其中 $V_{i}$ 为除 $q_{i}$ 外其他所有电荷在 $q_{i}$ 处产生的电势。
#### 电容器的能量:
电容器充电过程相当于将电荷 $dq$ 从一极板移到另一极板,外力克服电场力做功。设某时刻极板电荷 $q$,电势差 $u=q/C$,移动 $dq$ 做功:
$$
dW=u\,dq=\frac{q}{C}dq
$$
积分得电容器总电能:
$$
W=\int_{0}^{Q}{\frac{q}{C}dq}=\frac{Q^{2}}{2C}=\frac{1}{2}QU=\frac{1}{2}CU^{2}
$$
#### 电场能量与能量密度:
电能定域在电场中,即电场本身储存能量。以平行板电容器为例:
$$
W=\frac{1}{2}CU^{2}=\frac{1}{2}\cdot\frac{\varepsilon_{0}\varepsilon_{r}S}{d}\cdot(Ed)^{2}
=\frac{1}{2}\varepsilon_{0}\varepsilon_{r}E^{2}\cdot Sd
$$
$Sd$ 为极板间体积,因此电场的**能量密度**(单位体积电场能量)为:
$$
\boxed{w_{e}=\frac{W}{V}=\frac{1}{2}\varepsilon E^{2}=\frac{1}{2}\mathbf{D}\cdot\mathbf{E}}
$$
该式适用于任意静电场,对空间各点积分即得总电场能量:
$$
W=\int_{V}{w_{e}\,dV}=\frac{1}{2}\int_{V}{\varepsilon E^{2}\,dV}
$$
**例:球形电容器的电场能量。**
内半径 $R_{1}$,外半径 $R_{2}$,其间充满 $\varepsilon$ 电介质,带电量 $Q$。
用能量密度法:
$$
E=\frac{1}{4\pi\varepsilon}\frac{Q}{r^{2}}\;(R_{1}<r<R_{2}),\quad
w_{e}=\frac{1}{2}\varepsilon E^{2}=\frac{Q^{2}}{32\pi^{2}\varepsilon r^{4}}
$$
$$
W=\int_{V}{w_{e}\,dV}=\int_{R_{1}}^{R_{2}}{\frac{Q^{2}}{32\pi^{2}\varepsilon r^{4}}\cdot4\pi r^{2}dr}
=\frac{Q^{2}}{8\pi\varepsilon}\int_{R_{1}}^{R_{2}}{\frac{dr}{r^{2}}}
=\frac{Q^{2}}{8\pi\varepsilon}\left(\frac{1}{R_{1}}-\frac{1}{R_{2}}\right)
$$
用电容器能量公式法:
$$
C=4\pi\varepsilon\frac{R_{1}R_{2}}{R_{2}-R_{1}},\quad
W=\frac{Q^{2}}{2C}=\frac{Q^{2}}{2}\cdot\frac{R_{2}-R_{1}}{4\pi\varepsilon R_{1}R_{2}}
=\frac{Q^{2}}{8\pi\varepsilon}\left(\frac{1}{R_{1}}-\frac{1}{R_{2}}\right)
$$
两种方法结果一致。
> **相关**[[#电容器的能量]] | [[#球形电容器]] | [[#电势能]]
# 3.恒定磁场
## 3.1 磁场 磁感应强度 洛伦兹力
#### 磁感应强度 $\mathbf{B}$
描述磁场强弱和方向的物理量,单位特斯拉($\text{T}$$1\,\text{T}=1\,\text{N/(A·m)}$。
#### 洛伦兹力:
运动电荷在磁场中受到的力。
$$
\mathbf{F}=q\mathbf{v}\times\mathbf{B}
$$
大小 $F=qvB\sin\theta$,方向由右手螺旋定则判定。$\mathbf{F}$ 始终垂直于 $\mathbf{v}$,故洛伦兹力不做功。
#### 磁通量:
通过某一曲面的磁感应线数。
$$
\Phi_{B}=\int_{S}{\mathbf{B}\cdot d\mathbf{S}}
$$
单位韦伯($\text{Wb}$$1\,\text{Wb}=1\,\text{T·m}^{2}$。
> **相关**[[#洛伦兹力]] | [[#安培定律:]] | [[#磁场中的高斯定理]]
## 3.2 毕奥-萨伐尔定律
电流元 $Id\boldsymbol{l}$ 在空间某点产生的磁感应强度:
$$
d\mathbf{B}=\frac{\mu_{0}}{4\pi}\frac{Id\boldsymbol{l}\times\mathbf{e}_{r}}{r^{2}}
$$
$\mu_{0}=4\pi\times10^{-7}\,\text{T·m/A}$ 为真空磁导率。整个回路产生的磁场:
$$
\mathbf{B}=\frac{\mu_{0}I}{4\pi}\int_{L}{\frac{d\boldsymbol{l}\times\mathbf{e}_{r}}{r^{2}}}
$$
#### 1. 载流直导线的磁场:
设直导线长 $L$,电流 $I$,场点 $P$ 到导线的垂直距离为 $a$,两端与 $P$ 连线和电流方向的夹角分别为 $\theta_{1}$、$\theta_{2}$。
$$
dB=\frac{\mu_{0}}{4\pi}\frac{I\sin\theta\,dx}{r^{2}},\quad
x=a\cot(\pi-\theta)=-a\cot\theta,\quad
r=\frac{a}{\sin\theta}
$$
代入积分得:
$$
B=\int_{\theta_{1}}^{\theta_{2}}{\frac{\mu_{0}I}{4\pi a}\sin\theta\,d\theta}
=\frac{\mu_{0}I}{4\pi a}(\cos\theta_{1}-\cos\theta_{2})
$$
**无限长直导线**$\theta_{1}=0,\;\theta_{2}=\pi$
$$
B=\frac{\mu_{0}I}{2\pi a}
$$
**半无限长直导线**$\theta_{1}=0,\;\theta_{2}=\pi/2$ 或 $\theta_{1}=\pi/2,\;\theta_{2}=\pi$
$$
B=\frac{\mu_{0}I}{4\pi a}
$$
#### 2. 圆形载流导线轴线上的磁场:
设圆环半径 $R$,电流 $I$,轴线上距圆心 $x$ 处取 $P$ 点。由对称性,$dB_{\perp}$ 分量相互抵消,只有轴向分量 $dB_{\parallel}$ 贡献:
$$
dB_{\parallel}=dB\sin\varphi=\frac{\mu_{0}}{4\pi}\frac{Idl}{r^{2}}\cdot\frac{R}{r},\quad
r=\sqrt{R^{2}+x^{2}}
$$
$$
B=\int_{0}^{2\pi R}{\frac{\mu_{0}I}{4\pi}\frac{R}{r^{3}}dl}
=\frac{\mu_{0}IR^{2}}{2(R^{2}+x^{2})^{3/2}}
$$
**圆心处**$x=0$
$$
B=\frac{\mu_{0}I}{2R}
$$
**$x\gg R$**(远处近似):
$$
B\approx\frac{\mu_{0}IR^{2}}{2x^{3}}
$$
#### 3. 载流密绕直螺线管内部轴线上的磁场:
设螺线管单位长度匝数 $n$,每匝电流 $I$,半径 $R$。取 $dx$ 段等效为圆形电流,其在轴线上 $P$ 点产生的磁场:
$$
dB=\frac{\mu_{0}nI R^{2}dx}{2(R^{2}+x^{2})^{3/2}}
$$
令 $x=R\cot\beta$,得:
$$
B=\frac{\mu_{0}nI}{2}\int_{\beta_{1}}^{\beta_{2}}{(-\sin\beta)\,d\beta}
=\frac{\mu_{0}nI}{2}(\cos\beta_{1}-\cos\beta_{2})
$$
**无限长螺线管**$\beta_{1}=0,\;\beta_{2}=\pi$
$$
B=\mu_{0}nI
$$
内部磁场均匀,方向沿轴线。
#### 4. 运动电荷的磁场:
电流 $I=nqSv$,其中 $n$ 为电荷数密度,$S$ 为截面积,$q$ 为每个电荷的带电量,$v$ 为定向漂移速度。
$$
Id\boldsymbol{l}=nqSv\,d\boldsymbol{l}=nqS\,d\boldsymbol{l}\,v=Nq\mathbf{v}
$$
其中 $N=nS\,dl$ 为 $dl$ 段中的电荷数。由毕奥-萨伐尔定律,一个运动电荷产生的磁场:
$$
\mathbf{B}=\frac{d\mathbf{B}}{N}
=\frac{\mu_{0}}{4\pi}\frac{q\mathbf{v}\times\mathbf{e}_{r}}{r^{2}}
$$
方向由右手螺旋定则判定:$\mathbf{v}$ 转向 $\mathbf{r}$ 的方向为 $\mathbf{B}$ 方向。
> **相关**[[#安培环路定理]] | [[#载流直导线的磁场:]] | [[#运动电荷的磁场]]
## 3.3 磁场中的高斯定理
在磁场中通过任意闭合曲面的$\mathbf{B}$通量均等于零,即
$$
\oint_{S}{\mathbf{B}\cdot d\mathbf{S}}=0
$$
> **相关**[[#高斯定理:]] | [[#磁介质中的安培环路定理]]
## 3.4 安培环路定理
在恒定磁场中,磁感应强度 $\mathbf{B}$ 沿任意闭合回路的线积分等于该回路所包围电流代数和的 $\mu_{0}$ 倍。
$$
\oint_{L}{\mathbf{B}\cdot d\mathbf{l}}=\mu_{0}\sum_{L_{内}}{I_{i}}
$$
注意积分回路方向与电流正方向成右手螺旋关系时电流取正,反之取负。
#### 应用:载流长直螺线管内部磁感应强度
设螺线管单位长度匝数 $n$,电流 $I$。由对称性,管内 $\mathbf{B}$ 沿轴向均匀,管外 $\mathbf{B}\approx0$。取矩形回路 $abcd$$ab$ 段平行于轴线且长度为 $l$,由安培环路定理:
$$
\oint_{L}{\mathbf{B}\cdot d\mathbf{l}}
=\int_{ab}{B\,dl}+0+0+0=B\cdot l=\mu_{0}nIl
$$
$$
\boxed{B=\mu_{0}nI}
$$
#### 应用:螺绕环磁感应分布
设螺绕环总匝数 $N$,电流 $I$,环内半径为 $r$ 处取圆形回路。由对称性,$\mathbf{B}$ 沿圆周切向且大小恒定。螺绕环内部($R_{1}<r<R_{2}$
$$
\oint_{L}{\mathbf{B}\cdot d\mathbf{l}}=B\cdot2\pi r=\mu_{0}NI
$$
$$
\boxed{B=\frac{\mu_{0}NI}{2\pi r}}
$$
螺绕环外部($r<R_{1}$ $r>R_{2}$):回路所包围总电流代数和为零($NI$ 穿入一次又穿出一次),故:
$$
\boxed{B=0}
$$
特例:当螺绕环截面半径远小于环平均半径时,$r\approx R$(平均半径),环内磁场近似均匀 $B\approx\mu_{0}nI$,其中 $n=N/(2\pi R)$ 为单位长度匝数。
> **相关**[[#毕奥-萨伐尔定律]] | [[#载流密绕直螺线管内部轴线上的磁场]] | [[#磁介质简介]]
### 3.5 带电粒子在磁场中的运动
带电粒子在磁场中受洛伦兹力:
$$
\mathbf{F}=q\mathbf{v}\times\mathbf{B}
$$
#### 圆周运动:
当 $\mathbf{v}\perp\mathbf{B}$,粒子做匀速圆周运动,洛伦兹力提供向心力:
$$
qvB=m\frac{v^{2}}{R}
$$
圆周半径:
$$
\boxed{R=\frac{mv}{qB}}
$$
周期(与速度无关):
$$
\boxed{T=\frac{2\pi R}{v}=\frac{2\pi m}{qB}}
$$
当 $\mathbf{v}$ 与 $\mathbf{B}$ 不垂直时,粒子做螺旋线运动,螺旋半径 $R=mv_{\perp}/(qB)$,螺距 $h=v_{\parallel}T=2\pi mv_{\parallel}/(qB)$。
#### 速度选择器:
正交电场 $\mathbf{E}$ 与磁场 $\mathbf{B}$,带电粒子以速度 $\mathbf{v}\perp\mathbf{E},\mathbf{B}$ 射入。电场力与洛伦兹力平衡时粒子匀速穿过:
$$
qE=qvB\quad\Rightarrow\quad\boxed{v=\frac{E}{B}}
$$
#### 霍尔效应:
电流 $I$ 沿导体流过,垂直方向外加磁场 $B$,载流子在洛伦兹力作用下偏转,在导体上下表面累积电荷形成横向霍尔电压。
$$
U_{H}=\frac{1}{nq}\cdot\frac{IB}{d}=R_{H}\frac{IB}{d}
$$
其中 $d$ 为导体厚度,**霍尔系数**
$$
\boxed{R_{H}=\frac{1}{nq}}
$$
$n$ 为载流子浓度,$q$ 为载流子电荷量(含符号)。$R_{H}>0$ 表示空穴导电,$R_{H}<0$ 表示电子导电
> **相关**[[#洛伦兹力]] | [[#速度选择器]]
### 3.6 磁场对载流导线的作用
#### 安培定律:
电流元中的载流子均受洛伦兹力,合力即为安培力。设电流元 $Id\boldsymbol{l}$ 中载流子总数 $N=nS\,dl$,每个载流子受力 $\mathbf{f}=q\mathbf{v}\times\mathbf{B}$,由 $Id\boldsymbol{l}=Nq\mathbf{v}$ 得安培定律微分形式:
$$
\boxed{d\mathbf{F}=Id\boldsymbol{l}\times\mathbf{B}}
$$
有限长导线:
$$
\mathbf{F}=\int_{L}{Id\boldsymbol{l}\times\mathbf{B}}
$$
#### 载流线圈的磁力矩:
**磁矩**:平面载流线圈的磁矩定义为电流 $I$ 与线圈面积矢量 $\mathbf{S}$ 的乘积($\mathbf{S}$ 方向按右手定则由电流方向确定):
$$
\boxed{\mathbf{m}=I\mathbf{S}}
$$
$N$ 匝线圈:$\mathbf{m}=NI\mathbf{S}$,单位 $\text{A·m}^{2}$。
**矩形线圈的磁力矩**:设矩形线圈边长 $a$、$b$,电流 $I$,均匀磁场 $\mathbf{B}$ 中,磁矩 $\mathbf{m}$ 与 $\mathbf{B}$ 夹角 $\theta$。两边 $a$ 受安培力等大反向形成力偶:
$$
F=BIa,\quad M=2\cdot F\cdot\frac{b}{2}\sin\theta=BIab\sin\theta=mB\sin\theta
$$
矢量形式:
$$
\boxed{\mathbf{M}=\mathbf{m}\times\mathbf{B}}
$$
关于力矩的部分看前面[[#力矩:]]
**平衡状态**
- $\theta=0^{\circ}$$\mathbf{m}\parallel\mathbf{B}$):磁力矩为零,**稳定平衡**(势能最低)。
- $\theta=180^{\circ}$$\mathbf{m}$ 与 $\mathbf{B}$ 反向):磁力矩为零,**不稳定平衡**(势能最高,稍有扰动即偏转)。
**磁力矩的功**:线圈在磁场中转动 $d\theta$,磁力矩做功(外力做负功):
$$
dW=-M\,d\theta=-mB\sin\theta\,d\theta=mB\,d(\cos\theta)=d(mB\cos\theta)=d(\mathbf{m}\cdot\mathbf{B})
$$
又因 $\mathbf{m}\cdot\mathbf{B}=I\mathbf{S}\cdot\mathbf{B}=I\Phi$$\Phi$ 为通过线圈的磁通量),得:
$$
\boxed{W=\int_{\Phi_{1}}^{\Phi_{2}}{I\,d\Phi}=I\Delta\Phi}
$$
磁力矩做功等于电流 $I$ 乘以磁通量的增量。
> **相关**[[#洛伦兹力]] | [[#安培定律:]] | [[#磁矩]]
### 3.7 磁介质、磁介质中的安培环路定理
#### 磁介质简介:
在磁场中能被磁化并反过来影响磁场的物质称为磁介质。分为三类:
- **顺磁质**$\mu_{r}>1$(略大于 1如铝、锰。
- **抗磁质**$\mu_{r}<1$略小于 1如铜
- **铁磁质**$\mu_{r}\gg1$,如铁、钴、镍,磁化后剩磁显著。
#### 磁化强度 $\mathbf{M}$
单位体积内分子磁矩的矢量和,描述介质的磁化程度:
$$
\mathbf{M}=\frac{\sum{\mathbf{m}_{i}}}{\Delta V}
$$
对各向同性线性磁介质,磁化强度与磁场强度成正比:
$$
\mathbf{M}=\chi_{m}\mathbf{H}
$$
$\chi_{m}$ 为磁化率(无量纲),顺磁质 $\chi_{m}>0$,抗磁质 $\chi_{m}<0$。
#### 磁场强度 $\mathbf{H}$ 与磁介质中的安培环路定理:
为简化磁介质中安培环路定理,引入磁场强度:
$$
\boxed{\mathbf{H}=\frac{\mathbf{B}}{\mu_{0}}-\mathbf{M}}
$$
单位 $\text{A/m}$。对各向同性线性磁介质:
$$
\mathbf{B}=\mu_{0}(\mathbf{H}+\mathbf{M})=\mu_{0}(1+\chi_{m})\mathbf{H}=\mu_{0}\mu_{r}\mathbf{H}\equiv\mu\mathbf{H}
$$
其中 $\mu_{r}=1+\chi_{m}$ 为相对磁导率,$\mu=\mu_{0}\mu_{r}$ 为磁导率。
磁介质中的安培环路定理($\mathbf{H}$ 的环路定理):
$$
\boxed{\oint_{L}{\mathbf{H}\cdot d\mathbf{l}}=\sum_{L_{内}}{I_{\text{传导}}}}
$$
$\mathbf{H}$ 的环路积分只与传导电流有关,与磁化电流无关。
注:磁介质中的高斯定理仍为 $\displaystyle\oint_{S}{\mathbf{B}\cdot d\mathbf{S}}=0$,磁场仍是无源场——因自然界不存在磁单极子,磁化电流产生的磁场同样闭合。
> **相关**[[#安培环路定理]] | [[#磁场中的高斯定理]] | [[#磁矩]]
# 4. 变化的电磁场
## 4.1 法拉第电磁感应定律
#### 全磁通(磁链):
通过回路所围面积的总磁通量。对于 $N$ 匝线圈(每匝磁通量相同):
$$
\Phi=\int_{S}{\mathbf{B}\cdot d\mathbf{S}},\qquad
\Psi=N\Phi
$$
$\Psi$ 称为全磁通,又称磁链。
#### 法拉第电磁感应定律(微分形式):
当穿过回路的全磁通发生变化时,回路中产生感应电动势,大小等于全磁通对时间的变化率的负值:
$$
\boxed{\varepsilon=-\frac{d\Psi}{dt}}
$$
若回路闭合,则有感应电流 $I=\varepsilon/R$;若回路不闭合,电动势仍存在,只是无电流。
定律的积分形式(联系感应电动势与磁通量变化):
$$
\varepsilon=\oint_{L}{\mathbf{E}_{k}\cdot d\mathbf{l}}=-\frac{d}{dt}\int_{S}{\mathbf{B}\cdot d\mathbf{S}}
$$
其中 $\mathbf{E}_{k}$ 为非静电场强(感应电场)。
#### 楞次定律:
闭合回路中感应电流的方向,总是使它所激发的磁场去**阻碍**引起感应电流的磁通量变化。即「增反减同」——磁通量增大时,感应电流的磁场方向与原磁场方向相反;减小时方向相同。楞次定律本质是能量守恒定律在电磁感应中的体现。
> **相关**[[#4.2 动生电动势与感生电动势]] | [[#磁通量]]
## 4.2 动生电动势与感生电动势
### 4.2.1 动生电动势
导体在恒定磁场中运动而产生的感应电动势称为**动生电动势**。非静电力为洛伦兹力 $q\mathbf{v}\times\mathbf{B}$,等效非静电场强 $\mathbf{E}_{k}=\mathbf{v}\times\mathbf{B}$
$$
\boxed{\varepsilon=\int_{L}{(\mathbf{v}\times\mathbf{B})\cdot d\mathbf{l}}}
$$
**例:直导体棒在均匀磁场中滑动。**
长为 $l$ 的导体棒以速度 $v$ 在垂直于均匀磁场 $B$ 的方向上平动,三者两两垂直:
$$
\varepsilon=\int_{0}^{l}{vB\,dl}=Blv
$$
方向由 $\mathbf{v}\times\mathbf{B}$ 判定。
**例:导体棒绕一端在匀强磁场中旋转。**
棒长 $l$,角速度 $\omega$,磁场 $B$ 垂直于旋转平面。取距转轴 $r$ 处线元 $dr$,速度 $v=\omega r$
$$
d\varepsilon=Bv\,dr=B\omega r\,dr
$$
$$
\varepsilon=\int_{0}^{l}{B\omega r\,dr}=\frac{1}{2}B\omega l^{2}
$$
> 注意区分「动生」与「感生」:动生电动势的非静电力是洛伦兹力,磁场本身不随时间变化,只是导体运动导致回路面积或取向变化;感生电动势则是磁场本身随时间变化产生涡旋电场。
### 4.2.2 感生电动势与涡旋电场
磁场随时间变化时,在空间激发**涡旋电场** $\mathbf{E}_{\text{涡}}$(又称感生电场),其电场线为闭合曲线。回路不动而磁场变化时:
$$
\boxed{\varepsilon=\oint_{L}{\mathbf{E}_{\text{涡}}\cdot d\mathbf{l}}=-\int_{S}{\frac{\partial\mathbf{B}}{\partial t}\cdot d\mathbf{S}}}
$$
该式是法拉第定律的积分形式,揭示了「变化的磁场激发涡旋电场」。
涡旋电场 $\mathbf{E}_{\text{涡}}$ 与静电场 $\mathbf{E}_{\text{静}}$ 的区别:
| | 静电场 | 涡旋电场 |
|---|---|---|
| 场源 | 静止电荷 | 变化的磁场 |
| 电场线 | 起于正电荷,止于负电荷 | 闭合曲线(无头无尾) |
| 环路积分 | $\oint\mathbf{E}_{\text{静}}\cdot d\mathbf{l}=0$(保守场) | $\oint\mathbf{E}_{\text{涡}}\cdot d\mathbf{l}\neq0$(非保守场) |
| 高斯定理 | $\oint\mathbf{E}_{\text{静}}\cdot d\mathbf{S}=q/\varepsilon_{0}$(有源场) | $\oint\mathbf{E}_{\text{涡}}\cdot d\mathbf{S}=0$(无源场) |
#### 涡旋电场的高斯定理:
涡旋电场线为闭合曲线,通过任意闭合曲面的 $\mathbf{E}_{\text{涡}}$ 通量为零:
$$
\boxed{\oint_{S}{\mathbf{E}_{\text{涡}}\cdot d\mathbf{S}}=0}
$$
即涡旋电场是无源场,不存在与之对应的「涡旋电荷」。空间中总电场 $\mathbf{E}=\mathbf{E}_{\text{静}}+\mathbf{E}_{\text{涡}}$,其高斯定理为 $\displaystyle\oint_{S}{\mathbf{E}\cdot d\mathbf{S}}=\frac{1}{\varepsilon_{0}}\sum q_{i}$(因 $\mathbf{E}_{\text{涡}}$ 的通量为零)。
> **相关**[[#法拉第电磁感应定律(微分形式):]] | [[#静电场的环路定理:]] | [[#高斯定理:]]
## 4.3 自感和互感
### 4.3.1 自感
#### 定义:
回路中电流变化时,穿过回路自身的全磁通随之变化,在回路中产生感应电动势,此现象称**自感**。
全磁通与电流成正比(无铁磁质时):
$$
\boxed{\Psi=LI}
$$
比例系数 $L$ 称为**自感系数**,简称自感,单位亨利($\text{H}$$1\,\text{H}=1\,\text{Wb/A}$。
自感电动势(由法拉第定律):
$$
\boxed{\varepsilon_{L}=-\frac{d\Psi}{dt}=-L\frac{dI}{dt}}
$$
负号表明自感电动势总是阻碍电流的变化。
#### 例1长直螺线管的自感
设螺线管长 $l$,截面积 $S$,单位长度匝数 $n$,总匝数 $N=nl$,管内充满磁导率为 $\mu=\mu_{0}\mu_{r}$ 的磁介质。管内磁场 $B=\mu nI$,每匝磁通量 $\Phi=BS=\mu nIS$,全磁通:
$$
\Psi=N\Phi=nl\cdot\mu nIS=\mu n^{2}lSI
$$
$$
\boxed{L=\frac{\Psi}{I}=\mu n^{2}lS=\mu n^{2}V}
$$
其中 $V=lS$ 为螺线管体积。自感与匝数密度的平方和体积成正比。
#### 例2同轴电缆的自感单位长度
设内导体半径 $a$,外导体薄壳内半径 $b$,其间充满 $\mu$ 的磁介质。取半径为 $r$ 处的安培回路($a<r<b$由安培环路定理
$$
B=\frac{\mu I}{2\pi r}
$$
通过长度为 $l$ 的径向截面($a$ 到 $b$)的磁通量:
$$
\Phi=\int_{a}^{b}{B\cdot l\,dr}=\frac{\mu Il}{2\pi}\int_{a}^{b}{\frac{dr}{r}}=\frac{\mu Il}{2\pi}\ln\frac{b}{a}
$$
单位长度自感:
$$
\boxed{L_{0}=\frac{\Phi}{Il}=\frac{\mu}{2\pi}\ln\frac{b}{a}}
$$
> **相关**[[#载流密绕直螺线管内部轴线上的磁场]] | [[#互感]]
### 4.3.2 互感
#### 定义:
两个邻近回路,其中一个回路电流变化时,在另一个回路中产生感应电动势的现象称**互感**。
设回路1电流 $I_{1}$穿过回路2的全磁通 $\Psi_{21}$回路2电流 $I_{2}$穿过回路1的全磁通 $\Psi_{12}$。实验表明(无铁磁质时):
$$
\boxed{\Psi_{21}=M_{21}I_{1},\qquad \Psi_{12}=M_{12}I_{2}}
$$
且可证明 $M_{21}=M_{12}=M$,称为**互感系数**,简称互感,单位同自感($\text{H}$)。
互感电动势:
$$
\boxed{\varepsilon_{21}=-\frac{d\Psi_{21}}{dt}=-M\frac{dI_{1}}{dt},\qquad
\varepsilon_{12}=-M\frac{dI_{2}}{dt}}
$$
#### 例:两个同轴长直螺线管的互感
设原线圈1长 $l$,匝数 $N_{1}$,截面积 $S$副线圈2匝数 $N_{2}$,紧绕在原线圈上(两者长度、截面积近似相同)。原线圈通电流 $I_{1}$,管内磁场:
$$
B_{1}=\mu\frac{N_{1}}{l}I_{1}
$$
穿过副线圈每匝的磁通量 $\Phi=B_{1}S$,副线圈全磁通:
$$
\Psi_{21}=N_{2}B_{1}S=\mu\frac{N_{1}N_{2}}{l}SI_{1}
$$
$$
\boxed{M=\frac{\Psi_{21}}{I_{1}}=\mu\frac{N_{1}N_{2}}{l}S}
$$
若两线圈各自的自感分别为 $L_{1}=\mu N_{1}^{2}S/l$、$L_{2}=\mu N_{2}^{2}S/l$,则有 $M=\sqrt{L_{1}L_{2}}$(无漏磁的理想耦合情况,对应耦合系数 $k=1$)。
> **相关**[[#例1长直螺线管的自感]] | [[#载流密绕直螺线管内部轴线上的磁场]]
## 4.4 自感磁能与磁场能量密度
#### 自感磁能:
自感为 $L$ 的线圈中通有电流 $I$,回路储存的磁能。考虑电流从 0 增长到 $I$ 的过程,某时刻电流为 $i$,自感电动势 $\varepsilon_{L}=-L\,di/dt$,外电源克服自感电动势做功功率 $P=-\varepsilon_{L}i=Li\,di/dt$
$$
dW=P\,dt=Li\,di
$$
积分得:
$$
\boxed{W_{m}=\frac{1}{2}LI^{2}}
$$
此即自感线圈储存的磁能。形式上可与电容器电能 $W_{e}=\frac{1}{2}CU^{2}$ 对照——电能储于电场,磁能储于磁场。
#### 磁场能量密度:
磁能定域在磁场中。以长直螺线管为例验证:自感 $L=\mu n^{2}V$,电流 $I$ 产生管内磁场 $B=\mu nI$,磁能:
$$
W_{m}=\frac{1}{2}LI^{2}=\frac{1}{2}\cdot\mu n^{2}V\cdot\left(\frac{B}{\mu n}\right)^{2}=\frac{B^{2}}{2\mu}V
$$
因此磁场的**能量密度**(单位体积磁场能量)为:
$$
\boxed{w_{m}=\frac{W_{m}}{V}=\frac{B^{2}}{2\mu}=\frac{1}{2}\mathbf{B}\cdot\mathbf{H}=\frac{1}{2}\mu H^{2}}
$$
该式适用于任意磁场。对空间积分即得总磁能:
$$
W_{m}=\int_{V}{w_{m}\,dV}=\frac{1}{2}\int_{V}{\mathbf{B}\cdot\mathbf{H}\,dV}
$$
> **相关**[[#电场能量与能量密度]] | [[#例1长直螺线管的自感]] | [[#电容器的能量]]
## 4.5 位移电流与全电流安培环路定理
#### 问题的提出——安培环路定理在非稳恒电流中的矛盾:
对稳恒电流,安培环路定理 $\displaystyle\oint_{L}{\mathbf{H}\cdot d\mathbf{l}}=\sum I_{\text{传导}}$ 成立。但在含有电容器的交变电路中,取回路 $L$ 包围电容器一极板导线,以 $L$ 为边界取两个不同的曲面 $S_{1}$(穿过导线)和 $S_{2}$(穿过电容极板间):
- $S_{1}$ 有传导电流 $I$ 穿过;
- $S_{2}$ 无传导电流穿过(极板间为绝缘介质或真空)。
同一回路 $\oint_{L}{\mathbf{H}\cdot d\mathbf{l}}$ 必须唯一,但按原定理结果不同——这表明稳恒条件下的安培环路定理对非稳恒情况不适用,需要修正。
#### 位移电流:
麦克斯韦提出,变化的电场等效于一种电流——**位移电流**。设电容极板面积 $S$,电荷 $Q$,传导电流 $I=dQ/dt$ 流入极板。极板间电位移 $D=\sigma=Q/S$,通过整个极板间的电位移通量:
$$
\Phi_{D}=\int_{S}{\mathbf{D}\cdot d\mathbf{S}}=DS=Q
$$
$$
\frac{d\Phi_{D}}{dt}=\frac{dQ}{dt}=I
$$
即穿过 $S_{2}$ 的位移电流恰好等于穿过 $S_{1}$ 的传导电流!定义:
**位移电流密度**(空间各点的变化电场):
$$
\boxed{\mathbf{j}_{d}=\frac{\partial\mathbf{D}}{\partial t}}
$$
**位移电流**(通过某曲面的位移电流):
$$
\boxed{I_{d}=\int_{S}{\mathbf{j}_{d}\cdot d\mathbf{S}}=\int_{S}{\frac{\partial\mathbf{D}}{\partial t}\cdot d\mathbf{S}}=\frac{d\Phi_{D}}{dt}}
$$
> 注意:位移电流并非电荷定向运动产生的真实电流,仅因它按同样规律激发磁场而得名。「全电流 $=$ 传导电流 $+$ 位移电流」在任意情况下闭合回路都是连续的。
#### 全电流安培环路定理:
将位移电流纳入安培环路定理右侧,得到适用于一般情况的**全电流安培环路定理**(又称安培-麦克斯韦定律):
$$
\boxed{\oint_{L}{\mathbf{H}\cdot d\mathbf{l}}=\sum I_{\text{传导}}+I_{d}=\sum I_{\text{传导}}+\int_{S}{\frac{\partial\mathbf{D}}{\partial t}\cdot d\mathbf{S}}}
$$
其核心思想:传导电流激发磁场,**变化的电场也激发磁场**(涡旋磁场)。两者按右手螺旋关系分别由传导电流方向和 $\partial\mathbf{D}/\partial t$ 方向确定。这是麦克斯韦电磁理论的关键创新。
> **相关**[[#安培环路定理]] | [[#D通量与有介质时的高斯定理]] | [[#4.1 法拉第电磁感应定律]]
## 4.6 麦克斯韦方程组(积分形式)
电磁学的基本规律可归结为四个方程——麦克斯韦方程组:
### 电场的高斯定理:
$$
\boxed{\oint_{S}{\mathbf{D}\cdot d\mathbf{S}}=\sum Q_{\text{自由}}}
$$
反映电场是有源场,电荷是电场的源。
### 磁场的高斯定理:
$$
\boxed{\oint_{S}{\mathbf{B}\cdot d\mathbf{S}}=0}
$$
反映磁场是无源场,不存在磁单极子。
### 法拉第电磁感应定律:
$$
\boxed{\oint_{L}{\mathbf{E}\cdot d\mathbf{l}}=-\int_{S}{\frac{\partial\mathbf{B}}{\partial t}\cdot d\mathbf{S}}}
$$
反映变化的磁场激发涡旋电场。
### 全电流安培环路定理(安培-麦克斯韦定律):
$$
\boxed{\oint_{L}{\mathbf{H}\cdot d\mathbf{l}}=\sum I_{\text{传导}}+\int_{S}{\frac{\partial\mathbf{D}}{\partial t}\cdot d\mathbf{S}}}
$$
反映传导电流和变化的电场共同激发磁场。
四个方程加上电荷守恒定律 $\displaystyle\oint_{S}{\mathbf{j}\cdot d\mathbf{S}}=-\frac{dQ}{dt}$ 以及介质的本构关系 $\mathbf{D}=\varepsilon\mathbf{E}$、$\mathbf{B}=\mu\mathbf{H}$、$\mathbf{j}=\sigma\mathbf{E}$,完整描述了宏观电磁现象。
> 麦克斯韦方程组预言了电磁波的存在——变化的电场和变化的磁场相互激发,以波的形式在空间传播,光速 $c=1/\sqrt{\mu_{0}\varepsilon_{0}}$。这是 19 世纪物理学最伟大的成就之一。
> **相关**[[#高斯定理:]] | [[#磁场中的高斯定理]] | [[#法拉第电磁感应定律(微分形式):]] | [[#全电流安培环路定理:]] | [[#有介质时的高斯定理]]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,129 @@
---
import '../styles/global.css';
import Background from '../components/Background.astro';
import Nav from '../components/Nav.astro';
import Footer from '../components/Footer.astro';
import SearchModal from '../components/SearchModal.astro';
// KaTeX 数学公式样式Vite 会自动打包字体)
import 'katex/dist/katex.min.css';
interface Props {
title?: string;
description?: string;
/** 文章页传 true激活阅读进度条 */
showProgress?: boolean;
}
const {
title = 'Yukun\u2019s Blog',
description = '记录代码与生活,淡蓝色液态玻璃风的个人博客。',
showProgress = false,
} = Astro.props;
const siteName = 'Yukun\u2019s Blog';
const fullTitle = title === siteName ? title : `${title} · ${siteName}`;
const canonical = new URL(Astro.url.pathname, Astro.site).href;
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="generator" content={Astro.generator} />
<link rel="canonical" href={canonical} />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:site_name" content={siteName} />
<!-- favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- 字体Sora英文展示+ Noto Sans SC中文正文 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap"
/>
</head>
<body>
<Background />
<Nav />
{showProgress && <div id="reading-progress" class="reading-progress" aria-hidden="true"></div>}
<main>
<slot />
</main>
<Footer />
<SearchModal />
<!-- 全局交互脚本:滚动渐入 + 导航玻璃化 -->
<script>
// 滚动渐入threshold=0任意 1px 可见即触发),避免超大元素永不可见
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('in');
io.unobserve(e.target);
}
}
},
{ threshold: 0, rootMargin: '0px 0px -40px 0px' }
);
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
// 导航栏:滚动后加玻璃
const nav = document.getElementById('nav');
const onScroll = () => {
if (!nav) return;
nav.dataset.scrolled = window.scrollY > 12 ? '1' : '0';
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
// 阅读进度条
const bar = document.getElementById('reading-progress');
if (bar) {
const onProgress = () => {
const h = document.documentElement;
const max = h.scrollHeight - h.clientHeight;
const p = max > 0 ? (h.scrollTop / max) * 100 : 0;
bar.style.width = p + '%';
};
window.addEventListener('scroll', onProgress, { passive: true });
onProgress();
}
// 锚点跳转后浏览器聚焦目标,辅助可访问性
window.addEventListener('hashchange', () => {
const id = location.hash.slice(1);
const el = id && document.getElementById(id);
if (el instanceof HTMLElement) {
// 让 scroll-margin-top 生效即可,无需额外操作
}
});
</script>
<style>
.reading-progress {
position: fixed;
top: 0;
left: 0;
height: 3px;
width: 0;
z-index: 100;
background: linear-gradient(90deg, var(--blue-500), var(--cyan-400));
box-shadow: 0 0 12px rgba(47, 127, 224, 0.6);
transition: width 0.1s linear;
}
</style>
</body>
</html>

70
src/lib/utils.ts Normal file
View file

@ -0,0 +1,70 @@
import { getCollection, type CollectionEntry } from 'astro:content';
export type Post = CollectionEntry<'posts'>;
/** 获取已发布的文章(排除 draft按日期降序 */
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('posts', ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** 格式化日期2026年7月23日 */
export function formatDate(date: Date): string {
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
}
/** 紧凑日期2026-07-23 */
export function formatDateShort(date: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`;
}
/** 年份分组:[{ year, posts }] 降序 */
export function groupByYear(posts: Post[]) {
const map = new Map<number, Post[]>();
for (const p of posts) {
const y = p.data.date.getFullYear();
if (!map.has(y)) map.set(y, []);
map.get(y)!.push(p);
}
return [...map.entries()]
.sort((a, b) => b[0] - a[0])
.map(([year, items]) => ({ year, posts: items }));
}
/** 统计标签:[{ tag, count }] 按数量降序 */
export function getAllTags(posts: Post[]) {
const map = new Map<string, number>();
for (const p of posts) {
for (const t of p.data.tags) map.set(t, (map.get(t) ?? 0) + 1);
}
return [...map.entries()]
.sort((a, b) => b[1] - a[1])
.map(([tag, count]) => ({ tag, count }));
}
/** 阅读时间估算(中文按字数、英文按词数,约 400 字/分钟) */
export function readingTime(body: string): string {
const cn = (body.match(/[\u4e00-\u9fa5]/g) || []).length;
const en = (body.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[A-Za-z]+/g) || []).length;
const mins = Math.max(1, Math.round((cn / 400) + (en / 200)));
return `${mins} 分钟`;
}
/** 给封面渐变兜底 */
export function heroCss(p: Post): string {
const [a, b] = p.data.heroGradient ?? ['#7fb8ff', '#2f8df0'];
return `linear-gradient(135deg, ${a}, ${b})`;
}
/** 标题转锚点 id与 rehype-slug 规则保持一致:小写、连字符) */
export function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}

28
src/pages/404.astro Normal file
View file

@ -0,0 +1,28 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="页面走丢了" description="404 页面不存在">
<section class="container wrap">
<div class="card glass reveal">
<div class="big">4<span class="o">0</span>4</div>
<h1 class="title">页面走丢了喵</h1>
<p class="sub">你访问的页面不存在,或者已被移走。<br />不如回首页看看吧~</p>
<div class="cta">
<a href="/" class="btn btn-primary">回到首页</a>
<a href="/posts" class="btn">浏览文章</a>
</div>
</div>
</section>
</BaseLayout>
<style>
.wrap { display: grid; place-items: center; min-height: 70vh; padding-top: calc(var(--nav-h) + 20px); }
.card { border-radius: var(--r-xl); padding: clamp(32px, 6vw, 60px); text-align: center; display: flex; flex-direction: column; align-items: center; gap: 14px; }
.big { font-family: var(--font-display); font-weight: 800; font-size: clamp(4rem, 16vw, 8rem); line-height: 1; letter-spacing: -0.05em; color: var(--ink); }
.big .o { display: inline-block; color: var(--blue-500); animation: spin 4s ease-in-out infinite; transform-origin: center; }
@keyframes spin { 0%, 100% { transform: rotate(-8deg); } 50% { transform: rotate(8deg); } }
.title { font-family: var(--font-display); font-weight: 700; font-size: 1.5rem; color: var(--ink); }
.sub { color: var(--ink-soft); line-height: 1.8; }
.cta { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 10px; }
</style>

76
src/pages/about.astro Normal file
View file

@ -0,0 +1,76 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="关于" description="关于 Yukun">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px); padding-bottom: 60px;">
<div class="about glass reveal">
<div class="avatar">Y</div>
<h1 class="name">Yukun</h1>
<p class="sig">代码 · 生活 · 淡蓝色的液态玻璃</p>
<p class="bio">
你好喵,我是 Yukun。目前在同济大学就读
本人完全不懂前端博客完全由GLM-5.2构建
这里主要记录做机器学习和深度学习踩过的坑、读过的书,
以及一些关于世界的碎碎念。
如果哪篇文章对你有一点帮助,那就万分荣幸。
</p>
<div class="links">
<a href="https://sausagetoast.cloud" class="link-item glass">
<span class="link-ico">⌂</span>
<span>sausagetoast.cloud</span>
</a>
<a href="mailto:zhangyukunhh@gmail.com" class="link-item glass">
<span class="link-ico">✉</span>
<span>给我写信(Google)</span>
</a>
<a href="mailto:3385587900@qq.com" class="link-item glass">
<span class="link-ico">✉</span>
<span>给我写信(QQ)</span>
</a>
</div>
</div>
<div class="card glass reveal" style="margin-top: 20px;">
<h2 class="card-title">这个博客</h2>
<ul class="feat-list">
<li><span class="feat-ico">◆</span> 用 Astro 静态生成,零运行时 JS</li>
<li><span class="feat-ico">◆</span> Markdown 写作frontmatter 校验防手滑</li>
<li><span class="feat-ico">◆</span> 液态玻璃质感,淡蓝配色,响应式适配</li>
<li><span class="feat-ico">◆</span> 本地搜索、文章目录、归档时间轴</li>
</ul>
</div>
</section>
</BaseLayout>
<style>
.about { border-radius: var(--r-xl); padding: clamp(28px, 5vw, 52px); text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.avatar {
display: grid;
place-items: center;
width: clamp(80px, 18vw, 120px);
aspect-ratio: 1;
border-radius: 50%;
background: linear-gradient(135deg, var(--blue-400), var(--blue-600));
color: #fff;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(2.4rem, 6vw, 3.4rem);
box-shadow: 0 12px 30px rgba(47, 127, 224, 0.35), inset 0 4px 16px rgba(255, 255, 255, 0.3);
margin-bottom: 8px;
}
.name { font-family: var(--font-display); font-weight: 800; font-size: clamp(1.8rem, 1rem + 3vw, 2.6rem); letter-spacing: -0.03em; color: var(--ink); }
.sig { color: var(--blue-500); font-weight: 600; }
.bio { color: var(--ink-soft); max-width: 52ch; line-height: 1.85; }
.links { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 12px; }
.link-item { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 999px; color: var(--ink-soft); font-weight: 600; font-size: 0.9rem; transition: transform 0.25s, color 0.25s; }
.link-item:hover { transform: translateY(-2px); color: var(--blue-600); }
.link-ico { color: var(--blue-400); }
.card { border-radius: var(--r-lg); padding: clamp(22px, 4vw, 36px); }
.card-title { font-family: var(--font-display); font-weight: 700; font-size: 1.3rem; margin-bottom: 18px; color: var(--ink); display: flex; align-items: center; gap: 12px; }
.card-title::before { content: ''; width: 5px; height: 1em; border-radius: 6px; background: linear-gradient(180deg, var(--blue-500), var(--cyan-400)); }
.feat-list { display: flex; flex-direction: column; gap: 12px; }
.feat-list li { display: flex; align-items: center; gap: 12px; color: var(--ink-soft); }
.feat-ico { color: var(--blue-400); font-size: 0.7rem; }
</style>

123
src/pages/archives.astro Normal file
View file

@ -0,0 +1,123 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getPublishedPosts, groupByYear, formatDateShort } from '../lib/utils';
const all = await getPublishedPosts();
const years = groupByYear(all);
const total = all.length;
---
<BaseLayout title="归档" description="按时间线浏览所有文章">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<header class="page-head reveal">
<h1 class="page-title">归档</h1>
<p class="page-sub">共 {total} 篇 · 时光轴回顾</p>
</header>
<div class="timeline">
{years.map(({ year, posts }) => (
<section class="year-group reveal">
<div class="year-node glass">
<span class="year-num">{year}</span>
<span class="year-count">{posts.length} 篇</span>
</div>
<div class="year-items">
{posts.map((p) => (
<a href={`/posts/${p.id}`} class="tl-item">
<span class="tl-date">{formatDateShort(p.data.date)}</span>
<span class="tl-dot"></span>
<span class="tl-title">{p.data.title}</span>
{p.data.tags[0] && <span class="chip">{p.data.tags[0]}</span>}
</a>
))}
</div>
</section>
))}
</div>
</section>
</BaseLayout>
<style>
.page-head { margin-bottom: 32px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; color: var(--ink); }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.timeline { position: relative; padding-left: 8px; }
.timeline::before {
content: '';
position: absolute;
left: 88px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, var(--blue-300), var(--cyan-300), transparent);
}
.year-group { margin-bottom: clamp(28px, 4vw, 44px); }
.year-node {
display: inline-flex;
align-items: center;
gap: 10px;
border-radius: 999px;
padding: 6px 18px;
margin-bottom: 18px;
margin-left: 56px;
position: relative;
z-index: 1;
}
.year-node::before {
content: '';
position: absolute;
left: -28px;
top: 50%;
transform: translateY(-50%);
width: 14px; height: 14px;
border-radius: 50%;
background: var(--blue-500);
box-shadow: 0 0 0 4px rgba(132, 194, 255, 0.4), 0 0 14px var(--blue-400);
}
.year-num { font-family: var(--font-display); font-weight: 800; font-size: 1.2rem; color: var(--blue-600); }
.year-count { color: var(--ink-faint); font-size: 0.82rem; }
.year-items { display: flex; flex-direction: column; gap: 4px; padding-left: 56px; }
.tl-item {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
border-radius: 12px;
transition: background 0.2s, transform 0.2s;
position: relative;
}
.tl-item::before {
content: '';
position: absolute;
left: -24px;
top: 50%;
transform: translate(-50%, -50%);
width: 8px; height: 8px;
border-radius: 50%;
background: var(--blue-300);
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(79, 163, 255, 0.3);
transition: all 0.2s;
}
.tl-item:hover { background: rgba(132, 194, 255, 0.14); transform: translateX(4px); }
.tl-item:hover::before { background: var(--blue-500); box-shadow: 0 0 0 1px var(--blue-400), 0 0 10px var(--blue-400); }
.tl-date { font-family: var(--font-mono); font-size: 0.78rem; color: var(--ink-faint); width: 84px; flex-shrink: 0; }
.tl-dot { display: none; }
.tl-title { color: var(--ink); font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-item:hover .tl-title { color: var(--blue-600); }
.tl-item .chip { margin-left: auto; }
@media (max-width: 640px) {
.timeline::before { left: 20px; }
.year-node { margin-left: 0; }
.year-node::before { left: -16px; }
.year-items { padding-left: 0; }
.tl-item::before { left: -16px; }
.tl-item { flex-wrap: wrap; gap: 8px; }
.tl-date { width: auto; }
.tl-item .chip { margin-left: 0; }
.tl-title { white-space: normal; }
}
</style>

186
src/pages/index.astro Normal file
View file

@ -0,0 +1,186 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import PostCard from '../components/PostCard.astro';
import TagChip from '../components/TagChip.astro';
import { getPublishedPosts, getAllTags, formatDate } from '../lib/utils';
const all = await getPublishedPosts();
const tags = getAllTags(all).slice(0, 16);
const featured = all.find((p) => p.data.pinned) ?? all[0];
const latest = all.filter((p) => p.id !== featured?.id).slice(0, 6);
const total = all.length;
---
<BaseLayout
title="Yukun's Blog"
description="记录代码与生活 · 淡蓝色液态玻璃风的个人博客"
>
<!-- 英雄区:左对齐报头 + 右侧浮动玻璃卡片(非居中三件套) -->
<section class="hero container">
<div class="hero-left reveal">
<span class="eyebrow glass">
<span class="pulse"></span>个人博客 · 已发布 {total} 篇
</span>
<h1 class="hero-title">
<span class="gradient-text">Yukun&rsquo;s</span><br />Blog
</h1>
<p class="hero-sig">
记录代码与生活,淡蓝色的液态玻璃。<br />
在这里写下技术笔记、随手记的随笔,和一些关于世界的碎碎念。
</p>
<div class="hero-cta">
<a href="/posts" class="btn btn-primary">
浏览全部文章
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
<a href="/about" class="btn">关于我</a>
</div>
<div class="hero-tags">
{tags.slice(0, 6).map((t) => <TagChip tag={t.tag} count={t.count} />)}
</div>
</div>
<aside class="hero-right reveal">
<div class="float-card glass">
<div class="fc-head">
<span class="fc-dot"></span>
<span class="fc-label">最新发布</span>
</div>
{featured && (
<a href={`/posts/${featured.id}`} class="fc-post">
<div class="fc-emoji">✦</div>
<div class="fc-body">
<div class="fc-date">{formatDate(featured.data.date)}</div>
<div class="fc-title">{featured.data.title}</div>
{featured.data.tags[0] && <span class="chip">{featured.data.tags[0]}</span>}
</div>
</a>
)}
<div class="fc-stat">
<div><strong>{total}</strong><span>篇文章</span></div>
<div><strong>{tags.length}</strong><span>个标签</span></div>
</div>
</div>
</aside>
</section>
<!-- 置顶 / 推荐文章 -->
{featured && (
<section class="container section">
<h2 class="section-title reveal">置顶推荐</h2>
<PostCard post={featured} variant="featured" class="reveal" />
</section>
)}
<!-- 最新文章 -->
<section class="container section">
<div class="sec-head reveal">
<h2 class="section-title">最新文章</h2>
<a href="/posts" class="more-link">查看全部 →</a>
</div>
<div class="post-grid">
{latest.map((post) => <PostCard post={post} class="reveal" />)}
</div>
</section>
<!-- 标签云 -->
<section class="container section">
<h2 class="section-title reveal">探索标签</h2>
<div class="tag-cloud glass reveal">
{tags.map((t) => <TagChip tag={t.tag} count={t.count} />)}
</div>
</section>
</BaseLayout>
<style>
.hero {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: clamp(24px, 4vw, 56px);
align-items: center;
padding-top: calc(var(--nav-h) + clamp(40px, 8vw, 90px));
padding-bottom: clamp(30px, 5vw, 50px);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 600;
color: var(--blue-700);
}
.pulse {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--blue-500);
box-shadow: 0 0 0 0 rgba(47, 127, 224, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(47, 127, 224, 0.5); }
70% { box-shadow: 0 0 0 8px rgba(47, 127, 224, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 127, 224, 0); }
}
.hero-title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(2.8rem, 6vw, 5rem);
line-height: 0.98;
letter-spacing: -0.04em;
margin: 18px 0 20px;
color: var(--ink);
}
.hero-sig {
font-size: clamp(1rem, 0.9rem + 0.4vw, 1.15rem);
line-height: 1.75;
color: var(--ink-soft);
max-width: 38ch;
}
.hero-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 26px; }
.hero-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 28px; }
/* 右侧浮动玻璃卡片 */
.hero-right { position: relative; }
.float-card {
border-radius: var(--r-xl);
padding: 22px;
animation: floaty 6s ease-in-out infinite;
transform-style: preserve-3d;
}
@keyframes floaty {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.fc-head { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; color: var(--ink-faint); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 700; }
.fc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan-400); box-shadow: 0 0 10px var(--cyan-400); }
.fc-post { display: flex; gap: 14px; padding: 14px; border-radius: var(--r-md); background: rgba(132, 194, 255, 0.12); border: 1px solid rgba(132, 194, 255, 0.2); transition: transform 0.3s, background 0.3s; }
.fc-post:hover { transform: translateX(4px); background: rgba(132, 194, 255, 0.2); }
.fc-emoji { font-size: 1.4rem; color: var(--blue-500); }
.fc-date { color: var(--ink-faint); font-size: 0.76rem; }
.fc-title { font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; margin: 2px 0 6px; color: var(--ink); }
.fc-body .chip { margin-top: 4px; }
.fc-stat { display: flex; gap: 24px; margin-top: 18px; padding-top: 16px; border-top: 1px solid rgba(79, 163, 255, 0.18); }
.fc-stat div { display: flex; flex-direction: column; }
.fc-stat strong { font-family: var(--font-display); font-size: 1.5rem; color: var(--blue-600); font-weight: 800; }
.fc-stat span { font-size: 0.78rem; color: var(--ink-faint); }
.sec-head { display: flex; align-items: end; justify-content: space-between; margin-bottom: 1.5rem; }
.sec-head .section-title { margin-bottom: 0; }
.more-link { color: var(--blue-600); font-weight: 600; font-size: 0.92rem; }
.more-link:hover { color: var(--blue-500); }
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: clamp(16px, 2.5vw, 24px);
}
.tag-cloud { border-radius: var(--r-lg); padding: clamp(20px, 3vw, 32px); display: flex; flex-wrap: wrap; gap: 12px; }
/* 移动端:英雄区单列 */
@media (max-width: 860px) {
.hero { grid-template-columns: 1fr; gap: 32px; }
.hero-right { order: -1; }
}
</style>

View file

@ -0,0 +1,189 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getPublishedPosts, formatDate, readingTime, heroCss } from '../../lib/utils';
import Toc from '../../components/Toc.astro';
import '../../styles/prose.css';
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { slug: post.id.replace(/\/index$/, '') },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const all = await getPublishedPosts();
const idx = all.findIndex((p) => p.id === post.id);
const prev = idx > 0 ? all[idx - 1] : null;
const next = idx < all.length - 1 ? all[idx + 1] : null;
const rt = readingTime(post.body ?? '');
const date = formatDate(post.data.date);
const updated = post.data.updatedDate ? formatDate(post.data.updatedDate) : null;
---
<BaseLayout title={post.data.title} description={post.data.description} showProgress={true}>
<article class="container post" style="padding-top: calc(var(--nav-h) + clamp(32px, 6vw, 60px));">
<!-- 封面 -->
<div class="hero-banner reveal" style={`background:${heroCss(post)}`}>
<div class="hero-inner">
<div class="hero-tags">
{post.data.tags.map((t) => <span class="hero-chip">{t}</span>)}
</div>
<h1 class="post-title">{post.data.title}</h1>
<div class="post-meta">
<time datetime={post.data.date.toISOString()}>{date}</time>
<span class="dot"></span>
<span>{rt}阅读</span>
{updated && <><span class="dot"></span><span>更新于 {updated}</span></>}
</div>
</div>
</div>
<!-- 正文 + 侧栏目录 -->
<div class="post-layout">
<div class="post-content glass">
<div class="prose">
<Content />
</div>
<hr class="hr-soft" />
<div class="post-footer">
<div class="post-tags">
{post.data.tags.map((t) => (
<a href={`/tags/${t}`} class="chip chip-lg">#{t}</a>
))}
</div>
<a href="/" class="back-link">← 返回首页</a>
</div>
</div>
<div class="post-aside">
<div class="aside-inner">
<Toc />
</div>
</div>
</div>
<!-- 上下篇 -->
{(prev || next) && (
<nav class="pager reveal">
{prev ? (
<a href={`/posts/${prev.id}`} class="pager-card glass">
<span class="pager-label">← 上一篇</span>
<span class="pager-title">{prev.data.title}</span>
</a>
) : <span class="pager-empty"></span>}
{next ? (
<a href={`/posts/${next.id}`} class="pager-card glass next">
<span class="pager-label">下一篇 →</span>
<span class="pager-title">{next.data.title}</span>
</a>
) : <span class="pager-empty"></span>}
</nav>
)}
</article>
</BaseLayout>
<style>
.hero-banner {
border-radius: var(--r-xl);
padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 44px);
overflow: hidden;
position: relative;
color: #fff;
}
.hero-banner::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 25% 15%, rgba(255, 255, 255, 0.35), transparent 45%),
linear-gradient(180deg, transparent 50%, rgba(0, 40, 80, 0.22));
}
.hero-inner { position: relative; z-index: 1; }
.hero-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.hero-chip {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.35);
font-size: 0.78rem;
font-weight: 600;
color: #fff;
}
.post-title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.8rem, 1rem + 3vw, 2.8rem);
line-height: 1.15;
letter-spacing: -0.03em;
margin-bottom: 14px;
text-shadow: 0 2px 16px rgba(0, 40, 80, 0.25);
}
.post-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; color: rgba(255, 255, 255, 0.92); font-size: 0.88rem; }
.post-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: 0.6; }
.post-layout {
display: grid;
grid-template-columns: 1fr 260px;
gap: clamp(20px, 3vw, 36px);
margin-top: clamp(20px, 3vw, 32px);
align-items: start;
}
.post-content {
border-radius: var(--r-lg);
padding: clamp(20px, 3.5vw, 40px);
min-width: 0;
}
.post-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.post-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.back-link { color: var(--ink-faint); font-weight: 600; font-size: 0.9rem; }
.back-link:hover { color: var(--blue-600); }
.post-aside { position: relative; }
.aside-inner {
position: sticky;
top: calc(var(--nav-h) + 24px);
max-height: calc(100vh - var(--nav-h) - 48px);
overflow: auto;
}
/* 上下篇 */
.pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: clamp(28px, 4vw, 44px);
}
.pager-card {
border-radius: var(--r-md);
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 6px;
transition: transform 0.3s;
}
.pager-card:hover { transform: translateY(-3px); }
.pager-card.next { text-align: right; align-items: flex-end; }
.pager-label { color: var(--blue-500); font-weight: 700; font-size: 0.82rem; }
.pager-title { color: var(--ink); font-weight: 600; }
.pager-empty { visibility: hidden; }
/* 移动端:目录移到正文上方,上下篇单列 */
@media (max-width: 1024px) {
.post-layout { grid-template-columns: 1fr; }
.aside-inner { position: static; max-height: none; }
}
@media (max-width: 600px) {
.pager { grid-template-columns: 1fr; }
.pager-card.next { text-align: left; align-items: flex-start; }
.pager-empty { display: none; }
}
</style>

View file

@ -0,0 +1,42 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/PostCard.astro';
import TagChip from '../../components/TagChip.astro';
import { getPublishedPosts, getAllTags } from '../../lib/utils';
const all = await getPublishedPosts();
const tags = getAllTags(all);
---
<BaseLayout title="全部文章" description="Yukun's Blog 的全部文章列表">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<header class="page-head reveal">
<h1 class="page-title">全部文章</h1>
<p class="page-sub">共 {all.length} 篇 · 按发布时间倒序排列</p>
</header>
<div class="tag-bar glass reveal">
<span class="tag-label">筛选标签</span>
<div class="tag-list">
<a href="/posts" class="tag-chip active">全部</a>
{tags.map((t) => <TagChip tag={t.tag} count={t.count} />)}
</div>
</div>
</section>
<section class="container section" style="padding-top: 24px;">
<div class="post-grid">
{all.map((post) => <PostCard post={post} class="reveal" />)}
</div>
</section>
</BaseLayout>
<style>
.page-head { margin-bottom: 28px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; color: var(--ink); }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.tag-bar { border-radius: var(--r-lg); padding: 14px 18px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.tag-label { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-faint); font-weight: 700; white-space: nowrap; }
.tag-list { display: flex; flex-wrap: wrap; gap: 8px; }
.post-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: clamp(16px, 2.5vw, 24px); }
</style>

View file

@ -0,0 +1,18 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
// 构建期生成搜索索引:标题 / 标签 / 简介 / 链接
export const GET: APIRoute = async () => {
const posts = (await getCollection('posts', ({ data }) => data.draft !== true)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
const index = posts.map((p) => ({
title: p.data.title,
description: p.data.description,
tags: p.data.tags,
url: `/posts/${p.id.replace(/\/index$/, '')}`,
}));
return new Response(JSON.stringify(index), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
};

View file

@ -0,0 +1,44 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/PostCard.astro';
import { getPublishedPosts, getAllTags } from '../../lib/utils';
export async function getStaticPaths() {
const all = await getPublishedPosts();
const tags = getAllTags(all);
return tags.map(({ tag, count }) => ({
params: { tag },
props: {
tag,
count,
posts: all.filter((p) => p.data.tags.includes(tag)),
},
}));
}
const { tag, count, posts } = Astro.props;
---
<BaseLayout title={`#${tag}`} description={`${tag} 标签下的全部文章`}>
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<header class="page-head reveal">
<div class="crumb"><a href="/tags">标签</a> <span>/</span> #{tag}</div>
<h1 class="page-title gradient-text">#{tag}</h1>
<p class="page-sub">共 {count} 篇文章</p>
</header>
<div class="post-grid">
{posts.map((post) => <PostCard post={post} class="reveal" />)}
</div>
</section>
</BaseLayout>
<style>
.page-head { margin-bottom: 28px; }
.crumb { color: var(--ink-faint); font-size: 0.88rem; margin-bottom: 12px; }
.crumb a { color: var(--blue-600); }
.crumb span { margin: 0 6px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.post-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: clamp(16px, 2.5vw, 24px); }
</style>

View file

@ -0,0 +1,30 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import TagChip from '../../components/TagChip.astro';
import { getPublishedPosts, getAllTags } from '../../lib/utils';
const all = await getPublishedPosts();
const tags = getAllTags(all);
---
<BaseLayout title="标签" description="按标签浏览文章">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<header class="page-head reveal">
<h1 class="page-title">标签</h1>
<p class="page-sub">共 {tags.length} 个标签 · 字号越大文章越多</p>
</header>
<div class="tag-cloud glass reveal">
{tags.map((t) => (
<TagChip tag={t.tag} count={t.count} />
))}
</div>
</section>
</BaseLayout>
<style>
.page-head { margin-bottom: 28px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; color: var(--ink); }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.tag-cloud { border-radius: var(--r-lg); padding: clamp(24px, 4vw, 40px); display: flex; flex-wrap: wrap; gap: 12px; }
</style>

77
src/remark-wikilinks.mjs Normal file
View file

@ -0,0 +1,77 @@
import { visit, SKIP } from 'unist-util-visit';
import GithubSlugger, { slug } from 'github-slugger';
// Obsidian wikilink → 普通链接
// [[#锚点]] → 页内跳转 #锚点
// [[笔记名]] → /posts/笔记名
// [[笔记名#锚点]] → /posts/笔记名#锚点
// [[x|显示文字]] → 显示文字
// 注意Astro 内容集合的 id 会用 github-slugger 处理(大写转小写等),
// 所以笔记名部分必须用同样的 slug 规则否则生成的路由与构建产物不一致404
export default function remarkWikilinks() {
return (tree) => {
const slugger = new GithubSlugger();
// 每个链接独立计算锚点 slugAstro 的标题 id 是按页独立的,
// 重复链接同一锚点不应产生 -1、-2 后缀
const anchorSlug = (anchor) => {
slugger.reset();
return slugger.slug(anchor);
};
visit(tree, 'text', (node, index, parent) => {
if (!parent || index === null) return;
// 不动代码块里的文本
if (parent.type === 'code' || parent.type === 'inlineCode') return;
const value = node.value;
if (!value.includes('[[')) return;
const re = /\[\[([^\]]+)\]\]/g;
const parts = [];
let last = 0;
let m;
let matched = false;
while ((m = re.exec(value)) !== null) {
matched = true;
if (m.index > last) parts.push({ type: 'text', value: value.slice(last, m.index) });
const inner = m[1];
let target, alias;
const pipe = inner.indexOf('|');
if (pipe >= 0) {
target = inner.slice(0, pipe).trim();
alias = inner.slice(pipe + 1).trim();
} else {
target = inner.trim();
alias = '';
}
let href;
let text;
if (target.startsWith('#')) {
// 页内锚点
const anchor = target.slice(1).trim();
href = '#' + anchorSlug(anchor);
text = alias || anchor;
} else {
// 跨笔记
const hashIdx = target.indexOf('#');
if (hashIdx >= 0) {
const note = target.slice(0, hashIdx).trim();
const anchor = target.slice(hashIdx + 1).trim();
href = '/posts/' + slug(note) + (anchor ? '#' + anchorSlug(anchor) : '');
text = alias || anchor || note;
} else {
href = '/posts/' + slug(target);
text = alias || target;
}
}
parts.push({ type: 'link', url: href, children: [{ type: 'text', value: text }] });
last = m.index + m[0].length;
}
if (!matched) return;
if (last < value.length) parts.push({ type: 'text', value: value.slice(last) });
if (parts.length) {
parent.children.splice(index, 1, ...parts);
return [SKIP, index + parts.length];
}
});
};
}

303
src/styles/global.css Normal file
View file

@ -0,0 +1,303 @@
/* ==========================================================================
Yukun's Blog · 全局样式
液态玻璃 × 淡蓝设计系统
========================================================================== */
/* ---------- 设计令牌 ---------- */
:root {
/* 淡蓝配色 */
--blue-50: #eef7ff;
--blue-100: #d8ecff;
--blue-200: #b4d9ff;
--blue-300: #84c2ff;
--blue-400: #4ea3ff;
--blue-500: #2f8df0;
--blue-600: #2f7fe0;
--blue-700: #2a6cc7;
--cyan-400: #38bdf8;
--cyan-300: #67d3f8;
--ink: #16324f; /* 主文字 深墨蓝 */
--ink-soft: #3a5a7a; /* 次文字 */
--ink-faint: #6b87a3; /* 弱文字 */
--bg: #eef7ff; /* 底色 */
--bg-2: #f9fdff; /* 亮底色 */
/* 玻璃 */
--glass: rgba(255, 255, 255, 0.55);
--glass-strong: rgba(255, 255, 255, 0.72);
--glass-soft: rgba(255, 255, 255, 0.38);
--glass-border: rgba(255, 255, 255, 0.65);
--glass-shadow: 0 8px 32px rgba(31, 96, 160, 0.12),
0 2px 8px rgba(31, 96, 160, 0.06);
/* 字体 */
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
--font-body: 'Noto Sans SC', system-ui, -apple-system, 'PingFang SC',
'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono',
Consolas, monospace;
/* 间距 / 圆角 */
--r-sm: 10px;
--r-md: 16px;
--r-lg: 24px;
--r-xl: 32px;
--container: 1120px;
--nav-h: 68px;
}
/* ---------- 重置 ---------- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
body {
font-family: var(--font-body);
color: var(--ink);
background: var(--bg);
line-height: 1.75;
font-size: 16px;
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
img, picture, svg, video, canvas { display: block; max-width: 100%; height: auto; }
input, button, textarea, select { font: inherit; color: inherit; }
a { color: var(--blue-600); text-decoration: none; transition: color .2s; }
a:hover { color: var(--blue-500); }
ul, ol { list-style: none; padding: 0; }
table { border-collapse: collapse; }
:focus-visible { outline: 2px solid var(--blue-500); outline-offset: 3px; border-radius: 4px; }
/* 滚动条 */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(79, 163, 255, 0.35);
border-radius: 10px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: rgba(79, 163, 255, 0.6); background-clip: padding-box; }
::selection { background: var(--blue-200); color: var(--ink); }
/* ---------- 背景层:渐变 + 漂浮光斑 ---------- */
.bg-layers {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 20% 0%, #d8ecff 0%, transparent 55%),
radial-gradient(ellipse 70% 50% at 90% 10%, #cfeeff 0%, transparent 50%),
linear-gradient(180deg, #eef7ff 0%, #f6fbff 40%, #eaf4ff 100%);
}
.bg-layers::before {
/* 极淡网格,给玻璃一些"透"的纹理 */
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(79, 163, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(79, 163, 255, 0.05) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 70% 70% at 50% 30%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 30%, #000 30%, transparent 80%);
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(60px);
opacity: 0.6;
will-change: transform;
}
.blob-1 {
width: 460px; height: 460px;
top: -80px; left: -60px;
background: radial-gradient(circle, var(--blue-300), transparent 70%);
animation: float1 22s ease-in-out infinite;
}
.blob-2 {
width: 520px; height: 520px;
top: 20%; right: -120px;
background: radial-gradient(circle, var(--cyan-300), transparent 70%);
animation: float2 26s ease-in-out infinite;
}
.blob-3 {
width: 380px; height: 380px;
bottom: -100px; left: 35%;
background: radial-gradient(circle, var(--blue-200), transparent 70%);
animation: float3 30s ease-in-out infinite;
}
@keyframes float1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(60px, 80px) scale(1.1); }
}
@keyframes float2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-80px, 50px) scale(0.92); }
}
@keyframes float3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(40px, -70px) scale(1.08); }
}
/* ---------- 玻璃工具类 ---------- */
.glass {
background: var(--glass);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
/* 内侧高光:顶部 1px 亮线 */
position: relative;
}
.glass::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(180deg, rgba(255,255,255,0.9), rgba(255,255,255,0.1) 40%, transparent);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.glass-strong {
background: var(--glass-strong);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
}
/* ---------- 布局 ---------- */
.container {
width: 100%;
max-width: var(--container);
margin-inline: auto;
padding-inline: clamp(16px, 4vw, 32px);
}
main { display: block; }
.section { padding-block: clamp(40px, 7vw, 80px); }
.section-title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.5rem, 4vw, 2.25rem);
letter-spacing: -0.02em;
color: var(--ink);
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 1.5rem;
}
.section-title::before {
content: '';
width: 6px; height: 1.2em;
background: linear-gradient(180deg, var(--blue-500), var(--cyan-400));
border-radius: 6px;
}
/* ---------- 按钮 ---------- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 999px;
font-weight: 600;
font-size: 0.95rem;
border: 1px solid var(--glass-border);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--blue-600);
cursor: pointer;
transition: transform .25s cubic-bezier(.2,.8,.2,1), box-shadow .25s, background .25s;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(47,127,224,.18); background: var(--glass-strong); }
.btn-primary {
background: linear-gradient(135deg, var(--blue-500), var(--blue-600));
color: #fff;
border-color: transparent;
box-shadow: 0 8px 20px rgba(47,127,224,.32);
}
.btn-primary:hover { background: linear-gradient(135deg, var(--blue-400), var(--blue-500)); }
/* ---------- 标签 chip ---------- */
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: var(--blue-700);
background: rgba(132, 194, 255, 0.18);
border: 1px solid rgba(132, 194, 255, 0.4);
transition: all .2s;
white-space: nowrap;
}
.chip:hover { background: rgba(132, 194, 255, 0.32); transform: translateY(-1px); }
.chip-lg { padding: 6px 16px; font-size: 0.88rem; }
/* ---------- 滚动渐入 ---------- */
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity .7s cubic-bezier(.2,.8,.2,1), transform .7s cubic-bezier(.2,.8,.2,1);
}
.reveal.in { opacity: 1; transform: none; }
/* ---------- 元数据 ---------- */
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
color: var(--ink-faint);
font-size: 0.85rem;
}
.meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .5; }
/* ---------- 文字工具 ---------- */
.text-display { font-family: var(--font-display); }
.gradient-text {
background: linear-gradient(135deg, var(--blue-600), var(--cyan-400) 60%, var(--blue-500));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* ---------- 空状态 ---------- */
.empty {
text-align: center;
padding: 4rem 1rem;
color: var(--ink-faint);
}
/* ---------- 工具栏 / 分割 ---------- */
.hr-soft {
height: 1px;
border: 0;
background: linear-gradient(90deg, transparent, rgba(79,163,255,.3), transparent);
margin: 2.5rem 0;
}
/* 响应式断点工具 */
@media (max-width: 768px) {
body { font-size: 15px; }
.section { padding-block: clamp(28px, 8vw, 44px); }
}

137
src/styles/prose.css Normal file
View file

@ -0,0 +1,137 @@
/* ==========================================================================
文章正文排版 · prose
========================================================================== */
.prose {
font-size: clamp(1rem, 0.4rem + 1.6vw, 1.125rem);
line-height: 1.85;
color: var(--ink);
word-wrap: break-word;
overflow-wrap: break-word;
}
.prose > * + * { margin-top: 1.4em; }
/* 标题 */
.prose h2, .prose h3, .prose h4 {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.02em;
scroll-margin-top: calc(var(--nav-h) + 24px);
}
.prose h2 {
font-size: clamp(1.4rem, 1rem + 1.8vw, 1.75rem);
margin-top: 2.2em;
padding-bottom: .4em;
border-bottom: 1px solid rgba(79,163,255,.25);
position: relative;
}
.prose h2::before {
content: '';
display: inline-block;
width: 5px; height: 1em;
margin-right: 12px;
vertical-align: -0.05em;
border-radius: 6px;
background: linear-gradient(180deg, var(--blue-500), var(--cyan-400));
}
.prose h3 {
font-size: clamp(1.2rem, 0.9rem + 1.2vw, 1.4rem);
margin-top: 1.8em;
color: var(--ink);
}
.prose h4 { font-size: 1.05rem; margin-top: 1.6em; }
.prose p { color: var(--ink); }
.prose a { color: var(--blue-600); text-decoration: underline; text-decoration-color: rgba(47,127,224,.35); text-underline-offset: 3px; }
.prose a:hover { text-decoration-color: var(--blue-500); }
/* 列表 */
.prose ul, .prose ol { padding-left: 1.4em; }
.prose ul { list-style: disc; }
.prose ol { list-style: decimal; }
.prose li { margin-top: .4em; }
.prose li::marker { color: var(--blue-500); }
/* 引用 */
.prose blockquote {
padding: 1em 1.4em;
margin: 1.6em 0;
border-left: 4px solid var(--blue-400);
border-radius: 0 var(--r-md) var(--r-md) 0;
background: linear-gradient(90deg, rgba(132,194,255,.12), transparent);
color: var(--ink-soft);
font-style: normal;
}
.prose blockquote p { color: var(--ink-soft); }
/* 代码 */
.prose :not(pre) > code {
font-family: var(--font-mono);
font-size: 0.88em;
padding: 2px 7px;
border-radius: 6px;
background: rgba(47,127,224,.12);
color: var(--blue-700);
border: 1px solid rgba(47,127,224,.18);
word-break: break-word;
}
.prose pre {
padding: 1.15em 1.4em;
border-radius: var(--r-md);
overflow-x: auto;
border: 1px solid var(--glass-border);
background: rgba(255,255,255,.62);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: var(--glass-shadow);
font-size: 0.88rem;
line-height: 1.7;
}
.prose pre code { font-family: var(--font-mono); background: none; border: 0; padding: 0; color: inherit; }
/* 行内元素 */
.prose strong { font-weight: 800; color: var(--ink); }
.prose em { font-style: italic; }
.prose del { color: var(--ink-faint); }
.prose mark { background: var(--blue-200); color: var(--ink); padding: 1px 4px; border-radius: 4px; }
.prose kbd {
font-family: var(--font-mono);
font-size: .85em;
padding: 2px 6px;
border-radius: 6px;
background: #fff;
border: 1px solid var(--blue-200);
box-shadow: 0 2px 0 var(--blue-200);
}
/* 分割线 */
.prose hr { height: 1px; border: 0; margin: 2.4em 0; background: linear-gradient(90deg, transparent, rgba(79,163,255,.3), transparent); }
/* 图片 */
.prose img {
border-radius: var(--r-md);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
margin: 1.6em auto;
}
/* 表格 */
.prose table { width: 100%; margin: 1.6em 0; font-size: .92em; overflow-x: auto; display: block; }
.prose thead { background: rgba(132,194,255,.18); }
.prose th, .prose td { padding: .6em .9em; border: 1px solid rgba(79,163,255,.25); text-align: left; }
.prose tbody tr:nth-child(even) { background: rgba(132,194,255,.06); }
/* 移动端:代码块横向滚动不撑破布局 */
@media (max-width: 768px) {
.prose pre { font-size: 0.82rem; padding: 1em; }
.prose table { font-size: 0.85rem; }
}
/* KaTeX 数学公式 */
.prose .katex { font-size: 1.05em; }
.prose .katex-display {
margin: 1.4em 0;
padding: 0.6em 0.2em;
overflow-x: auto;
overflow-y: hidden;
}
.prose .katex-display > .katex { font-size: 1.15em; }

11
tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}