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:
commit
12dd13a76f
39 changed files with 19371 additions and 0 deletions
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# 日志与系统文件
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# 环境变量(避免误传密钥)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
107
README.md
Normal file
107
README.md
Normal 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 生成 URL(ASCII 大写自动转小写),
|
||||
> 例如 `变分下界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
38
astro.config.mjs
Normal 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
307
deploy/deploy.sh
Executable 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
98
deploy/ip-preview.sh
Executable 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
47
deploy/nginx-serve.conf
Normal 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
68
deploy/nginx.conf
Normal 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/yukun(deploy.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
273
deploy/proxy.sh
Executable 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
7514
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal 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
53
public/favicon.svg
Normal 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)">
|
||||
<Σ>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
8
src/components/Background.astro
Normal file
8
src/components/Background.astro
Normal 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>
|
||||
71
src/components/Footer.astro
Normal file
71
src/components/Footer.astro
Normal 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’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
222
src/components/Nav.astro
Normal 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">’</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>
|
||||
118
src/components/PostCard.astro
Normal file
118
src/components/PostCard.astro
Normal 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>
|
||||
273
src/components/SearchModal.astro
Normal file
273
src/components/SearchModal.astro
Normal 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
55
src/components/TagChip.astro
Normal file
55
src/components/TagChip.astro
Normal 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
139
src/components/Toc.astro
Normal 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
22
src/content.config.ts
Normal 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 };
|
||||
178
src/content/posts/变分下界ELBO笔记.md
Normal file
178
src/content/posts/变分下界ELBO笔记.md
Normal 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_{z∼q}[·]
|
||||
读作"$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]] | [[#结果讨论]] | [[#从自由变分到编码器]]
|
||||
193
src/content/posts/变分自编码器VAE笔记.md
Normal file
193
src/content/posts/变分自编码器VAE笔记.md
Normal 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 的梯度能回传 |
|
||||
3664
src/content/posts/数学分析笔记.md
Normal file
3664
src/content/posts/数学分析笔记.md
Normal file
File diff suppressed because it is too large
Load diff
878
src/content/posts/物理电磁学笔记.md
Normal file
878
src/content/posts/物理电磁学笔记.md
Normal 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 世纪物理学最伟大的成就之一。
|
||||
|
||||
> **相关**:[[#高斯定理:]] | [[#磁场中的高斯定理]] | [[#法拉第电磁感应定律(微分形式):]] | [[#全电流安培环路定理:]] | [[#有介质时的高斯定理]]
|
||||
|
||||
3534
src/content/posts/高等代数笔记.md
Normal file
3534
src/content/posts/高等代数笔记.md
Normal file
File diff suppressed because it is too large
Load diff
129
src/layouts/BaseLayout.astro
Normal file
129
src/layouts/BaseLayout.astro
Normal 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
70
src/lib/utils.ts
Normal 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
28
src/pages/404.astro
Normal 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
76
src/pages/about.astro
Normal 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
123
src/pages/archives.astro
Normal 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
186
src/pages/index.astro
Normal 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’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>
|
||||
189
src/pages/posts/[slug].astro
Normal file
189
src/pages/posts/[slug].astro
Normal 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>
|
||||
42
src/pages/posts/index.astro
Normal file
42
src/pages/posts/index.astro
Normal 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>
|
||||
18
src/pages/search-index.json.ts
Normal file
18
src/pages/search-index.json.ts
Normal 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' },
|
||||
});
|
||||
};
|
||||
44
src/pages/tags/[tag].astro
Normal file
44
src/pages/tags/[tag].astro
Normal 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>
|
||||
30
src/pages/tags/index.astro
Normal file
30
src/pages/tags/index.astro
Normal 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
77
src/remark-wikilinks.mjs
Normal 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();
|
||||
// 每个链接独立计算锚点 slug:Astro 的标题 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
303
src/styles/global.css
Normal 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
137
src/styles/prose.css
Normal 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
11
tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue