缩略图懒生成 + 评论登录限流:时间线不再拉原图

- 新增 /uploads/thumb/{key}?w=:首访从存储端读一次原图,标准库盒均值缩放后
  落盘 data/.thumbnail_cache(按内容哈希命名,天然失效);失败写 .failed 冷却
  一小时,非图片/超大一律 302 回原图,前端无感。原图路由改用 ServeContent,
  补上视频拖动进度条所需的 Range 支持
- 前端 thumbURL/thumbifyHtml 接入两套 UI 的时间线配图、长文封面与短文正文,
  并补 loading=lazy;详情页与灯箱仍用原图。/api/site 改为
  {settings, uploads_public_base},让前端识别哪些直链属于自家存储
- 后台登录的滑动窗口限速器抽成 internal/ratelimit 共享包(调用面不变),
  新增:读者登录失败按 IP 20 次/10 分钟、评论写入按读者 5 条/分钟
  (站主豁免,且只计成功写入)
This commit is contained in:
Sakurasan
2026-09-28 13:34:19 +08:00
parent b6342a622d
commit 6108aca34c
17 changed files with 822 additions and 71 deletions
+9 -5
View File
@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
const router = useRouter()
@@ -20,10 +20,11 @@ function openPost() {
router.push(`/post/${props.post.slug}`)
}
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要。
// 正文里的 <img> 换缩略图并补懒加载(详情仍是原图)。
const body = computed(() => {
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
return sanitizeHtml(props.post.content_html)
return sanitizeHtml(thumbifyHtml(props.post.content_html))
})
// 长文没写摘要时,从正文里截一段纯文本
@@ -33,6 +34,9 @@ const summaryText = computed(() => {
})
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
// 配图缩略图宽度:单图全宽格取宽一些,多图小格 480 足够(2x 屏也清晰)
const gridW = computed(() => (props.post.images && props.post.images.length === 1 ? 720 : 480))
</script>
<template>
@@ -58,7 +62,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<template v-else>
<div class="summary">{{ summaryText }}</div>
<div v-if="post.cover_url" class="imgs cover">
<img :src="post.cover_url" alt="" loading="lazy" decoding="async" />
<img :src="thumbURL(post.cover_url, 640)" alt="" loading="lazy" decoding="async" />
</div>
</template>
@@ -71,7 +75,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
:src="thumbURL(u, gridW)"
alt=""
loading="lazy"
decoding="async"
+6 -2
View File
@@ -1,5 +1,6 @@
import { reactive, watch } from 'vue'
import { publicApi } from './api'
import { setThumbBase } from './utils'
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
const DARK_SKIN = 'ink'
@@ -152,8 +153,11 @@ if (typeof window !== 'undefined' && window.matchMedia) {
export async function loadSite() {
try {
const data = await publicApi.site()
Object.assign(site, data)
const resp = await publicApi.site()
// 响应结构:{ settings: {…}, uploads_public_base }
const { uploads_public_base, settings } = resp || {}
setThumbBase(uploads_public_base)
Object.assign(site, settings)
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
// ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI
+4 -3
View File
@@ -1,6 +1,7 @@
<script setup>
import { RouterLink } from 'vue-router'
import { openLightbox } from '../../lightbox'
import { thumbURL, thumbifyHtml } from '../../utils'
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
@@ -35,7 +36,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<!-- 长文:卡片 -->
<RouterLink v-if="p.kind === 'long'" class="card" :to="`/post/${encodeURIComponent(p.slug)}`">
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
<img :src="thumbURL(p.cover_url, 640)" alt="" loading="lazy">
</div>
<div class="body">
<h2>{{ p.title }}</h2>
@@ -54,7 +55,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<!-- 短文:纸条,正文直接铺开 -->
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="body" v-html="p.content_html" />
<div class="body" v-html="thumbifyHtml(p.content_html)" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览(不跳详情) -->
<div
v-if="p.images && p.images.length"
@@ -64,7 +65,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<img
v-for="(u, i) in p.images.slice(0, 4)"
:key="i"
:src="u"
:src="thumbURL(u, p.images.length === 1 ? 720 : 480)"
alt=""
loading="lazy"
decoding="async"
+2 -1
View File
@@ -4,6 +4,7 @@ import YohakuHero from './YohakuHero.vue'
import YohakuToolbar from './YohakuToolbar.vue'
import YohakuFeed from './YohakuFeed.vue'
import YohakuPager from './YohakuPager.vue'
import { thumbURL } from '../../utils'
// 首页装配。对应设计稿 viewHome() 的 DOM 顺序:
// hero → wrap(toolbar + feed + empty + pager) → showcase(作品前三件 + 查看所有)。
@@ -81,7 +82,7 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
to="/projects"
>
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
<img :src="thumbURL(p.cover_url, 480)" alt="" loading="lazy">
<span v-if="p.status" class="st">{{ p.status }}</span>
</div>
<div class="body">
+34
View File
@@ -6,6 +6,40 @@ export function sanitizeHtml(html) {
return DOMPurify.sanitize(html || '')
}
// ---------- 缩略图 ----------
// 时间线只展示小图:把我们存储上的图片直链改写为 /uploads/thumb/{key}?w=,
// 后端懒生成并永久缓存。识别规则:以 uploads_public_base 开头的直链,
// 或同源的 /uploads/ 相对路径(本地存储兜底)。外链(图床/头像)原样返回。
// base 由 site.js 拉到站点设置后经 setThumbBase 注入(不走 import 避免循环依赖)。
const IMG_EXT = /\.(jpe?g|png|gif)([?#].*)?$/i
let thumbBase = ''
export function setThumbBase(base) {
thumbBase = (base || '').replace(/\/+$/, '')
}
export function thumbURL(u, w = 480) {
if (!u || typeof u !== 'string') return u
let key = ''
if (thumbBase && u.startsWith(thumbBase + '/')) key = u.slice(thumbBase.length + 1)
else if (u.startsWith('/uploads/') && !u.startsWith('/uploads/thumb/')) key = u.slice('/uploads/'.length)
else return u
if (!IMG_EXT.test(key)) return u
return '/uploads/thumb/' + key + '?w=' + w
}
// 时间线正文 HTML(短文全文 / 长文 600 字符截断)里的 <img> 换缩略图 + 补懒加载。
// 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签,
// 正则只匹配完整的 <img ...src="..."> ,坏尾巴交给 sanitize/浏览器容错。
export function thumbifyHtml(html, w = 480) {
if (!html) return html
return html.replace(/<img\b[^>]*?>/gi, (tag) => {
let out = tag.replace(/\bsrc="([^"]*)"/i, (m, src) => 'src="' + thumbURL(src, w) + '"')
if (!/\bloading=/i.test(out)) out = out.replace(/^<img/i, '<img loading="lazy"')
return out
})
}
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',