fix: 修复首页空白、小红书/游记封面图、定制页二维码

1. 首页大面积空白:useScrollReveal 添加 MutationObserver,
   确保异步子组件的 .reveal 元素也能触发入场动画

2. 小红书/游记封面图不显示:
   - 小红书 CDN 签名链接过期+防盗链,全部下载到本地
   - 新增 download-covers.mjs 脚本批量下载封面
   - 新增 useImageProxy composable 转换外部 URL
   - 页面 img 标签添加 referrerpolicy="no-referrer"
   - 管理端保存时自动下载外部封面图到本地
   - 种子数据更新为本地路径

3. 定制页微信号文字替换为二维码图片

4. prerender 路由添加 /xiaohongshu 和 /youji

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
这个提交包含在:
刘涛
2026-03-25 15:37:58 +08:00
共同撰写人 Claude Opus 4.6
父节点 b82712deed
当前提交 8832762e51
共修改 16 个文件,包含 599 行新增和 106 行删除
+38
查看文件
@@ -0,0 +1,38 @@
/**
* 小红书图片 URL 转换
*
* 小红书 CDN 图片 (sns-webpic-qc.xhscdn.com) 使用签名 URL,
* 格式: https://sns-webpic-qc.xhscdn.com/{timestamp}/{hash}/{path}
* 这些 URL 会过期且有防盗链,第三方站点无法直接使用。
*
* 解决方案:转换为 ci.xiaohongshu.com 的永久 URL
* 格式: https://ci.xiaohongshu.com/{path}
*/
/**
* 将小红书签名 URL 转换为永久可访问的 URL
*/
export function proxyImage(url) {
if (!url) return ''
try {
const parsed = new URL(url)
// sns-webpic-qc.xhscdn.com/{timestamp}/{hash}/{actual_path}
// 提取 actual_path 部分,转到 ci.xiaohongshu.com
if (parsed.hostname === 'sns-webpic-qc.xhscdn.com') {
const parts = parsed.pathname.split('/')
// 去掉前导空字符串、timestamp、hash,剩下的就是图片路径
// pathname: /202603231146/hash/notes_pre_post/image_id
if (parts.length >= 4) {
const imagePath = parts.slice(3).join('/')
return `https://ci.xiaohongshu.com/${imagePath}`
}
}
// 已经是 ci.xiaohongshu.com 的永久链接,直接用
if (parsed.hostname === 'ci.xiaohongshu.com') {
return url
}
} catch {
// 不是合法 URL(可能是本地路径),直接返回
}
return url
}
+22 -6
查看文件
@@ -2,28 +2,44 @@
* 滚动入场动画 composable
* 元素进入视口时触发淡入上滑动画
* 自动尊重 prefers-reduced-motion
* 使用 MutationObserver 确保异步渲染的子组件也能被观察到
*/
export default function useScrollReveal() {
onMounted(() => {
// 尊重用户减少动效偏好
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const observer = new IntersectionObserver(
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible')
observer.unobserve(entry.target)
io.unobserve(entry.target)
}
})
},
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
)
document.querySelectorAll('.reveal').forEach((el) => {
observer.observe(el)
})
// 观察当前已有的 .reveal 元素
const observed = new WeakSet()
function observeAll() {
document.querySelectorAll('.reveal:not(.is-visible)').forEach((el) => {
if (!observed.has(el)) {
observed.add(el)
io.observe(el)
}
})
}
observeAll()
onUnmounted(() => observer.disconnect())
// 监听 DOM 变化,捕获异步渲染的子组件中的 .reveal 元素
const mo = new MutationObserver(observeAll)
mo.observe(document.body, { childList: true, subtree: true })
onUnmounted(() => {
io.disconnect()
mo.disconnect()
})
})
}