目录结构
双通道架构
Qingyu’Blog 的核心设计是双通道架构——同一份代码支持两种运行模式: 云端模式下,请求分两条路径落地:前端架构
前台 (app.js)
- 真实路径路由:无 hash,基于
history.pushState;file://本地打开自动退化为#/内部路由 - Markdown 渲染:内置解析器,支持代码高亮、TOC 生成
- 评论系统:云端 D1 / 静态 localStorage,支持嵌套回复;重复发送拦截(后端 409 + 前端透传原因)
- 留言板:
/guestbook双分区(留言 / 优化方案),复用评论管道 - 文章加密:PBKDF2 + AES-GCM 端到端加密
- 搜索:实时匹配标题/标签/摘要,结果展示关键字所在完整句子并高亮
- 主题与强调色:深色/浅色两档(
qingyu.theme)× 4 种强调色terra(赭橙,默认)/indigo(黛蓝)/bamboo(竹青)/dusk(凝夜紫),存qingyu.accent;桌面为图标按钮 + 色板弹层,手机端为原生下拉 - 背景动画:首页春夏秋冬素描粒子动画,桌面默认开启、触屏默认关闭,开关存
qingyu.bgAnim;脚本按需加载 - 多语言:5 种语言,自动识别浏览器语言(桌面 🌐 图标弹层 / 手机原生下拉),选择存
blog.locale - AI 文章摘要:文章页一键生成内容摘要(单篇缓存 30 天),AI 不可用自动隐藏入口
- 全站音乐播放器:右下角悬浮音符按钮(缩进窗口外露出一点,悬停/点击滑出)→ 弹出面板(曲目信息 / 进度 / 播放控制 / 音量 / 播放列表);自动连播、记忆上次曲目与进度、音量持久化;无音乐隐藏、后台路由收起(独立模块
music-player.js,通过qy:route事件同步路由)
首屏弱网兜底(v2.10.93 / v2.10.97)
首屏关键资源(config / posts / i18n / app / style)在index.html 中显式 preload,不再靠脚本探测器猜时机:
实测排查结论:站点「打开慢」的主因是线路丢包,不是项目代码。首屏兜底是为了在丢包时优雅降级,而非掩盖性能问题。
资源按需加载
首屏只加载config.min.js、posts.min.js、i18n.min.js、app.min.js 与 style.min.css,其余重资源全部延后:
后台 (admin.js)
完整的 SPA 管理界面(懒加载 admin.min.js / admin.min.css):
后台编辑器已提供分类、发布状态、作者、SEO 与文章加密 UI;预览分享链接、版本恢复、导入导出与静态站导出均在后台完成。
后端架构
API 核心 (api-core.js)
所有 API 逻辑集中在单个文件中,被两处复用:
- Cloudflare Pages Functions:
functions/api/下的路由文件 - Cloudflare Workers:
worker.js的路由分发
Workers 入口 (worker.js)
worker.js 负责三件事:
- API 路由分发:把
/api/*请求交给api-core.js或_lib/中的处理函数 - 静态资源服务与 SPA 回退:静态文件命中 404、请求方法为
GET/HEAD且路径不含扩展名时,回退为index.html(由前端按pathname渲染);带扩展名的真实文件不会回退 - 未知
/api/*一律 JSON 404:/api与/api/开头且未命中任何路由的请求返回{"ok":false,"error":"Not Found"},绝不回退 HTML,避免调用方拿到 HTML 再去JSON.parse时报出难懂的解析错误
Pages 侧兜底 (functions/api/[[path]].js)
Pages 部署下 public/_redirects 末尾的 SPA 回退规则 /* /index.html 200 会把「没有对应函数文件」的 /api/* 路径兜成 HTML 200。catch-all 动态路由 [[path]].js 因此承担 Pages 侧兜底:
- 已知的具体路由(
KNOWN_ROUTES,与functions/api/**一一对应)显式放行,不吞真实接口 - 其余未知
/api/*返回 JSON 404(带hint说明) OPTIONS预检直接回 CORS 头,避免浏览器把「无 ACAO 的 404」报成 CORS 错误
缓存策略
Feed / Sitemap 动态端点:云端模式下/feed.xml、/sitemap.xml与/api/feed.xml、/api/sitemap.xml均实时从 D1 当前文章生成,文章增删改查后自动更新,不再需要手工维护public/feed.xml/public/sitemap.xml。D1 空表且尚未配置过管理员时,readPosts()会回退到静态posts.js示例文章;一旦写过admin_auth(视为作者已接管),空表就返回空,避免删光文章后示例内容「复活」。
按标签清缓存:只读响应带Cache-Tag(posts/feed/sitemap/post:<id>/stats:<id>)。写操作后由purgeTags()调用 Cloudflare Cache Purge API 按标签清除,让「发布即生效」。需要CF_ZONE_ID+ 具备 Zone·Cache Purge 权限的 Token(工作流会把它写成 Worker 的CF_API_TOKEN);未配置时不清缓存,依赖s-maxage自然过期,访客侧最长延迟 60 秒。
管理端缓存穿透:GET /api/posts带s-maxage=60边缘缓存,删除/发布文章后管理后台会以api/posts?_=<时间戳>请求穿透缓存立即取到最新列表(配合前台内存同步,删除无需刷新网页即可全线生效)。
KV 的真实用途
KV(绑定名BLOG)只用于限流计数、去重标记与 AI 缓存,不再存文章、feed 或 sitemap 缓存:
音乐(R2 直传)
音乐走浏览器直传 R2 通道:Worker 只签发预签名 PUT URL,音频文件不经过 Worker(规避请求体上限与带宽计费),元数据存 D1music 表。删除时 Worker 侧签名向 R2 发起 DELETE 再删 D1 行。
目标桶由 musicStorage() 决定:R2_BUCKET 与 R2_PUBLIC_BASE 都非空时写音乐桶,否则回退到 R2_MEDIA_BUCKET + R2_MEDIA_PUBLIC_BASE。详见音乐。
媒体图片(R2 直传)
后台媒体库的图片上传与音乐同款:POST /api/media/upload-url 签发预签名 PUT URL,浏览器直传 R2 媒体专用桶(R2_MEDIA_BUCKET),D1 media 表只存元数据(url 为 R2 公开地址)。删除时若 url 属于本站媒体公开域名则先删 R2 对象再删 D1 行;接口仅接受 http/https 链接(不接受 base64 内嵌)。详见媒体。
静态资源缓存与安全响应头
worker.js 会给所有静态响应统一注入安全响应头,并按路径类型设置缓存:
安全响应头清单(
securityHeaders(),API 响应与静态响应共用):X-Content-Type-Options: nosniff、Referrer-Policy: strict-origin-when-cross-origin、X-Frame-Options: SAMEORIGIN、Cross-Origin-Opener-Policy: same-origin、Content-Security-Policy(含 object-src 'none'、frame-ancestors 'none'、form-action 'self')。
Pages 纯静态路径下 HTML 由 Pages 直接返回、不经过任何函数,因此同样的安全头在
public/_headers 中重复声明了一份(见部署到 Cloudflare Workers)。缓存与安全的完整说明见安全概览。资源发现
站点为主流 AI/Agent 抓取提供了三个发现入口:public/index.html 中通过 <link rel="ard"> 与 <link rel="ai-catalog"> 声明;public/_headers 为这两个 JSON 设置了 Access-Control-Allow-Origin: * 与 1 小时缓存。