27 Commits
Author SHA1 Message Date
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 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 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 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 32859cc5cb fix(frontend): 收窄 infiniteScroll 点击判据 + 顶栏折叠按钮贴左(含产物重建)
F14:click 委托判据从「容器内任意 span」收窄到插件自己插入的哨兵专属类(closest 保留子元素与 .disabled 语义),绑定/解绑改用 click.infiniteScroll 命名空间 —— destroy() 不再摘掉同一容器上别人的委托(图片页还有一条 .image-selector)。header:折叠按钮绝对定位贴内容区左缘(桌面 292→256/折叠 100→64/手机 20→0),点击区 36→44,标题视觉 x 全程 Δ0。产物已真重建(npm run prod,Node 18):public/ 下只有 public/js/app.js 变(c4678319→7459c100),钉住的两个补丁产物字节未变。jsdom 288 条全绿。
2026-09-30 06:18:56 +08:00
mole404 3893e58069 fix(移动端): 图片墙滑到底第一下不自动加载 —— 改用哨兵相对可视视口判断
现象:手机端「我的图片」滑到最底部,第一次不触发自动加载,再往下拖一下才加载(桌面正常)。

根因:window 滚动分支用 `window.innerHeight + scrollTop >= $(document).height() - 30` 判定到底。
移动浏览器(安卓/Edge)的可滚动高度按「地址栏收起后的大视口」算,而 innerHeight 报的是
「地址栏可见时的小视口」,两者差 50~60px > 30px 余量 → 真滑到底那一发条件仍不成立;
再拖一下使地址栏收起、innerHeight 变大,条件才刚好成立。

修法:window 分支改用哨兵(本方法自己插在列表末尾的 .infinite-scroll)相对可视视口的
位置判断 `getBoundingClientRect().top <= window.innerHeight + offset` —— rect 是对当前可视
视口量的,天然免疫大小视口不一致;顺带把触发点提前到"哨兵进入视口"(体验更好)。
找不到哨兵时退回原公式(别把自己搞死),offset 默认值仍为 30 未动。
容器滚动分支(两个弹窗的列表)原封不动 —— 容器内滚动没有地址栏问题,且那两个弹窗里哨兵是
display:none,用 rect 判断会失效。

新增回归测试 test/infinite-scroll-bottom-mobile.test.mjs(13 条),含 RED→GREEN 证据:
打补丁前 10/13,失败项正是「★到底了自动加载」(gets=1,即到底了没触发);补丁后 13/13。
反向用例(哨兵离得远不触发)与容器分支用例两次都 PASS,证明测的是真实差异。

全仓库仅图片墙一处使用 root:'window'(images.blade.php:406),改动只惠及它。
两份 context-js.js 未改动(仍 b13fc1e4…);补丁重生成后与上游字节一致。
2026-09-29 23:42:41 +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
mole404 776e374ee0 fix(UI): 相册弹窗删掉「完成」按钮 + 相册行改为分区结构(44px 常显操作按钮)
① 删除底部「完成」按钮:markup(含整层包裹 div)、事件绑定、相关注释一并清掉;
   列表成为弹窗唯一主体,max-h/滚动不变(顺手多出约 52px 垂直空间)。
② 相册行改「左侧切换区 + 右侧操作区」:
   - 编辑/删除由裸 span(可点区≈13.5px 图标)改为 44×44 真按钮,所有设备常显、带 aria-label
   - 操作按钮移出 <a>(根因:点不中图标就命中行链接 → 跳进相册);data-id/data-json 同时
     挂在外层 .albums-row 与 <a> 上,编辑/删除从按钮往上找 row 取 id
   - 取消「张数 ↔ 按钮」的 hover 互换(行内容不再抽动);张数仍是 <div>(编辑路径依赖)
   - 当前相册高亮改打在外层 row(边框包住两个按钮)
   - 选择器同步:ALBUM_ITEM → ALBUM_ROW、'>a' → '> .albums-row';切换委托改为 .albums-item,
     仍在 #albums-container 上委托,追加页/重拉/清空都不受影响
   - 删掉上轮为「触摸常显」加的 @media (hover:none) 规则(按钮本就常显)
③ 新增 4 个 Tailwind 类(w-11 / items-stretch / border-l / rounded-l-lg)→ 已重跑前端构建,
   并逐个确认已出现在 public/css/app.css(否则按钮会退回图标大小)
④ 同步:Dockerfile 的 images_blade_md5 = c2014bb78cec8e2e9bfe6e3572eda26b、
   patches/ios-longpress.patch 重生成(3108 行、打回上游字节一致)、两套测试同步。
   两份 context-js.js 未改动(仍 b13fc1e4…)。
2026-09-29 23:04:34 +08:00
mole404 69efaca5db feat(UI): 相册切换从右侧抽屉彻底重做为弹窗 + 侧栏高度改用 dvh
① 相册切换(彻底重做,不是搬运):
   - 新增 #album-switch-modal(复用 <x-modal>,手机端随组件变成底部抽屉,
     自带 100dvh 与 overscroll-contain,滚动链问题不会复发)
   - 内容:16px/600 标题「相册」+ 新增本地搜索框(按名称即时过滤,不打接口)
     + 列表(每行 ≥44px 点击区、左名称右图片数、当前相册高亮 + 「当前」徽标、
     点一行立即切换)+ 底部「完成」
   - 补齐原来没有的状态:加载中、空状态(「还没有相册」/「没有匹配的相册」)
   - 原有能力一条不丢:创建相册(同表单同接口)、重命名/编辑、删除、相册图片数、
     无限滚动(每页 40)
   - 旧抽屉连根删除:#drawer / #drawer-mask / #drawer-title / #drawer-content 的
     markup、const drawer、以及三处 drawer.close() 逐处按语义收敛到
     openAlbums() / closeAlbums() / resetAlbums(),不留死代码
② 侧栏高度:#app-sidebar 由 h-screen(100vh) 改为 100vh→100dvh 兜底写法。
   手机地址栏可见时 100vh 高于真实可视区,侧栏底部(容量使用区)被推到可视区外,
   地址栏收起才出现。用 id 选择器写进 common.less,避免与 Tailwind 工具类的
   生成顺序打架。
③ 同步更新:Dockerfile 的 images_blade_md5、patches/ios-longpress.patch(重生成,
   打回上游字节一致)、三套断言旧抽屉 DOM 的测试。
   两份 context-js.js 未改动,md5 仍为 b13fc1e4…。
2026-09-29 22:45:06 +08:00
mole404 5773d432fb feat(细节二): 弹窗观感统一 + 三个旧弹窗换新装 + 返回键间距真修好 + 手机弹窗高度适配 + 上传全部按队列显隐补漏
①弹窗:
- 「移动到相册」弹窗:相册列表下那行「我也是有底线的~」(无限加载的哨兵)按弹窗 id 收窄隐藏,
  footer 多余的横线(border-t)去掉;列表仍可滚动、触底仍会继续加载相册。
- 两个新弹窗排版统一:标题 16px semibold、标签 13px / 值 14px、行高宽松、字段行装进一张
  divide-y 卡片等距排列;只用设计令牌色(亮/暗自动正确)。
- 右键菜单的「删除」「重命名」「设置权限」三个 sweetalert2 确认框换成同一套观感
  (12px 圆角、16px 标题、13/14px 正文、按钮 = ls-btn/ls-btn-primary/ls-btn-danger、
  输入框 = ls-input);JS 一行未改,确认/取消/校验流程原样。皮肤不用 !important,
  靠 html 前缀提权重压过 swal2 运行时注入的样式。
②「<返回」间距真因(上一轮判断错了):标记里那个 mr-1 工具类**从未被 Tailwind 生成**
  (tailwind.config.js 的 content 只扫 blade,扫不到 resources/js 里的类名),图标 margin-right
  实测 0px;且 context-js.css 在页面里没有版本串、iOS 会吃缓存。修法:规则同时写进
  context-js.less 与 common.less(后者由 layout 用 ?v=assetVersion() 引入,必然重新拉取)。
  真触摸复现实测:图标右缘→文字左缘 = 7px(修前 0px),旧缓存 CSS 情形下同样 7px。
③手机弹窗底部空一块:共用组件 modal.blade.php 的容器改 h-[100dvh]、内层保留 min-h-screen 兜底
  (CSSOM 实测 dvh 声明排在 vh 之后,支持则用 dvh、不支持自动退回 100vh);桌面端逐项实测
  与改前完全一致(容器/卡片高度、居中位置、页面可滚动高度均未变)。
④「上传全部」显隐补漏:漏在「逐张手动删除」那条路径上(走的是另一个函数),现已接上,
  共覆盖 入队/状态变化/自动清除/逐张删除/一键清空 五条路径;文案去掉「同时」二字。
  另修一处会让构建失败的坑:context-js.less 注释里写了 resources/views/**/*.blade.php,
  其中的 **/ 提前闭合了 /* */ 注释 → LESS "Unrecognised input"(已改写该句)。
验证:longpress 79/79、sidebar 6/6、images-modal 35/35、images-modal-dom 35/35、
swal-theme 48/48(新增,含 jsdom 复刻注入顺序验证皮肤真生效 + 一条反证);php -l + view:cache
exit 0;重建产物核对 .text-[16px]/.h-16/.transition-colors/.divide-y/.min-h-[100dvh]/.h-[100dvh]/
#image-movements-modal/.swal2-popup 全部存在;钉住的 context-js.js 两份 md5 仍 = b916555a…,
images.blade.php md5 已同步为 00f38dbf…,补丁重生成 2578 行且字节级验证通过。
2026-09-29 15:41:13 +08:00
mole404 30862d602f feat(图片页 1/2): 详细信息与移动到相册改用居中卡片弹窗、二级菜单返回键加空隙
#1 「详细信息」「移动到相册」弃用右侧抽屉,改复用 x-modal 的居中卡片弹窗(新增
   #image-detail-modal / #image-movements-modal 及其内容容器,三个新模板):
   - 详细信息:顶部小缩略图 + dl 行式布局(<640px 自动上下堆叠),标签 13px / 值 14px /
     leading-6;字段顺序按老师要求 = 上传时间 → 图片名称 → 其余 10 项(原抽屉里 12 项一个没删,
     只换位置);弹窗内不放复制链接/下载/删除(单一职责)。
   - 移动到相册:单选列表(当前所在相册带「当前」徽标)、行 min-h-[44px](手机点击区)、
     未选中时「移动」disabled;请求逐字沿用原实现(同 route user.images.movement、同 payload);
     点「移动」才提交,成功关弹窗 + resetImages + toastr。
   - 右侧抽屉保留本职(工具栏「相册列表」入口、自带无限加载、相册增删改查)原样不动;
     桌面右键、手机长按、工具栏三个入口都走新弹窗。
#2 二级菜单「<返回」箭头与文字间距:context-js.less 加 7px(选择器权重压过标记自带 mr-1)。

关键:本轮的 images.blade.php 改动不属于补丁文件(context-js.js 两份 md5 全程 = Dockerfile 钉住的
b916555a…,未动),Dockerfile 的 images_blade_md5 已随自证同步更新为 27bb3f20…,补丁重生成 2563 行
且「打回上游 = 仓库文件」字节一致。

验证:四套回归我自己跑过 —— longpress 79/79、sidebar 6/6、images-modal 35/35(静态契约:
12 字段与顺序 / 13-14px 字号 / min-h-44 / 请求不变 / 无硬编码色 / 返回键 7px / 补丁 md5 未变)、
images-modal-dom 35/35(jsdom 真跑页面内联脚本:点菜单 → 内容进新弹窗、抽屉全程为空且未被推开、
单选互斥、点「移动」实发 {selected,id,album_id} 后关弹窗、抽屉路径仍能筛选相册);
php -l + view:cache exit 0;重建产物含 text-[13px]/[14px]/min-h-[44px]/max-h-[50vh]/ls-badge 等。
2026-09-29 14:34:25 +08:00
mole404 6e1306d77b fix(手机/桌面体验): 头像居中、二级菜单就地展开防误触、恢复根滚动、策略卡桌面卡内滚动、小字号抬一档
① 头像在圆形容器里偏左:按钮 h-10 pl-1 pr-2 左右留白不对称(左 4px / 右 8px),
   且 <640px 用户名隐藏后只剩纯圆钮 → 改成 h-10 w-10 p-0 justify-center,
   ≥640px 用 sm:w-auto sm:pl-1 sm:pr-2 保持桌面胶囊形态不变。
   实测:手机 390px 下头像左右留白各 6.0px,偏差 0.0px(原 4/8)。

② 二级菜单重合 + 误触:真机触摸带粘滞 :hover,手指一按「复制链接」二级菜单就先弹出来
   (旧版还因侧向定位被 clamp 成压在主菜单上),抬手那发合成 click 直接落在菜单项上,
   实测被判成「Url」并立刻复制。改为:
   - 手机不再侧开二级菜单,改成**就地替换**(面板原地换成 Url/HTML/BBCode… + 顶部「返回」);
   - touchstart 即切换并「武装」防误触:判据是**同一根手指那一发**(不是时长),
     按多久(实测按住 700ms)抬手都不会误触;
   - 守卫顺序:父项处理 → 守卫 → 「返回」;桌面仍保留右侧二级菜单 + 视口钳制。
   真触摸(CDP Input.dispatchTouchEvent + CSS.forcePseudoState 模拟粘滞 hover)复现:
   改前二级菜单被挤出屏幕外 116px、改后抬手触发动作 = [](什么都没发生)。

③ 手机浏览器地址栏不会自动收起:body 有 overflow-hidden + 内容区是 absolute + overflow-y-auto
   的自滚容器,不是根滚动,浏览器只认根滚动。改成 body 去掉 overflow-hidden、容器回正常流
   (pt-14 pb-14 补顶栏高度)。fixed 侧栏/顶栏不受影响,全仓无 JS 依赖该内部滚动容器。

④ 策略卡桌面端限高 18rem + 卡内滚动(md: 前缀,手机仍是一列全展开、不滚不截断)。

⑤ 小字号统一抬一档(11/11.5→12.5、12.5/13→13.5、13.5→14,14 以上不动),
   受影响页遗留的 text-xs 一并抬到 13.5。
2026-09-29 04:49:25 +08:00
mole404 c6a02d88c2 fix(菜单/控制台): 菜单与二级菜单做视口边界钳制、控制台信息项整理、版本升到 v3.1
1) 靠屏幕边缘右键/长按,弹出菜单跑到屏幕外(老师实测手机+电脑都有),二级菜单「复制链接」
   时好时坏 —— 上游只在 above:'auto' 时做过纵向翻转,横向从来没钳制;二级菜单固定往右展开
   (left:100%),且只在 hover 路径判一次 drop-left,纵向完全没人管,所以「跑不跑」取决于菜单
   开在页面什么高度。
   改法(只动定位,显示/隐藏逻辑一行不碰 —— 长按与点击防护依赖 CSS :hover 的真实可见性):
   - 新增 clampMenu($dd):openMenu 定位完统一调用;按视口夹 top/left(主菜单是 body 下绝对定位、
     坐标是页面坐标,所以用 pageXOffset/clientWidth 换算),比视口还高时给 maxHeight + overflow-y:auto
   - 新增 fitSubmenu($li):hover 与 touch-open 两条路径都调用;每次先从「默认右开、无内联约束」
     重算,避免上次残留;左右翻不够时用内联 left 兜底,纵向用内联 top 回拉,超高转内部滚动
   - 子菜单里原先两处重复的 drop-left 判断合并成这一个函数

2) 控制台「软件信息」:删掉官方网站/使用手册两项,仓库地址改成本仓库
3) 版本号 v3.0 → v3.1(config/app.php 一行)

顺带把「手写资源版本串」这个坑根除:images.blade.php 引入补丁脚本那行的 ?v=ios-longpress4
改成 Utils::assetVersion('js/context-js/context-js.js'),由文件 mtime 自动失效 —— 之前手写
递增忘过一次,浏览器一直吃旧补丁,和上一轮 app.js 那次是同一类问题。

验证:
- 真 jQuery + 真菜单数据搭页面,在右下角右键/长按后量矩形(4 个用例:桌面右下角、桌面下滚 400px、
  手机 iOS 右下角长按、手机下滚 600px):改后 4/4 主菜单与二级菜单都完整落在视口内(留 8px 边距);
  **用改前的文件跑同一套用例,每个都报「跑出去 125~140px」** —— 证明用例真能抓到问题、也证明 bug 真实存在
- 暗色实拍:手机主菜单 + 二级菜单完整可见(二级菜单自动翻到左侧),桌面贴边不截断
- jsdom 行为测试 70/70 + 侧栏 6/6;php -l 全仓库;view:cache 全量通过;
  verify_repo.py 全通过(含从 Dockerfile 解析的 16 条护栏)
- 补丁 md5 同步:078a0d98…(两份 context-js.js)、5d1926d2…(images.blade.php),
  .code-revision 里的 md5 一并更新 → fork_sha 变化 → 容器会重新同步进卷
- patches/ios-longpress.patch 已用 tools/diff-vs-upstream.sh 重新生成
2026-09-29 02:46:20 +08:00
mole404 bc3751b394 feat: 侧栏可折叠 + 上传结果按钮不再压标签 + 文案与对齐调整
1) 上传结果行:「复制全部 / 清除」原来绝对定位盖在右上角,压住 Markdown with link /
   Thumbnail url 两个标签,且只在 hover 时出现(手机没有 hover,等于点不到)。
   改为与标签栏同一行的左右两段:标签栏 flex-1 自行横向滚动,按钮固定右侧、常显。
   (div 开闭数已核对:与原始一致,只多一层包装)

2) 侧栏品牌块去掉 sm:justify-center → 桌面端 logo + 站点名靠左(手机端本就不居中,无影响)

3) 文案:「设置」→「用户设置」(侧栏导航项、页面大标题「基础设置」、顶栏标题、头像菜单)

4) 桌面端侧栏可折叠(手机端抽屉行为完全不变):
   - 折叠状态 = <html class="sidebar-collapsed">,由 stores/sidebar.js 管理并记入 localStorage;
     app.blade.php 的首屏内联脚本在样式之前就贴类,不会「先展开再收起」地抖一下
   - 折叠宽度 4rem 只留图标;文字、分组标题、容量区整体隐藏;导航项居中;每项带 title 便于悬停辨认
   - 折叠规则全部在 @media (min-width: 640px) 内,并用选择器权重(html.sidebar-collapsed #app-sidebar)
     压过工具类 sm:w-64 / sm:ml-64 —— 不依赖打包顺序(components 层总在 utilities 之前)
   - 顶栏 ☰ 桌面端也显示:桌面=折叠/展开、手机=抽屉,同一语义;toggleSmart() 按视口分流
   - 我的图片页:图片墙按容器宽度算布局,侧栏一动必须重排 —— 监听 lsky:sidebar-toggled,
     等 300ms 宽度动画结束后 justifiedGallery('norewind') + viewer.update() + ds.setSelectables()
     (这三个 API 在翻页加载数据时本来就在用)
   - 不做「悬停临时展开」:那会让图片墙每次鼠标划过都重排

验证:
- 新增 test/sidebar.test.mjs(6 例,jsdom):默认展开 / 存过 collapsed 则加载即贴类 /
  折叠写存储并派发事件 / 展开还原 / 手机端只开抽屉不动折叠态 / 桌面端走折叠。
  与既有 70 例一起由 npm test 串行执行(CI 那步就是 npm test)
- 用真编译产物 + 真实 DOM 结构在 Chromium 里量计算样式:
  桌面折叠 → 侧栏 64px、内容左边距 64px、文字/分组/容量隐藏、图标居中偏差 0px、按钮与标签零重叠;
  桌面展开 → 256px / 256px;手机端即使贴了折叠类 → 侧栏仍 75% 抽屉、无隐藏、边距 0(确认零影响)
- php -l 全仓库通过;php artisan view:cache 全量编译通过;verify_repo.py 通过;
  context-js.js 两份仍字节一致(e114c840…,长按与点击防护未动)
2026-09-29 02:00:53 +08:00
mole404 7c3188e25a refactor(源码): 应用源码 vendored 进 src/ + 上游 pin 升级到 38d52c46(2025-11-24)
上一版是「钉死上游 commit、构建时拉 archive、再用 overlay/ 叠加补丁」。但上游已明示停止维护
(README 里 2024-12-28 起挂着警告:「开源版本已停止维护,不再进行新特性更新和 bug 修复」),
所以把源码放进本仓库:构建不再依赖 github.com/lsky-org 长期可用,之后要改 PHP 代码直接改
src/ 提交即可,不必再维护叠加机制。

1) 上游 pin:911275c(2023-05-16)→ 38d52c4609eb85236b45ac75acac2ced55174953(2025-11-24)。
   中间 32 个提交只动了 9 个文件,**数据库迁移 0 处**,所以升级不碰站点数据(SQLite 结构不变)。
   吃进三个真实修复:图片格式转换时不同图片共用同一个临时文件(并发上传会变成同一张图,
   默认配置即触发)、加水印后丢质量参数、SVG 支持;另有 laravel 9.52.4→9.52.17、
   phpseclib 3.0.19→3.0.43、commonmark 2.3.9→2.6.0、aws-sdk-php 等一串依赖安全升级。

2) vendored 源码:src/ = 上游 38d52c46 的完整树(304 个文件、8.1MB,按 commit 精确复制,
   文件数与上游 git 树一致)。我们的三个补丁文件就地打上补丁,取消 overlay/。

3) Dockerfile:去掉 curl 下载 archive 那一层,改成 COPY src/;自证拆成两组 ——
   「自证 1 上游纯净性」ImageService.php / convention.php / public/index.php / routes/web.php /
   composer.lock 必须等于上游 md5;「自证 2 补丁产物」三个补丁文件 md5 + 关键标记 grep。
   快照被误改、补丁丢了,构建期就拦下。

4) 新增维护脚本:tools/diff-vs-upstream.sh(拉上游快照做全树对比 + 白名单校验:除三个补丁文件
   外不许有别的改动,同时可重生成 patches/ios-longpress.patch)、tools/vendor-upstream.sh
   (换 pin 时重新 vendor,自动保留补丁文件并打印上游侧差异)。

5) 测试与文档:jsdom 测试默认路径改指 src/public/js/context-js/context-js.js,新增两条结构自检
   (两份 context-js.js 必须一致、blade 必须带 ?v= 版本串)→ 70/70 通过;README 重写
   「代码在仓库哪里、怎么改、怎么升 pin」,并修正许可证说明(应用 GPL-3.0 / 打包 AGPL-3.0)。

数据安全逻辑一字未动:entrypoint.sh 仍是「版本标记不一致才同步」,排除
.env / storage / database / bootstrap/cache / public/i。换镜像后容器自动把新代码同步进卷。
2026-09-28 22:36:48 +08:00
mole404 ffb7947d89 fix(menu): 菜单只要还在屏幕上就不许点穿(修掉 Windows 偶发点穿)+ 诊断转储
老师报:Windows 上偶尔“菜单还在显示时,点一下别的图片就透了”,直接开预览。

根因:拦不拦那一发点击只看内存状态 menuVisible,而外部滚动 / resize / Esc / 关菜单后的
100ms 淡出都会把它提前置 false。其中 scroll 恰恰很容易被“非用户操作”触发 —— 菜单是绝对定位
插进 body 的,**插入这个动作本身**就可能改变页面高度/滚动条、让浏览器自己滚动(滚动锚定、
图片懒加载重排、justified-gallery 重排)。于是“菜单刚打开就被自己引发的滚动关掉”,紧接着
元素淡出的那 100ms 里菜单看着还在、状态已经是关 —— 这时点别的图片就直通开预览。这也解释了
它为什么是“偶尔”:取决于菜单落在哪、要不要出滚动条、浏览器是否触发滚动锚定。

改法(仍然只改 overlay/context-js.js 一个文件):
1) 判据由 menuVisible 换成 menuOnScreen():状态打开 || 关菜单后的淡出窗口(fadeSpeed+50ms)
   || DOM 实测(.dropdown-context 存在且 getBoundingClientRect().height > 0)。
   这样任何一条把状态提前置 false 的路径,都不再让“看得见的菜单”失去拦截能力 ——
   “看得见就不许点穿”变成硬保证。
2) MENU_OPEN_IGNORE_INPUT = 300ms:菜单刚打开的 300ms 内忽略 scroll/resize/orientation
   (这一段里的滚动多半是菜单插入自身引发的);过了之后用户滚动照旧关菜单。
3) 诊断:常驻环缓冲记录最近 240 条菜单事件(打开 / 关闭**原因** / 每次点击的判定),
   暴露 context.debugDump()(控制台 copy(context.debugDump()) 即可导出);
   context.debug = true 时实时打控制台。

验证:
- test/longpress.test.mjs 68/68 通过。新增 5 条回归:淡出期间点别的图片不许点穿、
  状态已 false 但 DOM 实测菜单可见时仍要吞、菜单刚打开 300ms 内的自引发滚动不关菜单、
  菜单彻底消失后点击恢复正常(不会一直吞)、debugDump() 含打开记录与关闭原因。
- test/repro-clickthrough.mjs 最小复现(新增):对旧版打印 ❌ 点穿、新版打印 ✅ —— RED→GREEN 证据。
- CI 镜像自证新增 MENU_OPEN_IGNORE_INPUT / menuOnScreen / debugDump 三个标记断言。
- blade 版本串提到 ?v=ios-longpress4(击穿 Safari 启发式缓存);Dockerfile 三处 md5 +
  .code-revision 两行 + workflow 的 CONTEXT_JS_MD5/BLADE_MD5 全部同步;
  patches/ios-longpress.patch 重新生成并验证可干净应用、结果与 overlay 字节一致。
2026-09-28 22:11:58 +08:00
mole404 5151eff2e2 feat(menu): 点菜单外只关菜单(不再点穿)+ 触摸端二级菜单点击展开 + 滚动/缩放/Esc 关闭
老师在 iPhone 上提的两个交互问题,根因都在上游 context-js.js 里:

1) 点菜单之外会点穿:上游关闭逻辑只在 document 的冒泡阶段 fadeOut,不 preventDefault
   也不 stopPropagation → 点别的图片时"关菜单"和"开预览"同时发生(Viewer.js 的 click
   挂在图片网格上);手机上想关菜单只能去点地址栏,而地址栏又窄又难点。
2) 手机上二级菜单一点就没:二级菜单是 CSS :hover 驱动的,手机没光标 —— 点"复制链接"
   时浏览器补发的合成 mouseenter 让子菜单闪一下,紧接着那发 click 撞上关闭逻辑,整个
   菜单消失 → 复制链接里的 Url/Html/BBCode/Markdown 在手机上根本点不到。

改法(只在 overlay/context-js.js 里,不动主题 CSS):
- 菜单守卫装到 document 的捕获阶段:菜单打开时点菜单外 → 关菜单 + preventDefault +
  stopPropagation,事件永远到不了页面处理器;菜单内部点击照旧放行(复制/重命名/删除不变)。
- 手机上"手指按下"就收起来(更跟手),随后补发的 click 用 700ms 时间窗吞掉;
  拦截只打在 click 上、不拦 touchstart → 滚动/缩放不受影响。
- 刚打开的保护窗 MENU_OPEN_GRACE=800ms,只对"紧随一次触摸的 click"生效(lastTouchAt),
  鼠标点击不受影响:长按抬手补发的那发 click 只吞掉、不关菜单(否则菜单刚开就自己关)。
- 触摸设备(matchMedia('(hover: none)'))点带二级菜单的父项 → 加 touch-open 类 + 注入与
  hover 等价的 CSS,点击展开/收起、同层只留一个;有鼠标的设备继续 hover(桌面零改动)。
- 滚动/旋转缩放(含 #images-scroll 内部滚动)、Esc 关菜单;菜单自身滚动不误关。
- 新增 context.isMenuOpen()(上游没有)供测试与诊断观测菜单状态。

测试:test/longpress.test.mjs 25 → 56 个用例,覆盖上述每条分支,含三条回归
(抬手 click 不能关掉刚开的菜单 / 点菜单外必须吞点击 / 菜单关后点击要恢复正常)。
CI 新增 Behavior tests (jsdom) 步骤(构建前跑,npm ci + npm test),
镜像自证新增 installMenuGuard / MENU_OPEN_GRACE / touch-open / isMenuOpen 标记断言
与"Apache 实际吐出的 JS md5"端到端断言。

配套:blade 版本串 ios-longpress2 → ios-longpress3(击穿 Safari 启发式缓存)、
所有哈希断言同步(Dockerfile 自证 2 + .code-revision + workflow env)、
patches/ios-longpress.patch 重新生成(已 git apply --check 验证能干净打回上游并逐字节一致)、
README/test-README 文档更新、新增 .gitignore 与 test/package-lock.json。
2026-09-28 21:29:45 +08:00
mole404 90e151eef0 feat: 长按阈值 500→250ms;入口脚本改为每次启动强制同步代码进已有卷
老师的两个要求:

1) 长按 0.5s 偏长 → LONG_PRESS_DELAY 减半到 250ms。
   - 配套保留「手指移动 >10px 即取消」,用来抵消阈值变短带来的误触风险
   - blade 的资源版本串递增为 ?v=ios-longpress2,击穿 Safari 的启发式缓存
     (否则老浏览器还在用 500ms 那份 JS)
   - test/ 增加两个阈值回归用例(320ms 必须触发、160ms 必须不触发)

2) 升级不再需要人工 cp 文件:entrypoint.sh 在「已有部署」分支里每次启动都把
   镜像代码同步进卷(tar 覆盖式),并自动清掉编译视图缓存。
   - 绝不覆盖:.env / database/(SQLite)/ storage/(上传·日志·会话)/
     bootstrap/cache/ / public/i —— 这五项是站点数据与配置,不是镜像代码
   - Dockerfile 增加静态护栏:排除清单少一项就构建失败
   - CI 新增「Verify forced code sync」步骤:模拟已有部署的卷(塞旧代码 +
     .env/数据库/上传/编译缓存的哨兵文件),断言代码被更新、数据一项没被碰;
     不过就 exit 1,不会 promote 到 latest
   - README 重写升级/回滚章节并写明代价:手工改卷里的代码会失效、回滚必须连镜像一起回滚

自证:本地仿真 9/9(代码更新 + 五项数据保全)、jsdom 行为测试 25/25、
patch 能干净打到上游 911275c、Dockerfile 全部断言 grep 命中。
2026-09-28 18:24:08 +08:00
mole404 565aa3fefc feat: iOS 长按菜单修复 + 源码钉死在 911275c + GHCR 发布
问题:iOS WebKit 永远不派发 contextmenu(WebKit bug 213953),长按图片只弹系统
callout,导致"我的图片"页的自定义菜单在 iPhone/iPad 上完全不可用
(resources/js/context-js.js 的菜单只挂在 contextmenu 上)。

改动:
- overlay/context-js.js:纯增量补丁。新增仅 iOS 生效的长按分支(touchstart+500ms,
  移动 >10px / 多指 / 短按都取消),复用原本的开菜单逻辑(beforeOpen/afterOpen 照旧);
  注入 -webkit-touch-callout:none 压掉原生菜单(只作用于本库绑定的元素范围);
  抬手吞掉 iOS 补发的 click,避免长按后误开图片预览;长按与 contextmenu 之间 700ms 去重。
  判定只在 iPad/iPhone/iPod(含 iPadOS 伪装的 MacIntel+maxTouchPoints>1)为真,
  Android / Windows / 真 Mac(含 M 系列,maxTouchPoints=0)一行新代码都不执行。
- overlay/images.blade.php:只给脚本加 ?v=ios-longpress,绕开 Safari 的启发式缓存
- Dockerfile:源码钉死 911275c(= 2024-04-29 那版线上镜像使用的 master HEAD);
  基础镜像显式 bookworm;install-php-extensions 钉版本;构建期两处 md5 自证
- .github/workflows/build-image.yaml:替换上游的定时构建,改推 GHCR + 构建后真机自证
  (核 md5、记录 PHP/扩展/Debian、真起容器 curl 安装页)
- test/:node + jsdom 行为测试(23 项,覆盖 iOS/iPad/Mac/Android/Windows 五类场景)
- README:补丁原理、跨平台隔离说明、升级/回滚步骤(含"换镜像不会更新已有卷代码"这个坑)

致谢与许可保留上游全部署名与 LICENSE(AGPL-3.0)。
2026-09-28 16:41:41 +08:00