92 Commits
Author SHA1 Message Date
mole404 e5e925c579 fix(delete): 删完把卡片从 DragSelect 的两个集合里摘掉
症状(老师报的):选中图片删除后顶部选中状态不重置 —— 操作栏不收回,
之后任何一次重画又会显示「已选择 N 张图片」,再选一张时计数虚高(实测 1 张显示成 2 张)。

根因:删除成功那支只做 $(item).remove(),没把卡片从 DragSelect 的 SelectedSet /
SelectableSet 里摘掉。DS 的 getSelection() 就是 `return SelectedSet.elements`,
全库没有一处 isConnected 判断 → 已删卡片成为「幽灵选中项」;而顶部标题与操作栏
完全由 bindOperates() 按 getSelection() 渲染(挂在 elementselect/elementunselect 上),
所以之后任何一次重画都会带着幽灵重画。

修法(实测两个候选,取更干净的 A):
  ds.removeSelectables(selected);   // 从可选集合摘掉
  ds.removeSelection(selected);     // 从选中集合摘掉(发 Selected:removed → 自动重画)
  bindOperates();                   // 兜底,顶部统一回「未选中」

验收(真 jQuery + 真 ds.min.js + 逐字抽出的本文件代码,jsdom):
  删完 → 标题「我的图片」、可见操作项只剩 [refresh]、getSelection()=0、getSelectables()=2;
  重画后仍正确;再选一张显示「已选择 1 张图片」。

同步:Dockerfile 两处 blade md5(自证段 + .code-revision 生成段)重钉为
d9ffacb2bbd8905703c279965a07ddbf;patches/ios-longpress.patch 重生成(并把此前记错的
「上游原文件 md5」更正为 22c896eb7322ec2ff37d5eddd7ca0dec)。
2026-10-04 14:39:00 +08:00
mole404 0c24398130 fix(viewer): 桌面端看图器开关不再横向重排 —— html{scrollbar-gutter:stable}
症状(仅桌面端):图库里有滚动条;点开图片后滚动条消失,页面重新排版(内容右移约 8~9px);
关闭看图器滚动条回来,又跳一次。

真机实测的依据(老师给的 console 数字):
  · 图库时滚动条宽 = 9px(Win11 细滚动条)
  · 打开看图器后 body 补的 paddingRight = 0px   ← 库完全没补上
根因:viewerjs 的 scrollbarWidth 只在 new Viewer() 那一刻量一次(render.js initBody),
那时文档还没溢出、压根没有滚动条,于是量到 0 —— 之后 paddingRight 恒为 0,永不更新。
截图像素级取样也印证:内容右边界从 ~1262 移到 ~1270,与 9px 滚动条吻合。

修法(老师选定的方案①,只动一行 CSS):给 html 预留滚动条位置
  html { scrollbar-gutter: stable; }
滚动条即使因 .viewer-open{overflow:hidden} 消失,那 9px 也仍被预留 ⇒ 布局宽度不变
⇒ 无从重排。此法与「库量到多少」无关,不依赖那个恒为 0 的值。

回归:9 套守卫测试全绿。
2026-10-04 02:45:59 +08:00
mole404 ac428c2e6b fix(scroll): 闸门只留「重置窗口」——撤掉会把真实滑到底一起堵死的手指闸门
上一版加了两道闸:「最近 700ms 内有 touchmove/wheel 才算用户驱动」+「滚动停止后复查」。
守卫测试立刻拦下(infinite-scroll-bottom-mobile / click-delegate / destroy 三套全红,失败项
正是「到底了但不触发加载」),与老师真机上报的「滑到底不自动加载」完全一致 —— 说明方向错
了,不是参数没调好。

结论:A 方案(关滚动锚定 + 关刷新位置恢复 + 重置前先回顶部)已经把「无脑滚到底」治住,
额外的「谁在驱动滚动」闸门是多余复杂度,且会误杀真实滚动(滑到底那一刻可能刚处在窗口边缘,
当场被拦后又不再有 scroll 事件 ⇒ 永远不加载)。

本版:只保留 resetImages 开的 800ms「重置窗口」这一道闸,其余一律放行。
(另:上一稿用字符串切片改坏过 blade 的 script 块,被语法自检当场拦下、未推 —— 已改为
精确整段替换,并把「改完先跑 script 块语法自检」固定进流程。)
2026-10-04 02:18:33 +08:00
mole404 e63b5ddb53 fix(scroll): 治「换排序/刷新后无脑滚到底」(安卓 Edge)
页面侧(images.blade.php):
  · 关掉浏览器滚动锚定:html, body, #images-scroll, 哨兵 { overflow-anchor: none }
  · history.scrollRestoration = 'manual',刷新不再被拽回原位置
  · resetImages 里「先 window.scrollTo(0,0) 再清空」——清空后高度塌缩会把滚动位置夹到浅处,
    再叠加重排补滚就是连锁的起点
  · resetImages 开一个 800ms 重置窗口,这期间忽略滚动触发的加载

无限加载侧(app.js onScroll):
  · 闸门 1:距上次「用户真实向下滚动」(scrollY 增加 >4px)不足 150ms 不放行 ——
    浏览器为维持滚动位置自动补的那一滚不更新时间戳,所以补滚最多带出已有那一页,不连锁
  · 闸门 2:重置窗口内不放行

注:Claude 的分析里漏了 refresh(params) → load(params, true) 这条 —— force 会无视
「正在加载/已到底」直接发请求,是连锁能成立的另一半。本次两道闸门把它一并管住。
回归:9 套测试全绿(infinite-scroll-bottom-mobile / click-delegate / destroy 等)。
2026-10-04 02:02:00 +08:00
mole404 0caef9cac0 fix(viewer): 用 CSS 把切图过渡的起点钉在屏幕中心(保留动画,去掉造成忽有忽无的关过渡补丁)
老师反馈两点:① 反向切换(图都已加载完)时仍有时带过渡、有时不带;② 想要保留过渡
动画,"直接闪出来"效果差。复核后定位到两条:
  · 上一版的 1 秒兜底定时器:慢图(>1s 才加载完)会在库读 options.transition 之前先被
    恢复,于是过渡又生效 —— 这是"有时有动画"的来源之一。
  · 上一版还有一把锁:只有上一次已恢复(saved === null)时才去关过渡。若 1 秒内连续切
    (或 viewed 未到),这一次就不关 ⇒ 又带过渡。两条叠加就表现为"忽有忽无"。
既然老师要动画,方向改为"保留库自带的过渡,把起点钉死":

  库每切一张新建 <img>,刚插入时用默认 CSS(满宽、贴顶);加载完成后才写内联样式:先写成
  "屏幕中心的一个点",再写最终尺寸,靠 CSS 过渡补间。过渡起点 = 浏览器上一次渲染过的样式,
  哪一帧被渲染由缓存/网速/主线程决定 —— 渲染到"满宽贴顶"那帧就会从上方飞入。

  对策(不动库任何逻辑):用 :not([style]) 只命中"还没有内联样式"的那一瞬,把这一瞬做成
  屏幕中心的小点。库一写内联样式该规则立即失效,因此绝不影响最终显示,也不会与库打架;
  过渡仍是库自带的,所以动画完整保留。

本次移除上一版"临时关 options.transition"的整段补丁。
本机验收:9 套 jsdom 守卫测试全绿;浏览器六项全过(拖动跟手+回弹、横划 120px 切、
竖划不切、中途不翻页、捏合 351→1615、双击 1615→2751、切图后不误关);
飞入专项按 Claude 的延迟加载法执行;最终显示核对(宽度非 1px、opacity=1、已写内联样式)。
注:本机模拟环境复现不出真机那个飞入,最终以真机为准。
过程记录:本轮先被守卫拦下一次(把 CSS 误放进 <script> 块,测试会把 script 内容当 JS 跑),
已挪到 <style> 块并重跑,未影响线上。
2026-10-03 15:28:51 +08:00
mole404 5067ed817b fix(viewer): 切图取消过渡动画(Claude 方案 B),去掉真机上无效的强制回流方案
老师真机反馈:方案 A(强制回流固定动画起点)仍会从屏幕上方飞入。
  说明"提交中心点那一帧"在真机上依然不可靠(哪一帧被渲染由缓存/网速/主线程决定)。
  既然起点问题只在"有过渡"时才存在,索性取消这一下的动画。

做法:包一层 viewer.view,当目标 index 与当前不同且查看器已打开时,把
options.transition 临时置 false(load() 读到 false 就不加过渡类、回调照常执行),
在 viewed 事件里恢复,并加 1 秒兜底定时器防 viewed 未到而卡住。
必须走 options.transition 开关,不能用 CSS transition:none 去压 —— 否则 renderImage
永远等不到 transitionend、viewed 不触发、手势会被卡死。
路径覆盖:next()/prev() 内部调的都是 this.view,所以手指滑动、缩略图条、键盘、底部按钮
都会经过这里。
防御:测试环境里的 Viewer 是替身(没有 view/element),此时整体跳过。

本机验收(注入真实版本后):9 套 jsdom 守卫测试全绿;浏览器六项全过
(拖动跟手+回弹、横划 120px 切、竖划不切、中途不翻页、双指捏合 351→1615、
双击放大 1615→2751、切图后不误关);飞入专项 2/2 差值 0px。
注:本机"延迟加载"复现法测不出真机那个飞入(方案 A 在本机也是 0px),故真机为准。

过程记录:本轮先被仓库守卫拦下两次(脚本写歪留下孤立大括号;误把 context-js 的 md5
当成 blade 的),均已修正后重跑,未影响线上。
2026-10-03 15:08:36 +08:00
mole404 4085642695 fix(viewer): 切图动画起点固定为屏幕中心(强制回流),并删掉上一版无效的摘类补丁
老师反馈上一版没修好(附真机截图)。按 Claude 的静态分析复核后确认,根因是 view() 的时序竞态:
  · view() 每次都新建一个 <img>:先写成"屏幕中心的点"(宽高 0 + margin 居中),读自然尺寸后
    再加 viewer-transition 写最终尺寸,靠 CSS 过渡补间。
  · CSS 过渡的起点 = 浏览器"上一次真正渲染过的样式"。第 1 步默认样式是"满宽贴顶",第 2 步是
    "中心点",第 3 步是最终值。哪一帧被渲染过决定动画从哪开始:第 2、3 步之间有帧 ⇒ 从中心
    长出来;只渲染到第 1 步 ⇒ 从屏幕上方飞入;一帧都没渲染 ⇒ 无动画直接出现。取决于图片是否
    缓存、网络快慢、主线程是否空闲 —— 这就是"没规律"的来源。
  · 上一版在 pointerup 里 viewer.image.classList.remove('viewer-transition') 无效:view() 新建
    的是另一个 <img>,且 load() 会按 options.transition 给新图重新加类。已删除该段及错误注释
    (原注释"从上一张图 margin 滑过来"也不成立)。

修法(Claude 方案 A):包一层 viewer.initImage,在它读自然尺寸之前强制回流一次
(void viewer.image.offsetWidth),把"中心点"提交为已渲染样式 ⇒ 起点恒为屏幕中心。
覆盖手指滑动、缩略图条、键盘、底部按钮等所有切图路径(都在 view() 内)。

本机实测(延迟图片响应 + 限速,模拟"未缓存的大图",即 Claude 给的复现法):
  切换瞬间 top 与稳定后 top 的差值:A 组 0px / 0px,C 组重跑 0px / 0px ✓
  对照组(剥掉补丁)未取得有效数据(生成对照版本时脚本把页面切坏),"无补丁会飞"留待真机确认。
本机 9 套 jsdom 测试全绿。
2026-10-03 14:49:22 +08:00
mole404 1e5bac3c17 fix(viewer): 切图不再从偏移位置"飞入"(切的那一瞬摘掉过渡类)
老师反馈上一版的 viewer.reset() 没治到根(附了瞬间截图)。读源码确认:
  · reset() = this.imageData = initialImageData; renderImage() —— 它自己也是带动画的,
    所以"先 reset 再切"只是把过渡的起点从"被平移的偏移量"换成"复位中间态",照样滑。
  · 库的过渡本质是"从上一张图的 marginTop/marginLeft 滑到新图的 margin"(renderImage 直接
    写 width/height/margin,靠 CSS transition 补间)。两张图高度不同(横图↔竖图)时,
    新图就会从偏上/偏下的位置滑进来 —— 这就是"有时正常、有时从上面飞"的根因,与拖动无关。

修法:切图这一瞬先摘掉过渡类 viewer-transition,再 next()/prev() ⇒ 新图直接出现在它该在
的位置(观感即"从中间出来")。该过渡类是库自己在管理的(action 为 move/zoom 时它自己也会
摘掉),不属于那个碰不得的 action 状态机;renderImage 同一帧会按 options.transition 恢复它,
因此捏合/双击/平移不受影响。
同时去掉上一版那个无效的 viewer.reset()(它反而制造了另一个中间态)。

本机实测(390x844 + CDP 真触摸):
  【回归④切图不飞入】切换瞬间 top=-1582 → 稳定后 top=-1582,差 0px ✓
  ③ 切图后不退出查看 ✓   ① 捏合 351→1615 ✓   ② 双击 1615→2751 ✓
  拖动跟手/回弹 ✓   横划 20px·竖划 80px 不切 ✓   横划 120px 切 ✓   中途不翻页 ✓

本机 9 套 jsdom 测试全绿。
2026-10-03 14:13:07 +08:00
mole404 1564547216 fix(viewer): 切图前复位平移(不再从偏上位置飞入);切图后不再误关查看器
老师真机反馈两条,均已定位并修复:

① 切图时有时从靠上的位置"飞"出来。
   原因:库用 marginTop/marginLeft 定位,拖动时图片已被平移过,松手直接切图 ⇒ 新图从
   被平移的偏移量开始做 transition(手指带纵向位移时最明显)。修法:切图前先 viewer.reset()
   把平移复位,再从居中开始过渡。

② 手指在图片外空白处结束并抬起时,"切图成功"与"退出查看"同时发生。
   原因:抬手后浏览器补一发 click 落在背景上,库据此关闭查看器。修法:只吞掉紧跟切图之后
   400ms 内、且落点不在图片上的那发背景 click —— 正常"点背景关闭"不受影响。
   (历史上曾用 900ms 一刀切地吞 click,副作用是滑动后真双击失效,已废弃;本版条件精确得多。)

本机实测(390x844 手机模拟 + CDP 真触摸),六项全绿:
  未放大可拖动 left 20→-20→回弹 20 ✓   横划 20px/竖划 80px 不切 ✓   横划 120px 切 ✓
  中途不翻页 ✓   双指捏合 351→1615 ✓   双击放大 1615→2751 ✓
  【回归③】从图片内拖到图片外空白处抬起 ⇒ 查看器仍在 ✓(修复前会被关掉)

注:回归③的探针最初写错(用 offsetParent 判断可见性,固定定位元素恒为 null ⇒ 假阴性),
已改为 display/visibility。

本机 9 套 jsdom 测试全绿。
2026-10-03 14:01:56 +08:00
mole404 a3f5c82287 feat(viewer): 关掉库自带触摸切图,改由松手判定 —— 未放大也能拖动、不再一碰就切
采用 Claude 提出的方案(第二步),替换此前所有"阈值接管"尝试。

为什么之前的尝试都不行:
  · 第 1 版在 pointerup 后 view() 弹回 —— 拖动中途库早已按 1px 判据翻页,事后补救无效。
  · 第 2 版从外面改写 viewer.action —— 库的捏合/双击/过渡全靠这个状态机,外部改写导
    致双指捏合失效、拖动带过渡延迟、切图错乱(老师真机实测四条全中)。
  · 第 3 版在 document 捕获阶段 stopPropagation 拦 pointermove —— 库用
    HAS_POINTER_EVENT ? 'pointermove' : 'touchmove' 选通道,真机可能走 touchmove,
    拦截整段失效;且监听器注册在库之后,同一节点同一阶段抢不到前面。真机实测无效。

本次做法(读源码 + 真机特征共同指向的最小改动):
  · new Viewer(...) 加 slideOnTouch: false —— 单指动作永远是 'move',库自带的平移就是
    跟手拖动;不再产生 'switch' 动作 ⇒ 1px 判据与 others.js 的 'switched' 闩锁都不存在。
  · 松手时自己判定:够远(> max(56px, 视口宽×0.18))或够快(> 0.5px/ms 且 > 30px),
    且横向占优(|dx| > |dy|*1.5)⇒ viewer.next(false)/prev(false);否则 viewer.reset()
    带动画回弹。放大态或第二指(捏合)出现时一律放权给库。
  · 全程不写库的内部状态,也不拦事件 ⇒ 不依赖 pointer/touch 通道,捏合/双击/过渡不受影响。

本机实测(390x844 手机模拟 + CDP 真触摸)全绿:
  【未放大可拖动】按下 left=20 → 中途 left=-20 → 松手回弹 left=20 ⇒ 跟手 ✓ 回弹 ✓
  横划 20px / 竖划 80px → 不切 ✓    横划 120px → 切 ✓    拖到 30px 中途 → 不翻页 ✓
  双指捏合 w 351→1615 ✓            双击放大 w 1615→2751 ✓
  注:放大态下本方案不接管切图(一律放权给库做平移),与上一版"放大后 120px 也切"不同。

本机 9 套 jsdom 测试全绿。
2026-10-03 13:28:21 +08:00
mole404 f535c8c6fb fix(viewer): 切图阈值改为"只拦事件、不碰库状态"(上一版改写 action 把捏合等全弄崩了)
上一版的做法是从外面改写 viewer.action。事后真机验证发现这是根本性错误:
库的捏合、双击、移动过渡全都依赖它自己的 action 状态机(handlers.js:367-375 在
pointerdown 一次性定值;指针移动时若 action 为 move 会摘掉过渡类 CLASS_TRANSITION)。
外部改写它 ⇒ ①双指捏合被覆盖失效 ②拖动带上 CSS 过渡延迟 ③切图行为一并错乱。
老师实测四条全中。本版彻底换方向,并已回退到 49ef183a 期间线上一律跑稳定版。

新做法(零改写):
  · 单指触摸、且位移尚未到阈值时,在捕获阶段 e.stopPropagation(),让库收不到这次
    移动 —— 它就不会切图。库的内部状态自始至终不碰。
  · 一旦划够阈值(未放大 60px、放大后 120px,且 |dx| > |dy|*1.2)立即放行,之后
    全部交给库原样处理(跟手切图本来就是它自带的行为)。
  · 多指(捏合)全程不干预:只要同时按下的手指数 > 1,本层立刻退出。
  · 非画布区域(缩略图条等)一律放行。

本机实测(390x844 手机模拟 + CDP 真触摸,含两项回归):
  捏合      w 351 → 1615 ✓         双击放大  w 1615 → 2751 ✓
  横划 20px / 竖划 80px → 不切 ✓    横划 120px → 切 ✓
  拖到 30px 中途 → 不提前翻页 ✓     库状态零改写(源码里仅存松闩那一处历史用法)✓
  「未放大也能拖动」本轮未做(风险最高,留第二轮):实测拖动时图片不动,属预期。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 13:04:42 +08:00
mole404 a1b5ff298d fix(viewer): 用"规范动作值"驱动切图/拖动,未放大也能拖动
上一版的两个问题一起修:

1) 切图失效(上一版把库的内部状态读回来又还回去)。库的 change() 一进 SWITCH 分支就把
   this.action 置成 'switched'(others.js:217,即"闩"),而这个值在外层 switch 里没有 case;
   把读到的值还回去,等于让库什么都不做 ⇒ 够格也不切图。实测确认:横划 120px 由"切了"
   变成"没切"。改为直接写两个确定的字面量(handlers.js:367-375 里库自己的取值就是
   'move' / 'switch'):
     未放大且划够 ⇒ 'switch'(库跟手切图)
     其余(未划够 / 已放大未够格)⇒ 'move'(库只平移)
   不再读取库的内部 action,避免再被闩住的值带偏。

2) 老师新需求:未放大时也要能拖动图片。未划够时给库 'move',于是 1:1 状态下拖动就是平移,
   而"稍动就切图"依然不会发生(划够 60px 且明显横向才变 'switch')。
   阈值维持老师定的:未放大 60px、放大后 120px,竖直方向一律不切。

验收脚本同时修了一处自身缺陷:原来按"面积最大的 img"取对象,图片被平移后会取错对象,
改为直接取 .viewer-canvas img。

本机实测(390x844 手机模拟 + CDP 真触摸)全绿:
  拖到 30px 中途 → 未翻页 ✓        松手后 → 未切 ✓
  横划 20px / 竖划 80px → 没切 ✓    横划 120px → 切了 ✓
  【未放大可拖动】按下 left=20 → 中途 left=-3 → 松手 left=-20 ⇒ 跟着手走 ✓,且未切图 ✓
  放大后两条:无头环境里"放大"这个动作仍没成功,两条实际跑在未放大状态,无有效数据;
  放大后的 120px 阈值待老师真机定手感。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 12:50:22 +08:00
mole404 71657705e3 fix(viewer): 切图阈值改为"过程中就不动作"(上一版只在松手后弹回,翻页那一下用户已看见)
老师反馈:上版"有变化但仍极度灵敏"。诊断:两个原因都占,主因是**库的判定在拖动过程中就已生效**——
上一版只在 pointerup 时判定够不够格、不够就 view() 弹回,但拖动中途库的 1px 阈值
(others.js change():absoluteOffsetX > 1)早就把图翻过去了,事后弹回补救不了观感。

本次改法:拖动过程中、尚未划够阈值时,直接把 viewer.action 设成 false ——
库的 pointermove 第一行就是 `if (!this.viewed || !action) return;`,于是它整段移动都不处理,
根本不会发生"先翻过去再弹回来"。一旦划够阈值,就把库自己算出的动作值(在第一次 move 时
记下的 savedAction)原样还回去,从那一刻开始跟手。
阈值也按老师要求钝化:未放大 40 → 60,放大后 80 → 120。
"弹回"保留为兜底(正常路径不会走到)。

本机实测(390x844 手机模拟 + CDP 真触摸),这次专门量"拖动中途":
  拖到 30px 中途   → 大图未变 ✓(上一版的破绽,本次治住)
  松手后           → 未切 ✓
  横划 20px / 竖划 80px → 没切 ✓(方向判据生效)
  横划 120px       → 切了 ✓
  放大后两条:无头环境里"放大"这个动作没成功(工具栏与滚轮都试过),两条实际跑在未放大状态,
  未取得有效数据 —— 放大后的 120px 阈值待老师真机定手感。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 12:43:01 +08:00
mole404 49ef183a8e feat(viewer): 手机端切图阈值接管(老师定的三条规则)—— 不再"稍动就切"
老师反馈:手机端切图手势过于灵敏(滑短短一下就切),想拖动基本都误触;而且"放大后能拖、
缩小后一拖就切图",阈值区间摸不透。

根源(源码核对):库把切图阈值硬编码成 1px(others.js 的 change():
absoluteOffsetX > 1 && > |offsetY|),而且"这一下是切图还是拖动"在 pointerdown 就按
"图片有没有超出屏幕"定死了 ⇒ 未放大时几乎必然判成切图、放大后又必然判成拖动 ——
规则本身不自洽,所以摸不透。

老师定的三条规则(实现如下,全部挂在外面,不改 viewer.min.js):
  · 未放大:横划跟手(库自带的行为保留),松手时 |dx| ≥ 40px 且 |dx| > |dy|*1.2 才切图,
    否则把已切过去的那张 view() 回来 —— 观感就是弹回原位;
  · 放大后:拖动看图照旧(库的 ACTION_MOVE),横划 ≥ 80px 才由本层补一次 view();
  · 竖直滑永远不切图(方向判据同时生效)。
判定用 pointer 事件(与库同一套),范围限定 .viewer-canvas 内,不碰缩略图条。

本机实测(390x844 手机模拟,CDP 真触摸):
  未放大 横划 20px  → 没切 ✓(期望不切)
  未放大 竖划 80px  → 没切 ✓(方向判据生效)
  未放大 横划 120px → 切了 ✓
  放大后 50px/140px 两条:无头环境里"放大"这个动作没成功(工具栏点击与滚轮都试过),
  所以那两条实际跑在未放大状态,未取得有效数据。放大后的 80px 阈值待老师真机确认手感。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 12:31:50 +08:00
mole404 58e24766fa fix(viewer): 关掉 focus(真因:view() 聚焦缩略图 li 把 overflow:hidden 的导航条滚走)+ 滚轮监听器改 passive
真因(Claude 定位 + 真 Chromium 对照验证,我核对源码确认):
  view() 摆放缩略图条之前会先对目标缩略图的 <li> 调 .focus()(源码 s.focus&&h.focus()),
  而 .viewer-navbar 虽然 overflow:hidden,仍是滚动容器 ⇒ 浏览器为了"让焦点可见"把导航条滚走
  (scrollLeft 推到最大);随后 renderList() 再叠一层 translateX ⇒ 位置被偏移两次,整条列表
  跑到屏幕外,只剩空黑底。
  表现:从靠前的图跳到很靠后的图(或关掉再打开靠后的图)后,缩略图条里靠后那批不显示、
  靠前的还在 —— 因为靠前那批本来就在初始可视区、不触发滚动。与"图是不是后加载的"无关,只与位置有关。
  真机对照:桌面跳到第 93 张 navScrollLeft 2439 / 可见缩略图 0 → 加 focus:false 后 0 / 43;
  手机 2573/0 → 0/15;关闭后重开第 100 张 2909/0 → 0/15。
  代价:关掉"打开后聚焦容器 + 焦点陷阱",对鼠标/触屏无影响,仅纯键盘 Tab 导航有差别
  (键盘切图与 Esc 关闭实测不受影响)。
  保留上一版的 update() 包装 —— Claude 确认它修的是另一个真实存在的问题(重建列表后位置没重算)。

顺带的清理(回答老师"砍偏的地方有没有影响"):
  · 拦 touch / 关捏合 / 吞补发 click 三刀早已回退或删除,代码里没有残留 ✓
  · 画布 touch-action:none 与 touchmove 的 passive 保留(前者是标准做法且不碰缩略图条,
    后者比原来更正确)✓
  · 唯一还带隐患的是滚轮那个非 passive 监听器:与 touchmove 那个坑同源
    (document 上非 passive 会让浏览器丢掉"不等 JS 就绘制"的快路径)。
    它的 preventDefault 其实是多余的 —— stopPropagation 就足以拦住控件的滚轮缩放
    (控件是在它自己的监听器里做的)。故一并改成 passive。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 11:58:10 +08:00
mole404 50d4d034ae fix(viewer): 包一层 update(),让缩略图条重建后补算位置(Claude 定位 / 已核对产物)
现象:看图时底部缩略图条偶尔空白;等多久都不出来,但手指一碰/一滑它立刻显示;
安卓和电脑端都有(所以不是之前猜的"安卓绘制快路径",那条成因已被证伪)。

机制(Claude 定位,我在源码与线上压缩产物里逐条核对过):
  viewer.update() 会把 .viewer-list 的 width 设成 auto、重建所有 <li>,但当前图没变时
  不调 renderList(),于是位置(width + translateX((容器宽-30)/2 - 31*index))仍是旧值。
  两者错配 ⇒ 列表盒子沉在屏幕左侧外面,只剩一条空黑底。手指一滑走 goTo→view→renderList,
  位置被重算,缩略图立刻回来 —— 与老师"一碰就出来"完全吻合。
  看图时触发 update() 的路径:images.blade.php 里无限滚动每次请求结束的 complete 回调
  (看图时后台正好加载完一批),以及侧栏折叠。所以是"偶尔"。

本机 A/B 实测(副本站,1280x720,第 35 张,80 个缩略图):
  A 组(线上那版):打开后量到 width=auto + translateX(-460px) —— 正是那个错配状态,量到了 ✓
  但没有复现将整条推出可视区(该尺寸/该张数下缩略图仍可见)⇒ 没敢声称"一定治好"。
  已如实说明。"一碰就好"这条证据与机制吻合,且本改动只把状态修对、不会变坏。

实现(不依赖可能被压缩改名的成员:产物里查不到 items / isShown,故改成查 DOM):
  · 包一层 viewer.update:跑完补一次 renderList(),并按 index 补回丢失的 li.viewer-active。
  · 守卫:update/renderList 不是函数就安静退出;整个补算包在 try/catch 里 ——
    补这一刀绝不能反过来把看图界面弄挂。
  · 注意这是 fork 侧自证:Dockerfile 钉的 images.blade.php md5 必须同步。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 11:07:55 +08:00
mole404 73bf20143b fix(viewer): 修上一版引入的回归——缩略图条在安卓上偶尔空白(非 passive 的 document touchmove)
老师报的现象:安卓上看图时,底部缩略图条偶尔不显示;**等多久都不会自己出来,
但手指一碰它立刻显示**,像被什么卡住了;大图始终正常;且「这个版本加了滚动才开始有」。

定位:这就是典型的「不重绘」。上一版为了让缩略图条支持拖动切图,在 document 上注册了
一个 **非 passive 的 touchmove**(还带了 preventDefault)。在 document 上注册非 passive 的
touchmove 会让 Chrome 失去「不等 JS 就滚动/绘制」的快路径 —— 表现为远离手指的元素
(屏幕底部的缩略图条)不重绘:一直空白,直到下一次触摸才画出来。安卓上尤其明显;
大图在屏幕中间所以看起来正常。全中。

修法:把它改成 **passive: true** 并去掉 preventDefault ——
缩略图条本来就是 touch-action:none、浏览器根本不会滚它,所以 preventDefault 是多余的,
去掉它对功能毫无影响(拖动切图逻辑一行没动)。注释里写明了"这里必须 passive,不许改回去"。

本机 9 套 jsdom 测试 409 项全绿(含拖动切图的行为用例)。
2026-10-03 10:45:11 +08:00
mole404 1eb6a54053 feat(viewer): 底部缩略图条上的快捷切图(电脑滚轮切一张 / 手机拖动连续切)
老师的两个诉求:
  · 电脑:光标在底部缩略图条上滚轮 ⇒ 快速前后切图
  · 手机:在缩略图条上左右拖动 ⇒ 连续滑动切换(不是"划一下才切一张")

实现(全部挂在外面,不改 viewer.min.js):
  · 滚轮:document 捕获阶段接住 —— 控件自己在外层容器上绑了"滚轮缩放"(也是捕获阶段),
    所以必须比它更早,并且只对落在 .viewer-navbar 上的滚轮生效:图上滚轮照旧是缩放。
    节流 260ms,滚一格切一张。stopPropagation 阻止它被当成缩放。
  · 拖动:tracker 在触摸起手记下起点与当时的 index,touchmove 里按"已滑过距离 / 40px"
    连续 follower —— 拖回来也会切回去,是连续跟随而不是一次性触发。
    另:缩略图条被库设成 touch-action:none、本来就划不动,所以不存在"抢走它自己的滚动"
    这个问题(它是靠自动跟随当前图工作的)。
  · 一律调对外 API viewer.view(index),到头即止(loop 已关)。

本机验收(模拟环境,1280x720,40 张缩略图):
  ① 缩略图条上滚一格 → 切到下一张 ✓
  ② 大图上滚一格 → 只有缩放、不切图 ✓
  ③ 缩略图条上往左拖 120px → 连续切过 3 张 ✓
  ④ 再往右拖回 80px → 能切回来 ✓
  无 JS 报错 ✓

顺带修的守卫冲突:
  · 注释里写了"调公开 API"撞上项目铁律「公开/私有 口径一个字的残留都不许有(含注释)」,
    改成"对外 API"。
  · tag-feature 的守卫 `!/viewer\.view\(/` 原意是"不许劫持卡片点击",收窄为
    "卡片点击附近不许出现 viewer.view(",并写明理由(新功能挂在缩略图条上,不碰卡片)。
  jsdom 9 套 409 项全绿。
2026-10-03 10:16:05 +08:00
mole404 f1fc0fd639 fix(viewer): 关掉到头绕回(loop:false)+ 收口双击判定(300ms 窗口、拖动不算轻点)
一、到头绕回(老师明确不需要循环)
   打开第一张图后向右滑"上一张",库会绕回最后一张;而这一下会让控件重建画布/缩略图条,
   随之出现两个现象:iOS 上整个看图界面左偏、右侧空出一条;安卓上底部缩略图全空(只剩框)。
   采用 loop:false 从源头去掉这个动作;若后续仍观察到错位/缩略图异常,再按"重建那一步"单独查。

二、双击判定的收口(老师反馈:连续拖动两下也被当成双击放大)
   库的「模拟双击」(handlers.js:424-451)只看两次抬手的间隔(硬编码 500ms,且改不了),
   完全不检查中间有没有拖动。在外面收口(不改 viewer.min.js):
     · 抬手时用「起点→终点」位移判断是轻点还是拖动,阈值 30px ——
       依据是实测:真滑动 130~300px(中位 214px),轻点抖动一般 <15px,30px 把两者分开;
       (老师提醒过 10px 太严,会误伤真双击,故取 30px。)
     · 是拖动 ⇒ 立刻清掉库的 imageClicked,拖动永远不能充当"第一次轻点";
       若这一下之前已有"第一次轻点",那发 50ms 后要派发的合成 dblclick 也一并拦掉。
     · 两次都不动的轻点 ⇒ 真双击,放行;并按老师要求把有效窗口从 500ms 收到 300ms。
   仍用 touchstart/touchend 的坐标差(不用 touchmove —— 安卓上它可能收不到)。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 02:13:47 +08:00
mole404 8eda125810 chore(viewer): 撤掉临时诊断(面板 + 打点)与「吞补发 click」那段无效补丁
问题已由 5f36036 的「松闩」修复解决,随之清理两处临时/无效代码:

1. 删除 `?dbg=1` 诊断面板与远程打点(整块临时代码,共 6.5KB)——
   定位结束后按约定撤掉,线上恢复干净。
2. 删除「吞掉滑动手势后 900ms 内的 click/dblclick」那段。
   它针对的是「凭空 dblclick 触发放大」这条**错误判断**(真因是 others.js 的闩锁),
   而且它有副作用:滑完图 900ms 内真双击会失效,与「保留双击放大」的诉求相冲突。
   真机上也从未验证过它有效,故一并移除。

保留:
- 5f36036 的松闩修复(真因修复,已在 jsdom 复现脚本上验证对比)。
- common.less 里给 .viewer-canvas / img 补的 `touch-action: none`
  (全屏看图控件的标准做法,零副作用,顺带避免浏览器中途收走手势)。
- viewer.min.js 始终未改动。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 01:45:47 +08:00
mole404 5f3603658b fix(viewer): 松开关闭切图手势的「闩锁」——这解释了安卓上「纹丝不动 / 大多数切图失败」
真因(真机打点 518 条 + 源码逐行核对 + jsdom 复现三方对齐):

  node_modules/viewerjs/src/js/others.js:216-223  change() 里

      case ACTION_SWITCH: {
        this.action = 'switched';                                   // ← 先上闩
        const absoluteOffsetX = Math.abs(offsetX);
        if (absoluteOffsetX > 1 && absoluteOffsetX > Math.abs(offsetY)) {
          … this.view(index);                                       // 过了判定才真切图
        }

  第一次 pointermove 进来就把 this.action 改成字符串 'switched',而外层 switch 没有这个分支
  ⇒ 之后每一次 pointermove 全部落空:不切图、不平移、不放大 = 用户看到的「纹丝不动」。
  判定只看开头那次采样,而真手指刚落下天然带纵向抖动(dy ≥ dx 很常见)⇒ 大多数手势一开始
  就被闩死,偶尔开头偏横向才成功 = 「大多数失败、少数成功、无规律」。iOS 上因为触摸路径不同
  不触发,官方演示页也没有列表页那套前置条件,所以都正常。

修法(不改 viewer.min.js —— 压缩产物改了没法维护):从外面把闩松开。
手势期间用 document 捕获阶段监听 pointermove,发现 viewer.action 变成了那个闩值,
就恢复成手势开始时 Viewer 自己算出的动作值;后续采样继续参与方向判定(判据看的是最近一次
位移,所以横滑终究会切到)。首次遇到闩值时自动学出「切图动作」的真值,无需硬编码常量。

验证(用 Claude 提供的 jsdom 复现脚本,加/不加本补丁对比):
  修复前:starts diagonal dy>dx, then horizontal → no switch        ✗
  修复后:starts diagonal dy>dx, then horizontal → SWITCHED         ✓
  对照组:vertical drag (should NOT switch)      → 仍 no switch     ✓(未被破坏)
  其余 7 个用例修复前后都正常。jsdom 9 套 409 项全绿。
2026-10-03 01:34:09 +08:00
mole404 36245c583b fix(viewer): 安卓切图失败的真因修复——看图控件自己"模拟双击",用抬手坐标差把它拦住
真机打点(420 条,会话 palh0x,老师只做了点开图片 + 左右滑动)暴露了真因:
  类型计数 pointerdown 102 / touchstart 102 / pointerup 102 / touchend 88 / click 3 / **dblclick 23**
  —— 老师没做过任何双击,却凭空出现 23 次 dblclick(且 click 只有 3 次,说明不是"两次单击拼出来的",
     而是被合成派发的)。

源头在库自身:`viewerjs/src/js/handlers.js:424-451`
    // Emulate click and double click in touch devices to support backdrop and image zooming (#210).
    if (IS_TOUCH_DEVICE && action !== ACTION_ZOOM && pointer && (Date.now() - pointer.timeStamp < 500)) {
      if (options.toggleOnDblclick && this.viewed && event.target === this.image) {
        if (this.imageClicked) { …dispatchEvent(this.image, EVENT_DBLCLICK, {…}) }   // ← 凭空派发 dblclick
        else { this.imageClicked = true; … }
即:**两次落在图片上的抬手间隔 <500ms**,它就自己派发一个 dblclick ⇒ 触发放大 ⇒ 图片不再
"刚好一屏" ⇒ isSwitchable 为假 ⇒ 滑动变成平移 ⇒ 大多数切图失败。老师连续快速滑图正好满足。

上一版(3ea41b9)为何没拦住:它靠 touchmove 判断手指挥动过,但安卓上控件在指针处理里
preventDefault 之后 touchmove 可能根本收不到 ⇒ moved 永远为 false。
本次改为用 **touchstart / touchend 的坐标差**(真机打点里这两种事件 100% 都有,已逐条核对),
位移 >10px 即认定是滑图,吞掉其后 900ms 内落进看图区域的 click/dblclick;
真正的双击是两下不动的轻点,不受影响 —— 老师要求保留的双击放大照旧可用。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-03 00:29:21 +08:00
mole404 3c05072fd8 debug(viewer): 诊断面板改用「远程打点」——数据直接落进服务器访问日志,并自动停在最新一行
老师反馈:面板设了 pointer-events:none 之后自己也不能滚动了,看不到最下面。改为更聪明的方案:
  1. 页面把每条事件打点回站点(fetch 到 /dbg-<会话号>/<序号>/<记录>)——请求会落进服务器
     访问日志,阿罗娜在远程直接读日志即可拿到全部数据,老师不需要截图、不需要滚动。
     打点只在 ?dbg=1 时发生(整块诊断代码都不带参数时不加载)。
  2. 面板加自动滚动,始终停在最新一行。

本机验证:带 ?dbg=1 打开并操作后,在服务器日志里读到了完整打点,含会话号与序号,例如
  dbg-2tuzt1/1/pointerdown img ptrId=2/touch @195,247
  dbg-2tuzt1/2/touchstart  img touchId=0 @195,247
  dbg-2tuzt1/5/click       img ptrId=2/touch @195,247
共 10 条。不带 ?dbg=1 时面板与打点都不出现。
2026-10-03 00:17:38 +08:00
mole404 3ea41b940d fix(viewer): 安卓触摸手势修复——吞掉"滑完图之后"补发的 click,画布补 touch-action:none
真机诊断数据(老师安卓 + ?dbg=1 面板)确认了两件事:
  1. 计数里 dblclick×5,且 .viewer-move 上出现 dblclick ——「本来要双击才放大,点一下就放大」
     被数据证实:看图控件只绑 pointer 事件,安卓在触摸抬手后还会补发一发 click,
     它和紧接着的轻点挨得近,被浏览器拼成 dblclick ⇒ 误触发放大。
     iOS 上 fork 的长按补丁正好吞掉 iOS 抬手那发 click,所以只有安卓坏。
  2. touchstart×35 但 touchend×31、pointerdown×35 但 pointerup×31、pointercancel×4
     —— 有几次手势被浏览器中途收走(部分来自诊断面板自身滚动,面板已改 pointer-events:none)。

改动:
  a) images.blade.php:只吞掉「手指挥动过(位移 >10px)」之后紧跟的那发 click/dblclick。
     真正的双击是两下不动的轻点,不受影响 —— 老师要求保留双击放大。
  b) common.less:给 .viewer-canvas 与其 img 补 touch-action:none(库只在 .viewer-container
     上声明了,画布层仍是 auto)⇒ 浏览器不把手指判成滚动,不再发 pointercancel。
  c) 诊断面板加 pointer-events:none,避免它自身滚动产生干扰。

本机验收(安卓 UA 模拟,重建 CSS 后):
  单击不放大 ✓;双击仍放大(351 → 3050)✓;画布 touch-action=none ✓;无 JS 报错 ✓;
  100/160/260px 三条滑动全部切图 ✓;两指捏合仍可用(351 → 1193)✓;jsdom 9 套 409 项全绿 ✓。
注:本机模拟仍复现不出该 bug,最终效果以安卓真机为准。
2026-10-03 00:05:51 +08:00
mole404 57714aa09a fix(debug): 诊断面板去掉 hover:none 字样,避免撞上「触摸端媒体查询已删」的守卫测试
images-modal.test.mjs 里有一条守卫:blade 代码(去掉注释后)不得再出现 `hover: none`
字符串(为了让触摸端操作按钮常显、不留无用/矛盾规则)。诊断面板里那句
`matchMedia('(hover: none)')` 正好命中该断言,导致 CI 的 jsdom 测试失败、镜像未提升。
面板本来就是临时诊断,去掉这一项即可(ontouchstart / maxTouchPoints / pointer:coarse 仍在)。

本机 9 套 jsdom 测试 409 项全绿。
2026-10-02 23:43:35 +08:00
mole404 1ca7f96ad7 debug(viewer): 临时诊断读数面板(?dbg=1),用于定位安卓看大图手势问题
背景:安卓(Edge/Chrome)看大图时切图/放大/拖动都不稳,iOS 正常,官方演示页在老师安卓机上
也正常;本机 Chromium 模拟安卓复现不出(滑动/单击/双击/捏合/事件计数/pointers 全正常)。
老师补充的关键现象:「出问题的时候,本来要双击才放大,点一下图片就能放大」——像输入被重复投递。
既然本机复现不了,改为取真机数据。

做法:仅在网址带 ?dbg=1 时,在页面顶部显示一块读数面板(26vh,避免遮挡图片),实时显示:
  · 环境:UA 尾部、ontouchstart、maxTouchPoints、hover:none、pointer:coarse
  · viewer 容器个数、大图尺寸 / 容器尺寸
  · 最近 20 条事件:capture 阶段的事件名 + 目标 + touchId/pointerId + 坐标;
    每条都跟一行 bubble 阶段的**最终 defaultPrevented**
  · 事件计数(touchstart/pointerdown/click/dblclick…)
点面板清空;不带 ?dbg=1 时完全不加载(已实测:不带时无面板 ✓,带时出现 ✓,计数实时更新 ✓)。

注意:这是**临时**代码,定位完即删除。
2026-10-02 23:39:23 +08:00
mole404 cc150e2f95 revert(viewer): 回退安卓触摸手势相关的两轮改动,恢复捏合缩放
前两轮都未获真机验证有效:
  05604dc 捕获阶段拦 touch* —— 实测 Viewer 只绑 pointer 事件,是空拦;且会挡住
          context-js 菜单守卫的触摸(点空白关菜单失效),属回归
  53edb1f 安卓关掉 zoomOnTouch —— 老师实测「还是不行」,与捏合无关
老师要求把捏合恢复,故把 images.blade.php / Dockerfile / patches/ios-longpress.patch
一并还原到 af7c730(老师验收「修得很完美」的那一版)。
md5 回到 27e13507a629aa3ae9f21496a6cf0ebf,zoomOnTouch 已移除(默认 true,捏合恢复)。

看大图手势的真因仍未定位。老师补充的关键现象:出问题时「单击一下图片就放大」(本该双击)
——即输入像被重复投递;但本机 Chromium 模拟安卓仍复现不出:滑动/单击/双击/捏合均正常,
一次单击只产生 1 个 click、双击是 2 click + 1 dblclick,只有一个 .viewer-container 实例。
下一步改为在页面加临时诊断读数,取真机数据定位。
2026-10-02 23:35:57 +08:00
mole404 53edb1ffb3 fix(viewer): 安卓上关掉「触摸捏合缩放」,绕开"单指被当成两指"的手势误判
现象:iOS 手机端手势完全正常;安卓(Edge/Chrome)看大图时切图极难触发、放大与拖动都不稳。
用户观感「单指被判定成双指」,与 Viewer 内部 `Object.keys(pointers).length > 1` 触发
ACTION_ZOOM 的表现一致。

已有的排查结论(都实测过):
  · 官方演示页在老师安卓机上正常 ⇒ 差异在我们自己的页面/环境,不是库本身在安卓上不能用
  · 本机 Chromium 模拟安卓复现不出来:两引擎、真手指速度、三种距离、hover:none、触摸+指针
    全量探针、touch-action 计算值链、pointercancel、页面滚动 —— 全部正常
  · 页面注册的 pointer 监听只有 Viewer 自己的(另一个是 Playwright 测试框架注入的)
  · Viewer 在触摸环境下绑的是 pointer 事件(实测 .viewer-canvas 上 pointerdown,
    document 上 pointermove/pointerup/pointercancel)
故不做无法验证的底层修补,改为**中和**这条误判路径:

    zoomOnTouch: !/Android/i.test(navigator.userAgent)

安卓上单指只可能被解释成平移/切图(放大仍可用工具栏 ± 与双击),iOS 行为完全不变。

同时撤掉上一版在捕获阶段拦 touch* 的代码:实测 Viewer 只绑 pointer 事件,那段是空拦;
且它会挡住 context-js 菜单守卫的触摸(点空白关菜单会失效),属回归,故删除。

本机验收(安卓 UA 模拟):100/160/260px 三条滑动全部正常切图 ✓;捏合缩放按设计在安卓关闭 ✓。
真机效果仍需老师在安卓上确认。
2026-10-02 23:24:13 +08:00
mole404 05604dcff2 fix(viewer): 安卓 Chromium 上看图控件触摸判定错乱 —— 拦掉重复的 touch* 事件流
现象:iOS 手机端手势完全正常,安卓(Edge/Chrome)看大图时切图极难触发、放大与拖动也常出问题。

真因(真 Chromium 实测的事件计数与 id):一次单指滑动同时收到 pointerdown ×1 与 touchstart ×1,
两者用的是**不同的 id 体系**(pointerdown: pointerId=5 / touchstart: touch.identifier=0)。
Viewer 的 pointerdown 里:

    if (options.zoomOnTouch && options.zoomable && Object.keys(pointers).length > 1) action = ACTION_ZOOM;

于是同一根手指被记成两个键 ⇒ 被判定成「两根手指捏合」⇒ 动作误派成 ZOOM,切图/平移都不对。
iOS 上两套 id 恰好同值(WebKit 的 pointerId 就是 touch identifier)⇒ 只有一个键 ⇒ 一切正常,
这正解释了为什么只有安卓坏。

修法:在看图容器那层用**捕获阶段**把 touch* 停掉,只留 pointer 一套(捏合缩放照旧走 pointer 路径)。
只影响 .viewer-container 内部,页面其它地方的触摸不受影响。

验收(本机模拟):一刀切图仍正常 ✓;两指捏合仍可用(图片宽 351 → 1193)✓;jsdom 9 套件全绿 ✓。
注:本机模拟复现不出该 bug(模拟器不是真安卓),但上面的事件计数与 id 差异是实测的,
实际效果请老师在安卓真机上确认。
2026-10-02 23:02:38 +08:00
mole404 af7c730edc fix(viewer): 用反向 zoom 根治看图控件在桌面 110% 缩放下的偏移
上一版只在「点开」这条路径上用 JS 重算 margin:键盘 ←→ 切图又偏回来,图片适配尺寸也偏大、
会盖住下方导航条 —— 逐条路径打补丁的方向是错的。

真因:<html>{zoom:1.1} 让 Viewer 内部「以为的 1px」只有屏幕上的 1/1.1。它按视觉尺寸
(window.innerWidth=1280)算居中/适配/边界,写出来的却是布局值(空间只有 1164),
渲染时浏览器又乘 1.1 ⇒ 居中偏、适配偏大、边界回收也错。

修法:在外壳上把缩放缩回去(common.less 的 html .viewer-container { zoom: calc(1/1.1) }),
让 Viewer 的子坐标系与屏幕 1:1,它自己的数学就全对;blade 里上一版加的 JS 补丁整块删除。
只在 @media (min-width:768px) 内,手机端零影响。

真机验收(生产库只读副本 + 一次性容器 + Chromium,1280×577):
- 图片视觉尺寸 = 布局尺寸(625.2 vs 625)⇒ 坐标系 1:1
- 画布高 577 = 屏幕高(原来 525)
- 左右留白差 +0.0(两张横图两张竖图都验过);按键盘 → 切图后仍 +0.0(上一版这里会偏)
- 图片底 455 / 导航条顶 525 → 留 70px,不再被下方按钮/缩略图压住
- 竖直:上 24 = 到工具栏间隙 24,对称(Viewer 主动给底部 UI 留位)
- jsdom 9 套件全绿
2026-10-02 22:34:10 +08:00
mole404 197a5f0cd6 fix(viewer): 桌面 110% 缩放下看图控件整体右偏 + 遮罩盖不满屏幕
真因(真 Chromium 实测,1280×577 / html{zoom:1.1}):
- 布局空间只有屏幕的 1/1.1(1280 屏 → 布局 1164)。Viewer 按「视觉屏幕宽 1280」算居中,
  算出的 margin-left 被当【布局值】写进内联样式 ⇒ 渲染时又 ×1.1 ⇒ 图片整体右偏
  (1280 − 1280/1.1)/2 ≈ 58 布局 px(渲染 64px)。实测左右留白 360.1 / 232.1(差 +128)。
- 看图外壳(.viewer-container,fixed + inset:0)在 zoom 下只铺到屏幕的 90.9%
  (1163.6×524.5),右侧/底部约 116×52px 的遮罩盖不住。

修法:
- blade:点开图后约 4s 内持续按【布局单位】重算大图 margin(幂等;z=1 的手机端直接跳过;
  带「整图适配」护栏,不干扰用户自己的放大/平移)。不用 Viewer 的事件 API —— 内置的
  viewer.min.js 是压缩产物、实例上没有可用的 .on()(用了会让整段脚本报错)。
- common.less:外壳改 width/height:100%(实测渲染出来正好铺满屏幕;不能用 100vw,
  zoom 下它会渲染成 1408 即溢出 10%)。

真机验收(生产库只读副本 + 一次性容器 + Chromium):
- 左右留白 296.4 / 295.8(差 +0.6)✓  上下 51.5 / 51.3(差 +0.2)✓
- 容器与遮罩 = 1280×577 = 正好一屏 ✓
- 手机档 390 宽:z=1,代码自动跳过,左右 19.5/19.5(差 0.0,与原样一致)✓
- jsdom 9 套件 409 项全绿 ✓  无 JS 报错 ✓
2026-10-02 21:17:39 +08:00
mole404 c242bfb12c fix(tags): 代码审查发现的 6 条问题全修 + 补真机/静态断言
审查(含新加的打标部分)发现并修掉:
1. 改名/删除标签会清空选中 ✗ —— refreshAfterTagChange() 走 setTags() → resetImages(),
   会清空图片墙并 ds.clearSelection(),把用户正在打标的这批图连同弹窗勾选态一起丢掉
   (老师问过的「编辑/删除会整页刷新」)。改为 patchCardsTag() 就地同步(卡片角标 + data-json
   + 详情卡 chip);只有「被删的标签正用作筛选项」这种结果集真的变了的情况才调 setTags()。
2. 删图片/删用户不清 image_tag / tags ✗ —— 两个迁移的注释都写着「关联行由应用代码显式删除」,
   图片这条路径没落实,残留行会无声累积。UserService::deleteImages() 补 detach();
   后台删用户时一并清标签与关联行。(本机 SQLite 未启用 PRAGMA foreign_keys,不能指望级联。)
   附带 tools/check-tag-integrity.php:只读自检残留行,退出码非 0 即为异常。
3. 标签名进 data-json(单引号属性)未转义 ✗ —— 名字里带 ' 就能提前闭合属性(自伤型 XSS 面)。
   三处注入(列表渲染 / 弹窗行 / syncCardTags)统一先 escapeHtml 再写。
4. 弹窗里几个 axios 调用没有 catch ✗ —— 网络错误或 422(名字超 64 字)会静默无反应。
   统一走 apiErrMsg() 取后端 message;三个标签名输入框补 maxlength=64。
5. 后端 trim() 在校验之后 ✗ —— 只输入空格会建出空名标签。TagRequest 加 prepareForValidation()。
6. 两处硬编码路径 ✗(action="/user/tags/__id__"、axios.delete('/user/tags/'+id))→ 改用 route()。

测试:
- jsdom 118/118(新增 7 条:改名的局部同步、data-json 转义、maxlength+catch、
  deleteImages/删用户的清理、TagRequest trim;测试台补 renderTagRoutes 渲染带 id 的路由)。
- 真机 70/70(新增 #2c:改标签名后选中不丢 / 图片墙不重载 / 角标就地换名,
  6 项断言,在整轮末尾单开 context 跑,避免本机 1vCPU/965MB 下的页面 OOM)。
- 真机实证:图片挂 1 条关联 → deleteImages → 残留 0;check-tag-integrity.php 干净 ✓。
- tools/diff-vs-upstream.sh:已知偏离清单补登 UserService.php / Admin\UserController.php,
  校验通过(103 项,恰好等于实际偏差)。
- patches/ios-longpress.patch 按原范围(3 个文件)重新生成。
2026-10-02 14:59:40 +08:00
mole404 64dc93be5b fix(images): 框选判定改由原始指针坐标驱动 —— 修掉"框住上一行、下一整行被选中"
真因(实测):页面是「<html>{zoom:1.1} + 整页滚动」。DragSelect 内部把**布局单位**的滚动量
混进了**视觉**指针坐标,于是它算出的判定框 = 真实框 ×1.1 + 随滚动增大的偏移
(滚动 670 时约 (28,−55);误差 ≈ scroll×(1−1/zoom),所以"不滚动就不偏")。
它的 zoom 选项只影响尺寸、不修指针坐标 —— 喂 zoom:1.1 也没用(实测照旧)。
结果:框住上面那行、下面一整行却被选中,框越大偏得越离谱。

修法:
- clientX/clientY 与 getBoundingClientRect() 同属视觉坐标 —— 按原始指针路径自己记框,
  ① 覆盖 ds.Selector.rect 给库判定用 ② 每帧把屏幕上的框摆到同一处
  (外壳 .ds-selector-area 按区域矩形铺满,框是它的子元素、坐标必须相对区域原点)。
- 指针事件必须注册在**捕获阶段**:库在 mousedown 冒泡阶段就做判定,晚注册的话
  它那一刻读到"还没开始的空框" → 单击卡片不勾选(实测 0 张)。
- 删掉上一版的"手工除以 zoom 再写回"补偿。那是第二个补偿,与库自己的算法打架;
  更糟的是它的 rect 覆盖读的是库刚写进元素的矩形 —— 等于把错误读两遍,
  验收因此一直是绿的(这次把验收也改成真正的判据,见下)。

保留:图片墙排完版后 ds.Interaction.init() 重测 + 清掉库的缓存矩形("拖不动"的老根因)、
      原生拖拽抑制(-webkit-user-drag + dragstart)。

验收(这次不再自我循环):
- 真机 64/64。新增 #2b「诚实框选」在滚动 0/240/670 各验一遍:
  ① 画出来的框 == 库拿去判定的框 ② 鼠标坐标与页面 clientX/Y 同一套(证明测试本身可信)
  ③ 逐张比对"几何上该不该选中"与实际选中,必须完全一致。
  —— 上一版就是缺 ③,才让"假绿"混过去。
- jsdom 111/111。
2026-10-02 14:08:10 +08:00
mole404 bab875aeda fix(images): 缩放倍数改用「布局÷视觉」比值(修老师环境里极大的拖选偏移)+ 撤回过度下压
1. 老师环境里拖选偏移极大(框住上一行、下一整行被选中,误差半个行高以上),我这边量不出来 →
   锁定为**浏览器页面缩放**:原来 dsZoom() 读 getComputedStyle(html).zoom,浏览器自身的页面缩放
   可能被折进这个值里,导致除以一个偏大的倍数、整个盒子在页面上跑偏。
   改成用「布局尺寸 ÷ 视觉尺寸」的比值算(只由 CSS 的 html{zoom:1.1} 决定,页面缩放不影响它)。
   实测:默认比值 1.0999、注入 zoom=1.25 时比值 1.2500,两种情况下「判定框 == 画出来的框」误差都是 0.00px。
   新增 jsdom 静态断言:必须用比值、禁止再读 getComputedStyle 的 zoom。
2. 「显示图片标签」那一行:上一轮按 canvas 字体度量把文字下压了 1.5px,实为压过头
   (改成**截图像素法**量:文字比行中心低 1.49px,所以看着开关偏上)→ 撤回下压,
   现在文字/开关都在行中心 ±0.3px 内。验收脚本 #5b 也从「字体度量」改成「截图像素」量。
3. 菜单里「暂无标签」空态行 py-4 → py-2(比别的菜单行厚一倍,整块看着偏下)。

验收:jsdom 110/110(标签那份)、全仓 396;真机 61/61 全绿。
2026-10-02 12:29:44 +08:00
mole404 c1ab184b83 fix(ui): 标签开关收小 + 那一行墨迹居中 + 策略页角色组多选框加高
按老师第三轮反馈(4 条里的 1/2/4,第 3 条另查):
1. 开关收小一点点:18×32 → 16×28(滑块 14 → 12,行程 14 → 12),保持两态形状一致。
2. 「显示图片标签」那一行上下不居中:量出来文字**墨迹**中心比行中心高 1.5px(字体上下墨迹天生
   不对称),旁边放个居中的开关就显出来了 → 文字下压 1.5px,实测墨迹 239.32 vs 行 239.18(+0.14),
   开关 +0.49,两者互相对齐。
4. 存储策略编辑页的「选择角色组」多选框默认只有两行高(form-multiselect 在上游样式表里其实
   不存在,等于没给高度)→ 显式 min-h-[8rem],实测 128px(5~6 行)。

验收:jsdom 全仓 396;真机 61 条全绿(新增 4 条:墨迹居中、开关与墨迹对齐、开关尺寸、多选框高度)。
第 3 条(滚动后纵向偏移)另附排查结论:滚动 0/300/600/900 四档实测「画框 == 库判定框」误差 0px、
被选中卡片加入瞬间均与框相交,怀疑是卡片盒子比可见图片外扩 10px(justified-gallery border:10)
造成的「擦边就选中」,等老师做一个小实验确认。
2026-10-02 11:54:02 +08:00
mole404 6e3a0428f1 fix(images): 框选判定矩形接实时位置(纵向偏移)+ 自己画「显示图片标签」开关
1. 拖选纵向偏移的真因:库把选择框的判定矩形缓存成 clientWidth × zoom,在缩放文档里既比实际
   大 1.1 倍、纵向还随页面滚动跑偏(实测滚动 0 偏 (20,14)、滚动 500 偏 (18,63)),于是「框住上面
   这行」把下面一行也选了。修法:把 ds.Selector 的 rect 接到 .ds-selector 的实时矩形上 ——
   判定区与画出来的框永远重合(真机验收:滚动 0/240/500 三档,四边误差 < 3px)。
2. 「显示图片标签」开关改成自己画的(老师选的第 3 案):圆角药丸 + 滑块,两态形状完全一样,
   只变颜色与滑块位置(开=bg-brand 滑块靠右、关=bg-line 滑块靠左),不再用 FontAwesome 的
   fa-toggle-on/off(那两个字形一粗一细)。

验收:jsdom 全仓 396(标签那份 109/109);真机 57/57 全绿,其中 #4b 新增 6 条专门盯纵向偏移
(每档都比对「库判定框 vs 画出来的框」,并按「加入瞬间是否真的相交」核对每一张被选中的卡片)。
2026-10-02 03:13:20 +08:00
mole404 2b57bcbbbb fix(images): 按老师要求回滚「点图片不勾选」,并去掉标签行内的状态字样
1. 回滚第 3 条:删掉整套「单击图片不改变勾选」逻辑(捕获阶段记按下前选中 + 松手还原),
   小圆勾的 click 恢复全平台生效 —— 点图片本身照旧参与勾选(老师决定)。
   保留本轮的另一半修复:Zoom 坐标修正(html zoom:1.1 与 DragSelect 的冲突)与
   图片/卡片原生拖拽禁用(这两条是「框选起点偏右、松手不释放」的真因)。
2. 标签管理每一行不再显示任何状态字样(已有的「已有 / 移除 / 部分有」全删,DOM 里也没了),
   状态只看左边那个勾:实心勾=有、方框=无、减号=部分有、红色方框=待移除。

验收:jsdom 全仓 396(标签那份 109/109);真机 51/51 全绿(含手机 390 一段)。
2026-10-02 02:14:21 +08:00
mole404 8108cae88b fix(images): 按老师第二轮反馈修 7 条(框选坐标/点选语义/标签窗口版式/入口)
1. 框选「起点偏右、最左侧拖不动、松手不释放」的真因:common.less 给 <html> 加了 zoom:1.1,
   而 DragSelect 把 .ds-selector-area(position:fixed)与 .ds-selector 的坐标按视觉像素写进
   内联样式,浏览器再乘一次 zoom(实测区域真实 (282,120) 被当 (310,132),正好 ×1.1)。
   修法:盯这两个元素的内联样式,按 zoom 折算回布局单位再写回(数值容差比较,避免反复折算)。
   另:图片原生拖拽会吃掉 mousemove(框不跟随、松手不解锁)——卡片 <a href> 本身默认可拖拽,
   所以 -webkit-user-drag 要同时禁 .images-item 与里面的 img,并用 dragstart 兜底。
2. + 3. 单击图片不再顺手勾选(老师要求只有右上角小圆勾才勾选):捕获阶段记录按下前的选中,
   松手时若位移 < 3px 就还原成按下前那份(连盖两拍,压住库自己的「点哪个选哪个」);
   小圆勾改成幂等的集合运算;这条逻辑桌面走这里、手机仍旧走 .image-selector 的 click。
4. 标签管理窗口:条目行 overflow-hidden(行内方角背景会盖住圆角 → 蓝框左上/左下看着缺角);
   去掉勾选后的「添加」字样;副标题只留「已选择 N 张图片」;新建标签那行提到首行。
5. 「新建标签」不整页刷新 = 正常(不改图片与角标,只更新标签下拉);改名/删除会影响角标与筛选所以刷新。
6. 右上角标签下拉里的「标签管理」入口已删(老师要求)。
7. 标签按钮图标按截图实测下移 0.5px(文字墨迹中心 90.0 / 图标 89.5)。

验收:jsdom 全仓 396 → 标签那份 109/109;真机(一次性容器 + 生产库只读副本)51 条全绿,
含「从图片上起手拖动框选 8 张、框精确跟手 600×300、松手解锁、单击不改勾选、小圆勾正好 1 张」。
2026-10-02 01:40:40 +08:00
mole404 13d834506b fix(images): 框选失效真因修复 + 标签按反馈重构
按老师 5 条反馈改(全部在「我的图片」页):
1. 合并标签窗口:删掉多余的「管理标签」,新建/改名/删除并进原「修改标签」窗口,
   标题统一为「标签管理」;工具栏 / 手机菜单 / 右键菜单 / 标签下拉都只留这一个入口。
2. 两态勾选:勾上 = 给选中图片加上该标签,取消 = 移除;部分选中的图片上有该标签时
   显示半勾(「部分有」)表示现状,点一下变全勾。废弃原「不变/添加/移除」三态。
3. 右上角标签下拉新增「显示图片标签」开关(状态存 localStorage),默认开。
4. 标签按钮改 inline-flex items-center gap-1.5(py 2 收 1.5 补偿高度),
   实测图标与文字中心差值 < 0.5px、按钮高度不变。
5. 框选失效真因修复:DragSelect 给图片墙套的 .ds-selector-area 包装盒只在初始化时
   量过一次(实测缓存 59px vs 实际 1697px),图片墙长高后鼠标按下被判成「在区域之外」,
   拖动根本不启动。修法:挂 jg.complete / jg.resize,每次排完版调 ds.Interaction.init()
   让它重新测量(注意 justifiedGallery('norewind') 是空操作,不能写在它后面)。
   同时放宽 predragstart 判据:卡片带 10px 内边距、缝隙落在卡片盒子里,退回上游判据会
   连从缝隙起手也框不出来(实测 0 张);控件(小圆勾等)仍旧 break()。

真机验收(一次性容器 + 生产库只读副本,未碰生产):缝隙起手能框选、小圆勾点选正好 1 张、
单击仍开看图器、两态/半勾真写库通过、无 JS 报错。jsdom 全仓 396(标签那份 105)。
已知遗留:按在图片正中间起手时浏览器会起原生图片拖拽,选择框不跟随(只选中 1 张),
按老师要求本轮不动这条路。
2026-10-02 00:21:45 +08:00
mole404 51b6658007 feat(tags): 图库标签适配(筛选/详情/角标/批量修改/标签管理)+ 下线「图片权限」界面口径
界面侧
- 图库页:原「设置权限」按钮、权限筛选下拉、详情卡「权限」行全部下线,换成标签:
  顶部标签多选筛选(后端 AND,带「清除筛选」)、详情卡标签行(可增/可删/现场新建)、
  卡片左下角标签角标(pointer-events-none,不抢选中与预览)、批量「修改标签」
  (桌面工具栏 + 手机 ⋯ 菜单两套入口,行内 44px,添加/移除三态循环)
- 新增「标签管理」弹窗:新建 / 重命名 / 删除(删除二次确认,文案说明会从所有图片上移除);
  入口两处:标签筛选下拉底部「管理标签」、图片右键菜单「管理标签」
- 用户设置页删掉「图片默认权限」整块;后台图片页删详情「权限」行与搜索语法
  is:public/is:private 帮助行;接口文档删 permission 参数行
- 连带:UserSettingRequest 的 configs.default_permission 由 required 放宽为 nullable
  (表单不再提交该项,不放宽则保存设置直接 422;上传默认权限仍取库里已有值)
- 后台搜索后端也不再见识别 is:public / is:private(删除该 match 分支与 ImagePermission 引用)

后端
- permission 列与既有接口形状保持不动;仅 ImageController 的列表/详情序列化补 tags 字段
  (卡片角标与详情卡需要),标签相关接口沿用上一步已就绪的 GET/POST/PUT/DELETE user/tags
  与 PUT user/images/tags

测试与产物
- 新增 test/tag-feature.test.mjs(88 条:静态契约 + jsdom 行为,含筛选 AND/清除、详情增删新建、
  批量三态与 payload、标签管理增删改、右键菜单注入、文案标准化、无「公开/私有」残留)
- images-modal / swal-theme 里钉住旧「权限」口径的断言随之更新
- 重建前端产物(public/css/app.css),重钉 Dockerfile 自证 md5(routes/web.php、
  images.blade.php 与 .code-revision),重生成 patches/ios-longpress.patch
- tools/diff-vs-upstream.sh 的已知偏离清单补登记本批改动文件
2026-10-01 22:02:03 +08:00
mole404 2da63c7e9a chore(mem): MaxConnectionsPerChild 默认值改回 5(低流量下取最严内存纪律)
老师决定:他这台图床流量约 3 请求/分钟,MCP=5 意味着每 ~3 分钟才退休一个 worker,
换班开销可忽略;换班越勤,单个 worker 允许积累的内存天花板越低。故取回他原来的 5。
(中途试过 30,现按实测与他的偏好改回;env 覆盖机制 APACHE_MAX_CONNECTIONS_PER_CHILD
与 0=永不回收 的语义完全保留。)

同步改回:entrypoint.sh 默认值与旁注、build-image.yaml 6 处期望值(断言 + 自证清单 + 覆盖用例)、
Dockerfile 概览注释、docker-compose.yaml 注释。README 参数表本来就是 5,无需改。

验证:一次性容器集成跑 5 用例(默认→5、env=10→10、env=0→0、脏值 abc→回退 5 且不启动失败、
PHP_MEMORY_LIMIT 仍生效)+ apache2ctl -t = Syntax OK + memory_limit 仍 64M;
全仓 grep 无遗留的 30 期望值;Dockerfile 9 条 md5 自证 9/9。
2026-10-01 18:06:03 +08:00
mole404 d202908ecc merge master:带上桌面端右键菜单错位修复(测试版同步修复) 2026-10-01 15:40:13 +08:00
mole404 da378e56cf fix(ui): 修好桌面端右键菜单错位且超出视口(桌面 110% 缩放导致的二次缩放)
根因:6d17ea9 给桌面端加的 html{zoom:1.1} 会把菜单写入的 left/top 长度再放大一次;
而 MouseEvent.pageX/pageY、getBoundingClientRect、documentElement.clientWidth 都是
视口物理像素 → 渲染位置 = (点击坐标 - 13) × zoom,偏差 ≈ 0.1×点击坐标 - 14.3,
越靠右下偏得越多,靠近右/下边缘时被拉出视口而无法完整显示。

实测(真 Chromium 153,1440×900,菜单左上角相对点击点):
  居中 (720,450) 改前 +57.7/+58.2 → 改后 -13/+12
  右上/右下/左下/底部中央 改前均越界(menu right=1575 / bottom=983>900 等)→ 改后全部在视口内
  无缩放对照组偏差恒为 -13/+12,证明偏差就是这 0.1 倍缩放;1280×800 / 1920×1080 同规律。

修法(src/resources/js/context-js.js,同步 public/js/context-js/context-js.js,两份逐字节一致):
- zoomFactor():运行时读 getComputedStyle(documentElement).zoom,缺省 1(不写死 1.1)
- measureBox():用 getBoundingClientRect 取真实尺寸(display:none 时沿用临时显示测量)
- clampMenu()/fitSubmenu()/openMenu():所有位置与尺寸先在「元素本地单位」里换算
  (物理量一律先除以 z),保证四边都落在视口内,靠边缘时翻转到另一侧而非硬夹
- z === 1 时表达式逐字退化为原算术 → <768px 行为逐字节不变(手机端 Δ=0)

验证:3 种桌面宽度 × 5 个点击位置 = 25 次,改前 23 处失败 → 改后 0 处;
zoom 改 1.2 抽查 0 处失败;zoom 1.0 与改前逐字节相同;
滚动关闭菜单行为保留(3 种视口均验 open→scroll→closed);jsdom 290/290。
未改动 images.blade.php 与 README;patches/ios-longpress.patch 已同步再生成
(git apply 到上游快照可还原 src/ 三文件,逐字节一致)。
2026-10-01 15:40:11 +08:00
mole404 5554c28c02 feat(mem): 测试版 —— PHP/opcache 收紧 + MaxConnectionsPerChild 放宽 + CI 断言
本提交只在 test/memory-tuning 分支上,master 保持 = 线上稳定版不动。

- 先量后改:镜像里实际生效的 memory_limit 是 512M(上游 memory-limit.ini),
  不是 PHP 编译期默认的 128M。本轮改为默认 64M,并做成运行期可覆盖:
  entrypoint 新增 PHP_MEMORY_LIMIT(默认 64M),只接受 <数字>[KMG],脏值只忽略该项
  并警告、绝不启动失败,幂等;改 env 即可回 128M/256M,不用重建镜像。
- opcache 实测:配置 128M/10000;真实预热集合只用了 827 个脚本 / 25.0MB;
  全树编译上限 9949 个脚本 / 255.9MB(走不到)→ memory_consumption 收成 64(约实测 2.6 倍,
  省下 64MB 共享内存),max_accelerated_files 保持 10000,validate_timestamps=1 显式写死。
- MaxConnectionsPerChild 默认 5 → 30(减少无谓的 worker 换班),
  APACHE_MAX_CONNECTIONS_PER_CHILD 覆盖机制与 0=永不回收 的语义原样保留。
- CI:Verify published image 新增两条断言(memory_limit == 64M、mpm.conf 有
  MaxConnectionsPerChild 30),沿用先落文件再比较(不碰 pipefail + grep -q 那个坑);
  MPM 自证步骤里的期望值 5 → 30。
- 分支安全:push 触发增加 test/*(保留 master);Promote to latest 本就有
  github.ref == refs/heads/master 的条件(本轮补强注释),分支构建只产不可变 sha- 镜像;
  retag.yaml 只由 workflow_dispatch 触发且硬拒 latest 目标,无需额外守卫。
2026-10-01 12:57:09 +08:00
mole404 d77ba67d5c fix(security,deps): 认证端点节流 + 镜像信息收敛 + 两条依赖公告
认证(src/routes/auth.php):
- login / register / confirm-password 加 throttle:5,1,forgot-password 加 throttle:6,1。
  实测发现登录原本并非零防护 —— Breeze 的 LoginRequest 有按(邮箱|IP)的 5 次/60 秒
  RateLimiter(CACHE_DRIVER=file,真生效),它挡单账号撞库;路由级节流补的是
  『换邮箱撒网 / 换 IP 轮换』这个口,两者互补。
- verification.verify(6,1) 与 verification.send(3,1) 原样未动。
- 已知取舍(未改,已登记):TrustProxies 配的是 $proxies='*',节流键取自 X-Forwarded-For
  且外层 nginx 用 $proxy_add_x_forwarded_for 追加 → 客户端自带的假 XFF 会排在前面并被采信,
  即限流键可伪造。修法是把信任代理收窄到 nginx 的地址/网段。

镜像信息收敛(Dockerfile):
- 新增 /usr/local/etc/php/conf.d/zz-lsky-hardening.ini(expose_php=0 / display_errors=0),
  zz- 前缀保证不被别的 ini 覆盖;构建期用 php -r 真读回 ini_get 断言。
- 新增 /etc/apache2/conf-enabled/zz-lsky-hardening.conf(ServerTokens Prod /
  ServerSignature Off)。放 conf-enabled 而不是 vhost 模板:ServerTokens 是全局指令,
  写进 VirtualHost 会被 Apache 拒绝加载。
- 自证从 8 条加到 9 条(新增 routes/auth.php 钉住),本地实算 9/9 通过。

依赖公告(OSV 扫描到 11 条,本次清掉两条有修复版本的):
- psy/psysh v0.11.12 → v0.11.23(它在生产依赖树里、随镜像发布;Dockerfile 的
  composer.lock md5 自证同步为新值),并从 osv-scanner.toml 删掉那条白名单。
- sweetalert2 11.4.6 → 11.22.4(前端依赖,会打进浏览器包)。升级后新版默认样式
  div:where(.swal2-container) .swal2-input 与我们的皮肤权重打平并靠注入顺序取胜,
  实测把输入框高度从 38px 压成 36.75px → 把皮肤前缀从 html 提到 html:root 修掉,
  并同步 3 条断言;jsdom 290/290 全绿。
- OSV 门禁复跑:exit 0,已过滤清单里只剩 laravel 9.x 那 4 条(上游无修复版本)
  与 debugbar / phpunit 等 dev-only 项。
2026-09-30 08:00:46 +08:00
mole404 de891b4a35 chore(image): .code-revision 补 composer_lock_md5 与构建期断言
只升级 vendor 依赖(composer.lock 变了、其他文件没变)的镜像,在已有卷上不会触发代码同步;
同类的『只改前端产物』口子这轮已用 app_js_md5 等补上,这是剩下唯一一条。
标记生成端在 Dockerfile(烘焙进镜像),入口脚本只做镜像标记与卷标记的整份比较,故无需改动。
另加一条构建期 grep 断言,保证该行真的写进 /var/www/lsky/.code-revision。
2026-09-30 07:20:43 +08:00
mole404 64c03f5514 fix(image): 修正 F21 —— Debian 自带 default-ssl 仍指向已删的 snakeoil 证书,导致 Apache 起不来(安装页 000)
CI 实撞的栈:AH00526 SSLCertificateFile '/etc/ssl/certs/ssl-cert-snakeoil.pem' does not exist → Apache 整个起不来,容器进程在但安装页 000。原因是 F21 只改了 000-default.conf.template 的 HTTPS vhost,漏了 a2ensite 启用的 Debian 自带 default-ssl.conf。改法:把它也指向入口脚本运行期生成的 /etc/apache2/ssl/lsky-selfsigned.{crt,key},并加构建期护栏(按文件路径判,不按 snakeoil 这个词判 —— 该文件里的注释提到它是正常的)。顺带把 ENV 的 legacy 写法改成 key=value,消掉两条 Dockerfile lint 警告。
2026-09-30 06:24:23 +08:00
mole404 f3aa1c191c chore(image): 供应链加固 + Laravel 9.52.22 + 健康检查
F15:两个阶段的基镜像都钉 OCI digest(附取 digest 的方法)。F16:构建期两个下载源改为先落盘再校验(composer installer 用官方 installer.sig 的 sha384;install-php-extensions 钉 2.12.0 + 写死 sha256),验不过即构建失败。F19:laravel/framework 9.52.21→9.52.22(另 7 个 symfony 6.4.x 补丁级,共 8 个包,content-hash 未变)。F10:加 HEALTHCHECK。F12:.dockerignore 排除 node_modules/vendor/开发机 .env/编译产物(vendor 只排 src/vendor —— 用 **/vendor 会误伤 fontawesome 的字体文件,前端图标会全空)。
2026-09-30 06:18:56 +08:00
mole404 c5b74cca9e feat(apache): MPM 参数内置为镜像默认值(低配单用户),支持 APACHE_* 逐项覆盖
背景:镜像此前完全吃 Debian/php-apache 的出厂 MPM 值(prefork: StartServers 5 /
MinSpareServers 5 / MaxSpareServers 10 / MaxRequestWorkers 150 /
MaxConnectionsPerChild 0),低配单用户机上光是常驻 10 个空闲 PHP 子进程就已过重。

① 新增 mpm.conf.template(照 000-default.conf.template / ports.conf.template 的既有套路),
   Dockerfile COPY 进 /etc/apache2/ 并加构建期护栏(模板缺失 / 占位符被删 → 构建失败)。
② entrypoint.sh 在 exec 之前渲染到 /etc/apache2/conf-enabled/mpm.conf —— Debian 的
   apache2.conf 先 include mods-enabled 再 include conf-enabled,后者覆盖出厂 prefork 值。
   内置默认:StartServers 2 / MinSpareServers 1 / MaxSpareServers 3 /
   MaxRequestWorkers 5 / MaxConnectionsPerChild 5 / KeepAlive Off。
③ 六项各自独立覆盖(写哪个改哪个,没写的保持默认),并带校验与兜底:
   非整数 / 越下限 / 一致性不满足(MaxRequestWorkers ≥ MaxSpareServers ≥ MinSpareServers)
   → 该项退回默认值 + [lsky] 警告;KeepAlive 只认 On/Off(大小写不敏感,统一渲染成 On/Off);
   MaxConnectionsPerChild 允许 0(0 = 永不回收,合法值)。
   渲染或落盘失败只警告,绝不影响容器启动(set -eu 下全部走 if 守卫,另挡掉
   「目标路径被目录占住时 mv 静默假成功」那条实测发现的坑)。
④ APACHE_KEEPALIVE_TIMEOUT / APACHE_MAX_KEEPALIVE_REQUESTS 仅在 KeepAlive=On 且显式
   设置时才写入;未设则不写该行,跟随 Apache 发行版默认(5 秒 / 100 个请求)。
⑤ README 新增「Apache MPM」一节:默认值、变量表、脏值策略、默认态日志摘要与自查命令、
   高配参考值、以及「MaxRequestWorkers × 单进程内存 ≈ 可用内存」的估算提醒;
   docker-compose.yaml 的 environment 加注释示例(不改任何生效值)。
⑥ CI 新增真容器步骤「Verify Apache MPM defaults and overrides」:默认态与期望的 6 行
   逐行 diff;单项覆盖生效且其余仍默认;脏值退回默认且日志有警告;KeepAlive=On 时
   「不设 timeout → 两行都不出现」「设了 → 两行出现且值正确」;大小写归一化;
   并跑 apache2ctl -t 确认渲染结果不会让 Apache 报语法错。

本机验证(无 docker,镜像层由 CI 验):bash -n 通过;用抽取的入口脚本真代码跑 15 个场景
(全默认 / 单项覆盖 / 六项全脏 / KeepAlive 各形态 / 前导零 / 一致性破坏 / 目标被目录占住 /
模板缺失)全部 exit 0 且输出符合预期;Dockerfile 的 8 组 md5 自检与补丁字节校验未受影响
(未碰 patches/ 与 src/)。
2026-09-30 00:26:45 +08:00
mole404 89f9d28fbe fix(回归): infiniteScroll 销毁时误杀 window 滚动监听,图片墙不再自动加载
① app.js 的 infiniteScroll.destroy() 原来无条件执行 $(window).off('scroll.infiniteScroll'),
   而该命名空间在 window 上是全局唯一 —— 相册弹窗(容器版实例)一关闭,就把图片墙(window 版)
   的整页滚动监听一起解绑了:桌面「我的图片」滚到底不再自动加载,只能手动点哨兵文字。
   改为只有自己就是 window 版(useWindowScroll)时才解绑 window;容器版只解绑自己的 selector。
   该 bug 旧抽屉关闭时同样存在(抽屉用得少,一直没暴露)。
② 相册行名称 <span class="name"> → <div class="name">:infiniteScroll 第 133 行的委托会把
   列表容器里任意 span 当作「加载更多」,点相册名会顺带多拉一页相册(改前实测请求 2→3,
   改后 2→2)。只改模板侧,不动那个全站共用的委托选择器(改它需单独做全站回归)。
   「移动到相册」行的名称与两处 ls-badge 徽标改前已是 div,无需改动。
③ 桌面端相册弹窗宽度 420px → 520px(@media min-width:768px;手机底部抽屉不受影响)。
④ 新增回归测试 test/infinite-scroll-destroy.test.mjs(12 条,含 RED→GREEN 证据:补丁前 10/12,
   失败项正是「销毁容器版后 window 上的监听是否仍在」「销毁后整页滚到底是否仍自动加载」)。
   同步:Dockerfile 的 images_blade_md5 = cab1ae9abf815ae3ca30a63608dbf004、
   patches/ios-longpress.patch 重生成(3115 行、打回上游字节一致)、两套测试更新。
   两份 context-js.js 未改动(仍 b13fc1e4…)。
2026-09-29 23:25:07 +08:00