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>
这个提交包含在:
@@ -0,0 +1,91 @@
|
||||
import { writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
/**
|
||||
* 下载外部图片到本地
|
||||
* POST /api/admin/download-image
|
||||
* Body: { url: "https://..." }
|
||||
* Returns: { localUrl: "/uploads/covers/xxx.webp" }
|
||||
*/
|
||||
|
||||
const ALLOWED_HOSTS = [
|
||||
'sns-webpic-qc.xhscdn.com',
|
||||
'ci.xiaohongshu.com',
|
||||
'sns-img-bd.xhscdn.com',
|
||||
]
|
||||
|
||||
/**
|
||||
* 将签名 CDN URL 转为永久可下载的 URL
|
||||
*/
|
||||
function toPermanentUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.hostname === 'sns-webpic-qc.xhscdn.com') {
|
||||
const parts = parsed.pathname.split('/')
|
||||
if (parts.length >= 4) {
|
||||
const imagePath = parts.slice(3).join('/')
|
||||
return `https://ci.xiaohongshu.com/${imagePath}`
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return url
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdmin(event)
|
||||
|
||||
const { url } = await readBody(event)
|
||||
if (!url || !url.startsWith('http')) {
|
||||
throw createError({ statusCode: 400, message: '请提供有效的图片 URL' })
|
||||
}
|
||||
|
||||
// 验证域名白名单
|
||||
let parsedUrl
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
throw createError({ statusCode: 400, message: '无效的 URL' })
|
||||
}
|
||||
|
||||
if (!ALLOWED_HOSTS.includes(parsedUrl.hostname)) {
|
||||
throw createError({ statusCode: 403, message: '该域名不支持自动下载,请手动上传图片' })
|
||||
}
|
||||
|
||||
// 生成本地文件名
|
||||
const pathParts = parsedUrl.pathname.split('/')
|
||||
const imageId = pathParts[pathParts.length - 1].split('!')[0] || `img-${Date.now()}`
|
||||
const filename = `${imageId}.webp`
|
||||
const coversDir = join(process.cwd(), 'public', 'uploads', 'covers')
|
||||
|
||||
if (!existsSync(coversDir)) {
|
||||
mkdirSync(coversDir, { recursive: true })
|
||||
}
|
||||
|
||||
const localPath = join(coversDir, filename)
|
||||
const localUrl = `/uploads/covers/${filename}`
|
||||
|
||||
// 如果已下载过,直接返回
|
||||
if (existsSync(localPath)) {
|
||||
return { localUrl }
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
const downloadUrl = toPermanentUrl(url)
|
||||
try {
|
||||
const res = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw createError({ statusCode: 502, message: `图片下载失败 (${res.status})` })
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer())
|
||||
writeFileSync(localPath, buffer)
|
||||
return { localUrl }
|
||||
} catch (err) {
|
||||
if (err.statusCode) throw err
|
||||
throw createError({ statusCode: 502, message: `图片下载出错: ${err.message}` })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 图片代理接口
|
||||
* 用于加载有防盗链保护的外部图片(如小红书 CDN)
|
||||
*
|
||||
* 用法: /api/image-proxy?url=https://sns-webpic-qc.xhscdn.com/...
|
||||
*
|
||||
* 安全限制:只允许代理白名单域名的图片
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { url } = getQuery(event)
|
||||
|
||||
if (!url) {
|
||||
throw createError({ statusCode: 400, message: '缺少 url 参数' })
|
||||
}
|
||||
|
||||
// 白名单:只允许代理指定域名的图片
|
||||
const allowedHosts = [
|
||||
'sns-webpic-qc.xhscdn.com',
|
||||
'ci.xiaohongshu.com',
|
||||
'image.xiaohongshu.com',
|
||||
'picx.zhimg.com',
|
||||
]
|
||||
|
||||
let parsedUrl
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
throw createError({ statusCode: 400, message: '无效的 URL' })
|
||||
}
|
||||
|
||||
if (!allowedHosts.includes(parsedUrl.hostname)) {
|
||||
throw createError({ statusCode: 403, message: '该域名不在白名单中' })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw createError({ statusCode: response.status, message: '图片获取失败' })
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
||||
const buffer = await response.arrayBuffer()
|
||||
|
||||
setHeader(event, 'Content-Type', contentType)
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400, s-maxage=604800')
|
||||
setHeader(event, 'Access-Control-Allow-Origin', '*')
|
||||
|
||||
return Buffer.from(buffer)
|
||||
} catch (err) {
|
||||
if (err.statusCode) throw err
|
||||
throw createError({ statusCode: 502, message: '图片代理请求失败' })
|
||||
}
|
||||
})
|
||||
在新工单中引用
屏蔽一个用户