FlintHub 列表与正文图展示改造方案
thumb_」的说法要改口 —— 那批 thumb_ 正是列表摘要要用的档位。
0. 需要你先拍板的 5 个决策点
| 编号 | 决策点 | 选项 | 我的建议 |
|---|---|---|---|
| C-a | 列表摘要图最多显示几张?(第 5 项「多的就不显示了」) | ① 只显示 3 张(一行) ② 最多 6 张(两行) ③ 最多 9 张(3×3,微博式) | ① 3 张。理由:列表行高稳定、首屏流量可控;落库本来就最多 9 张,其余可进详情页看 |
| C-b | 摘要只有 1 张图时,是否也按「一格」大小? | ① 统一为一格(约 108×81) ② 保持现在的放大单图(180×120) | ① 统一。你说的是「统一一下尺寸」,且混排时视觉更整齐 |
| D-a | 详情页正文只有 1 张图时怎么排? | ① 整行但限宽(约 480px) ② 只占半行(与多图同宽) | ① 整行限宽。单图占半行会显得小气 |
| D-b | 正文多图是否限制张数? | ① 不限(全部按一行 2 个铺开) ② 超过 N 张折叠 / 显示「+N」 | ① 不限。正文是内容主体,不该截断 |
| A-1 | 附件区布局怎么改? | ① 拆成两个容器:图片走网格、文件走宽列表 ② 不拆,文件卡跨 2 列 | ① 拆容器。两类卡片的宽度诉求本来就不同,语义最清晰 |
1. 附件下载框太窄 布局缺陷
现状与根因
图片附件卡和非图片附件卡共用同一个网格 .att-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}。
150px 的列宽对图片卡合适,对文件卡就是灾难 —— 按实际 CSS 算一遍:
列宽 148px − padding 20 = 128px 可用 − 图标 36 + gap 10 = 82px − gap 10 + 下载按钮 62 = 10px ← 留给文件名 文件名只有约 10px 宽,实际等于完全看不见
移动端(<480px)与回复区紧凑变体 .att-card--sm 还额外把按钮文字 .att-card__dl-text 隐藏,
按钮退化成约 28px 的纯图标方块 —— 这就是「下载框太窄」的观感来源。
改法
- 拆容器:
thread/show.php两处(主帖 160 行、回复 264 行)把附件按「是否图片」分成两组 —— 图片组沿用.att-grid;非图片组改用新增的.att-list。.att-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px},移动端单列。 - 按钮定宽:
.att-card__dl{min-width:64px;justify-content:center},让每张卡的按钮等宽、不再被压缩。 - 移动端不再隐藏文字:删掉
<480px与.att-card--sm里的.att-card__dl-text{display:none}(改由宽列表保证空间),或退一步改成「图标 + 下载」竖排。 - 消除扩展名清单漂移:把
attachment_card.php里那份$imageExts提为Upload::isImageName(?string): bool,视图分组与组件判定共用同一份清单。
if,收益明显更大。
2. 列表摘要图改读缩略图 省流量
现状
四处摘要渲染点直接输出原图 URL:
| 文件 | 行号 |
|---|---|
app/Views/forum/index.php | 81(置顶区)、170(普通区) |
app/Views/home/index_default.php | 71 |
app/Views/forum/category.php | 91 |
<img src="<?php echo $this->e($imgUrl); ?>" alt="">
$imgUrl 来自 topic_index.excerpt_images(写帖时从正文抽的前 9 张 <img src>,已排除 emoji),
是原图 URL。实测 topic 88 的 4 张原图合计 1.69 MB,而它们对应的 thumb_ 合计约 200 KB —— 列表首屏差 8 倍。
关键结论:这批缩略图已经存在,不需要额外生成
实测盘点(.workbuddy-ai/tmp/p7_thumb_matrix.php):
| 用途 | 该用哪一档 | 现状 | 结论 |
|---|---|---|---|
| 列表摘要图(单格约 108px) | thumb_ 400×300 | 16 / 16 已有 | 零额外生成 ✓ |
| 详情页正文图(单格约 330–400px) | md_ 800×600 | 0 / 16 | 需补生成 15 个 |
| 附件卡图片(单格约 148px) | thumb_ 400×300 | 2 / 2 已有 | 已就绪 ✓ |
| 头像 / 封面 | 不生成 | — | — |
改法
在 TemplateCompiler 加一个助手,把「URL → 缩略图 URL」收敛成一处:
public function uploadThumbFromUrl(?string $url, string $prefix = 'thumb_'): string
{
$rel = \app\Helpers\Upload::relFromUrl($url);
return $rel === '' ? (string)$url : \app\Helpers\Upload::thumbUrl($rel, $prefix);
}四处视图统一改为 <img src="<?php echo $this->e($this->uploadThumbFromUrl($imgUrl)); ?>" …>。语义:
- 站内 uploads 图 → 输出
thumb_;缩略图不存在时自动回退原图(thumbUrl()内建),零 404 - 外链图 / 其它来源 →
relFromUrl()返回空 → 原样输出,行为不变 - 分月路径(
2026-09/xxx.png)由relFromUrl()纯字符串归一,不依赖文件存在性
3. 摘要图统一尺寸 · 每行 3 个 · 超出不显示 规范化
现状
- 列数已经是 3(
.mn-excerpt-thumbs{grid-template-columns:repeat(3,1fr);max-width:320px}),但格子尺寸不统一: 高度写死 96px,宽度随容器浮动;单图另有-single分支(max-width:180px、高 120px) - 渲染张数没有上限:
foreach ($thumbs as $imgUrl)全渲染,落库最多 9 张 → 最多铺 3 行
改法
.mn-excerpt-thumbs{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;max-width:330px}
.mn-excerpt-thumb{aspect-ratio:4/3;width:100%;height:auto;object-fit:cover;border-radius:6px;
background:var(--mn-bg-row)} /* 高度由比例决定,不再写死 96px */
/* 删除 .mn-excerpt-thumbs-single 及其单图分支 */- 四处视图的
foreach ($thumbs as $imgUrl)改为foreach (array_slice($thumbs, 0, N) as $imgUrl)(N 见决策点 C-a) - 删掉四处
count($thumbs) === 1 ? ' mn-excerpt-thumbs-single' : ''这段三元判断 - CSS 删除
.mn-excerpt-thumbs-single与.mn-excerpt-thumbs-single .mn-excerpt-thumb两条规则
4. 详情页正文图:一行 2 个 + 容器包裹 风险最高
现状(实测正文结构)
编辑器上传的图片在正文里的真实形态很规整,两种:
topic 88(多图):<p>文字…</p><div><img><img><img><img><br></div>
└ 4 张图挤在同一个 div 里,彼此之间没有任何分隔
topic 89(单图):<img><div>文字…</div>
└ 裸 img 挂在顶层
目前 Content::sanitizeHtml() 的 img 分支只做两件事:src 换 md_、
原图写进 data-fullurl,完全不动结构。所以图片是自然流排布(一张一行),既没有容器也没有网格。
改法(推荐:渲染期分组,服务端出 HTML)
在 sanitizeHtml() 现有 img 处理之后追加一个「分组」步骤:
- 收集所有已识别的正文图(
$isUpload || $isExternal) - 对每张图,向上找最近的块级祖先(
div/p/ 根节点) - 该祖先的子节点只含图片 /
<br>/ 空白,且至少 1 张图 → 给这个祖先加class="post-img-grid"(单图再加--single),并把每张 img 包一层<span> - 否则(图片混在文字里,如裸 img 挂顶层)→ 就地新建
<div>包住该图
.post-img-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0}
.post-img-grid--single{grid-template-columns:1fr;max-width:480px} /* 单图整行限宽 */
.post-img-cell{display:block;overflow:hidden;border-radius:8px;cursor:zoom-in;
background:var(--mn-bg-row-hover)}
.post-img-cell img{display:block;width:100%;height:100%;object-fit:cover}点击看高清图:不需要改 JS
lightbox.js 已有两条现成通路:① data-fullurl(净化期写入);② 兜底选择器
.post-text-content img。图片进网格后依然命中,且灯箱「全页连读」会按 DOM 顺序把 4 张图收进同一阅读序列,左右箭头直接可用。
Content 的 md_ 逻辑当初就是为此改成幂等的)。
分组必须能识别「已经分过组的结构」并跳过:判据用结构自检而非 class 名 ——
父链上存在 div.post-img-grid 且其子节点全为 cell / br / 空白,即视为已处理。
这样即使有人粘贴带同名 class 的内容也不会被误判(它本来就是图片网格)。
.workbuddy-ai/tmp/test_p5_content_img.php(含 S-1 CDATA 那一组,因为
thread/show.php 的预渲染槽位故意不转义,净化层是唯一防线);
另加「同一份正文连跑两遍净化,输出必须逐字节一致」的幂等用例。
备选(不推荐):前端 JS 分组
DOM ready 后扫描 .post-text-content img 就地包容器。改动小、不动净化层,但会首屏布局跳动(FOUC)、
无 JS 环境退化为原样,且与项目「服务端渲染」的既有约定不符。仅在净化层改动被否决时才考虑。
5. 灯箱右上角关闭按钮点击无反应 已定位根因
根因(实测确认)
容器里的关闭按钮长这样(_components/image_lightbox.php:23):
<button>
<i></i> ← $this->icon('close',20) 的输出
</button>点击落在里层的 <i> 上,于是 e.target 是这个 <i>。
而 lightbox.js:152-166 的关闭函数用 e.target 做白名单判定:
function closeLightbox(e) {
if (e && e.type === 'click' && justDragged) { justDragged = false; return; }
if (!ensure()) return;
if (e && e.target) {
var t = e.target;
var ok = (t === lb) || (t === lbImg) ||
(t.classList && (t.classList.contains('lb-close') || t.classList.contains('lb-nav')));
if (!ok) return; // ← <i> 三项都不满足 → 静默返回,什么都不做
}
…真正关闭的代码在这里,永远走不到…
}
而调用方(lightbox.js:322)明明已经用 closest() 正确找到了按钮:
var closeLink = target.closest('[data-action="close-lightbox"]');
if (closeLink) { e.preventDefault(); closeLightbox(e); return; } // ← 传了原始 event结论:按钮找对了,但传进去的 event 让函数二次自检失败 → 关闭被静默丢弃。
Esc / 点遮罩 / 点图片关闭都正常(那些路径 e.target 恰好命中白名单),所以只有「点 ×」失灵 —— 与你的现象完全吻合。
附带说明:左右箭头同样是 <button><i></i></button>,但它们不走 closeLightbox(),
所以一直是好的。这也是这个 bug 藏得住的原因。
改法
// lightbox.js 的 closeLightbox():白名单判定改用 closest()
var ok = (t === lb) || (t === lbImg) ||
(t.closest && (t.closest('.lb-close') || t.closest('.lb-nav')));
if (!ok) return;并把点击分支里的 closeLightbox(e) 改成 closeLightbox() ——
调用方已经用 closest() 判定过,无需二次自检。顺带修好 window.closeLightbox(event) 兼容出口的同样隐患。
回归
- Node DOM 桩(
node .workbuddy-ai/tmp/test_lightbox_js.js):新增「点<i>应关闭」「点箭头里的<i>应切换」 - HTTP 端到端:新增一条「触发关闭后
.image-lightbox不再带show类」
6. 顺带修正:P6 剩余待办要改口
md_,并清理仅用于正文的过时 thumb_」。
按本次实测盘点,那句话的后半段是错的。
实测:正文图 16 个,全部已有 thumb_(400×300),而 md_ 一个都没有。
那批 thumb_ 是 P3 之前的旧默认档位产物,而它恰好就是列表摘要现在要用的档位。
| 动作 | 原待办 | 修正后 |
|---|---|---|
补生成 md_ 800×600(详情页正文图用) | 要做 | 要做(15 个) |
清理正文图的 thumb_ | 要做 | 不要做 —— 摘要图正要用它 |
注:盘点时正文图里有 1 个 md_(efec1066…png),那是我上一轮测试脚本遗留的 8 字节假探针,已删除。当前 md_ 实际为 0 个。
7. 施工顺序与验收
按「独立 → 耦合 → 高风险」排序,每步单独验收后再进下一步(沿用你要求的「一步一步来,完成一步做检查」)。
| 序 | 步骤 | 改动面 | 验收 |
|---|---|---|---|
| 1 | 灯箱关闭按钮(第 4 项) | assets/js/lightbox.js 1 处 | Node 桩 + HTTP 端到端;顺带确认左右箭头未回归 |
| 2 | 摘要图改读缩略图(第 2 项) | TemplateCompiler +1 助手;4 处视图 | 断言摘要 src 指向 thumb_;无缩略图时回退原图不 404 |
| 3 | 摘要图网格规范化(第 5 项) | modern.css + 4 处视图(array_slice + 去三元) | 渲染张数 = min(N, 实际);单图与多图格子等尺寸 |
| 4 | 附件卡布局拆分(第 1 项) | Upload +1 判定;组件 1 处;thread/show.php 2 处;modern.css | 文件卡文件名可读(≥180px);图片卡不变;移动端单列 |
| 5 | 正文图分组(第 3 项) | Content::sanitizeHtml();modern.css | 重跑 test_p5_content_img.php;新增幂等用例;4 图帖渲染为 2×2 |
| 6 | 补生成正文图 md_(P6 收尾) | 新增 CLI;assets/uploads/ | 15 个 md_ 生成且尺寸恰为 800×600;原图与 thumb_ 零改动 |
| 7 | 发布(P7) | i18n(如有新键)→ FLINTHUB_VERSION → update/N/ + 增量包 | 三语字典零差异;包内结构与站点根镜像一致 |
8. 统一遵守的既有红线
- CSS 只用既有
--mn-*变量 —— 9 套主题各覆盖 103 个变量,新造变量不会被主题覆盖, 夜间主题会残留浅色默认值造成「浅底浅字」 - 改视图 / CSS / 数据后必须清页面静态缓存(
data/runtime/pages/*.html→PageCache::invalidate()) - i18n 三语同步:
lang/{zh,en,zh_tw}.php当前各 1332 键、键序一致;本轮若新增文案键须三文件同改 - 编码:UTF-8 无 BOM + LF(
modern.css、lightbox.js、4 个视图均在 LF 组内) - 图标:新增
$this->icon()名称前先跑.workbuddy-ai/tmp/check_glyphs.py <hex>核查字形 - 关键改动后必须回读验证:本机 Edit 偶发「报成功但未落盘」(伴
EBUSY)