From 30862d602fc4eb84dcfbbb049fedfa23a7f8f58b Mon Sep 17 00:00:00 2001 From: mole404 <248403041+mole404@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:34:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=9B=BE=E7=89=87=E9=A1=B5=201/2):=20?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E4=BF=A1=E6=81=AF=E4=B8=8E=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=88=B0=E7=9B=B8=E5=86=8C=E6=94=B9=E7=94=A8=E5=B1=85=E4=B8=AD?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=BC=B9=E7=AA=97=E3=80=81=E4=BA=8C=E7=BA=A7?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E8=BF=94=E5=9B=9E=E9=94=AE=E5=8A=A0=E7=A9=BA?= =?UTF-8?q?=E9=9A=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 等。 --- Dockerfile | 4 +- patches/ios-longpress.patch | 352 ++++++++++++++++++---- src/public/css/app.css | 2 +- src/public/css/context-js/context-js.css | 2 +- src/resources/css/context-js.less | 7 + src/resources/views/user/images.blade.php | 254 ++++++++++++---- test/images-modal-dom.test.mjs | 296 ++++++++++++++++++ test/images-modal.test.mjs | 202 +++++++++++++ 8 files changed, 987 insertions(+), 132 deletions(-) create mode 100644 test/images-modal-dom.test.mjs create mode 100644 test/images-modal.test.mjs diff --git a/Dockerfile b/Dockerfile index c2b232d..66a58f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,7 +109,7 @@ RUN php -r "file_exists('.env') || copy('.env.example', '.env');" \ RUN printf '%s\n' \ 'b916555ab122d2b3db99153d9316a887 ./public/js/context-js/context-js.js' \ 'b916555ab122d2b3db99153d9316a887 ./resources/js/context-js.js' \ - '5da6f22c67fc824d5af88a7de32ad7f6 ./resources/views/user/images.blade.php' \ + '27bb3f203c931633961798dda7aab7dc ./resources/views/user/images.blade.php' \ | md5sum -c - \ && grep -q "assetVersion('js/context-js/context-js.js')" ./resources/views/user/images.blade.php \ && grep -q 'isIOSWebKit' ./public/js/context-js/context-js.js \ @@ -130,7 +130,7 @@ RUN printf '%s\n' \ "fork_sha=${FORK_SHA}" \ "lsky_commit=${LSKY_COMMIT}" \ "context_js_md5=b916555ab122d2b3db99153d9316a887" \ - "images_blade_md5=5da6f22c67fc824d5af88a7de32ad7f6" \ + "images_blade_md5=27bb3f203c931633961798dda7aab7dc" \ > .code-revision \ && cat .code-revision diff --git a/patches/ios-longpress.patch b/patches/ios-longpress.patch index a765002..823d41e 100644 --- a/patches/ios-longpress.patch +++ b/patches/ios-longpress.patch @@ -36,7 +36,7 @@ # 三个文件各自的 md5(与 Dockerfile「自证 2」里的期望值一致): # b916555ab122d2b3db99153d9316a887 src/resources/js/context-js.js # b916555ab122d2b3db99153d9316a887 src/public/js/context-js/context-js.js -# 5da6f22c67fc824d5af88a7de32ad7f6 src/resources/views/user/images.blade.php +# 27bb3f203c931633961798dda7aab7dc src/resources/views/user/images.blade.php # # 上游原文件 md5(改动前,供比对): # c8e57f6232848ca8277341ddf1a3a7a6 resources/js/context-js.js @@ -2075,7 +2075,7 @@ -@@ -76,17 +79,26 @@ +@@ -76,33 +79,56 @@ @@ -2108,7 +2108,20 @@
-@@ -96,13 +108,16 @@ + + ++ {{-- 图片「详细信息」与「移动到相册」改用居中卡片弹窗(复用 components/modal.blade.php, ++ 手机上它就是「底部抽屉式」——弹窗容器在 <640px 时贴底、不依赖 hover)。 ++ 右侧抽屉 #drawer 保留它原来的本职:顶部工具栏的「相册列表」入口照旧走抽屉。 --}} ++ ++
++
++ ++ ++
++
++ + -@@ -137,9 +152,9 @@ +@@ -137,71 +163,106 @@
@@ -2171,71 +2184,143 @@
-@@ -148,51 +163,51 @@ -
-
- 相册名称 + ++ {{-- 图片「详细信息」:居中卡片弹窗(不再渲染进右侧抽屉)。 ++ 字段顺序按老师要求:**上传时间第一、图片名称紧跟其后**,其余字段一个没删、只换了位置 ++ (相册名称 / 使用策略 / 图片原始名称 / 图片大小 / 图片类型 / 尺寸 / MD5 / SHA-128 / 权限 / 上传 IP)。 ++ 排版:标签在左、值在右(窄屏 flex-col 自动上下堆叠);标签 13px、值 14px,行高 leading-6 宽松一些。 ++ 顶部一张小缩略图。这里不放任何快捷操作(复制链接/下载/删除都不进这个弹窗)。 --}} + ++ ++ {{-- 「移动到相册」弹窗:相册列表(单选)+ 底部「移动」「取消」。 ++ 列表数据与顶部工具栏的「相册列表」同一个接口;点一行只选中,底部「移动」才提交。 ++ 每行 min-h-[44px](手机点击区 ≥44px),当前所在相册带「当前」标记(__current_badge__)。 --}} ++ -@@ -201,7 +216,8 @@ + ++ {{-- 相册列表行:整行是一个点击区(min-h-[44px]),不用 span(避免被无限加载的 ++ "点 span 加载更多"委托命中)。选中态由 JS 切换 border-brand/bg-brand-soft/text-brand。 --}} ++ ++ + @push('scripts') @@ -2245,7 +2330,19 @@ + {{-- 图片「详细信息」:居中卡片弹窗(不再渲染进右侧抽屉)。 + 字段顺序按老师要求:**上传时间第一、图片名称紧跟其后**,其余字段一个没删、只换了位置 + (相册名称 / 使用策略 / 图片原始名称 / 图片大小 / 图片类型 / 尺寸 / MD5 / SHA-128 / 权限 / 上传 IP)。 + 排版:标签在左、值在右(窄屏 flex-col 自动上下堆叠);标签 13px、值 14px,行高 leading-6 宽松一些。 + 顶部一张小缩略图。这里不放任何快捷操作(复制链接/下载/删除都不进这个弹窗)。 --}} + + {{-- 「移动到相册」弹窗:相册列表(单选)+ 底部「移动」「取消」。 + 列表数据与顶部工具栏的「相册列表」同一个接口;点一行只选中,底部「移动」才提交。 + 每行 min-h-[44px](手机点击区 ≥44px),当前所在相册带「当前」标记(__current_badge__)。 --}} + + {{-- 相册列表行:整行是一个点击区(min-h-[44px]),不用 span(避免被无限加载的 + "点 span 加载更多"委托命中)。选中态由 JS 切换 border-brand/bg-brand-soft/text-brand。 --}} + + @push('scripts') @@ -241,6 +286,11 @@ const $photos = $(IMAGES_GRID); const $drawer = $("#drawer"); const $drawerMask = $('#drawer-mask'); + // 居中卡片弹窗(复用 components/modal.blade.php 的 Alpine store,用法同 admin 页) + const modal = Alpine.store('modal'); + // 「移动到相册」弹窗里的相册列表容器 / 底部按钮 + const MOVEMENTS_MODAL = 'image-movements-modal'; + const DETAIL_MODAL = 'image-detail-modal'; const viewer = new Viewer(document.getElementById('images-grid'), {url: 'data-original'}); const drawer = { open(title, content, callback) { @@ -572,25 +622,94 @@ classes: ['dragselect'], const methods = { movements() { - getAlbums({title: '选择相册'}, e => { - let selected = ds.getSelection().map(item => $(item).data('id')); - $headerTitle.text(`移动 ${selected.length} 张图片至...`) - $(e).off('click', '>a').on('click', '>a', function () { - axios.put('{{ route('user.images.movement') }}', { - selected: selected, - id: $(this).data('id'), - album_id: selectedAlbum.id || 0, - }).then(response => { - if (response.data.status) { - drawer.close(); - resetImages(); - toastr.success(response.data.message); - } else { - toastr.warning(response.data.message); - } - }) + // 「移动到相册」改用居中卡片弹窗(不再渲染进右侧抽屉 —— + // 抽屉只留给顶部工具栏的「相册列表」入口)。 + // 相册列表仍走同一个 user.albums 接口 + 同一个 utils.infiniteScroll; + // 弹窗内是单选列表(当前所在相册带「当前」标记),底部「移动」「取消」。 + let selected = ds.getSelection().map(item => $(item).data('id')); + if (! selected.length) { + return false; + } + + $('#image-movements-content').html( + $('#movements-container-tpl').html().replace(/__count__/g, selected.length) + ); + + const $movementsAlbums = $('#movements-albums'); + const $movementsConfirm = $('#movements-confirm'); + let targetId = null; // 本次要移动到的相册(单选) + + // 单选:点一行只选中并高亮,不发请求;底部「移动」才提交 + const selectRow = ($row) => { + targetId = $row.length ? $row.data('id') : null; + $movementsAlbums.find('.movements-album').each(function () { + let on = targetId !== null && $(this).data('id') === targetId; + $(this) + .toggleClass('border-brand bg-brand-soft text-brand', on) + .toggleClass('border-line bg-surface-2 text-ink', ! on) + .attr('data-selected', on ? 'true' : 'false') + .find('.selected-mark').toggleClass('opacity-0', ! on); }); + $movementsConfirm.prop('disabled', targetId === null); + }; + + utils.infiniteScroll('#movements-albums', { + url: '{{ route('user.albums') }}', + success: function (response) { + if (! response.status) { + return toastr.error(response.message); + } + + let albums = response.data.albums.data; + if (albums.length <= 0 || response.data.albums.current_page === response.data.albums.last_page) { + this.finished = true; + } + + let html = ''; + for (const i in albums) { + html += $('#movements-album-item-tpl').html() + .replace(/__id__/g, albums[i].id) + .replace(/__name__/g, albums[i].name) + .replace(/__image_num__/g, albums[i].image_num) + // 当前所在相册标出来 + .replace(/__current_badge__/g, albums[i].id === selectedAlbum.id + ? '
当前
' + : '') + } + + $movementsAlbums.append(html); + } }); + + $movementsAlbums.off('click', '.movements-album').on('click', '.movements-album', function () { + selectRow($(this)); + }); + + $('#movements-cancel').off('click').on('click', _ => modal.close(MOVEMENTS_MODAL)); + + // 移动请求沿用原实现(同一个 route、同一份 payload), + // 唯一区别:原来「点哪行就移动哪行」,现在是「选中后点移动」 + $movementsConfirm.off('click').on('click', function () { + if (targetId === null) { + return false; + } + + axios.put('{{ route('user.images.movement') }}', { + selected: selected, + id: targetId, + album_id: selectedAlbum.id || 0, + }).then(response => { + if (response.data.status) { + modal.close(MOVEMENTS_MODAL); + resetImages(); + toastr.success(response.data.message); + } else { + toastr.warning(response.data.message); + } + }) + }); + + modal.open(MOVEMENTS_MODAL); }, remove() { let selected = ds.getSelection().map(item => $(item).data('id')); @@ -721,6 +840,7 @@ classes: ['dragselect'], if (response.data.status) { let image = response.data.data.image; let content = $('#image-detail-tpl').html() + .replace(/__thumb_url__/g, image.thumb_url) .replace(/__album_name__/g, image.album ? image.album.name : '-') .replace(/__strategy_name__/g, image.strategy ? image.strategy.name : '-') .replace(/__filename__/g, image.filename.replace(/\$/g, '$$$$')) @@ -734,7 +854,9 @@ classes: ['dragselect'], .replace(/__permission__/g, image.permission === 1 ? '公开' : '私有') .replace(/__uploaded_ip__/g, image.uploaded_ip) .replace(/__created_at__/g, image.created_at) - drawer.open(item.filename, content); + // 「详细信息」改成居中卡片弹窗(原来画进右侧抽屉) + $('#image-detail-content').html(content); + modal.open(DETAIL_MODAL); } else { toastr.error(response.data.message); } diff --git a/test/images-modal-dom.test.mjs b/test/images-modal-dom.test.mjs new file mode 100644 index 0000000..59f276f --- /dev/null +++ b/test/images-modal-dom.test.mjs @@ -0,0 +1,296 @@ +/* + * 「详细信息 / 移动到相册」新弹窗的行为测试(jsdom + 真实 jQuery) + * + * 与 images-modal.test.mjs(静态断言)互补:这里把 images.blade.php 里 @push('scripts') + * 的三段内联脚本真的跑起来(周边依赖打桩),然后驱动新代码路径: + * 1. methods.movements() → 打开居中卡片弹窗(modal.open),列表渲染进 #movements-albums + * 2. 点一行 = 只选中(单选、高亮、「当前」标记、≥44px 的行),不发请求 + * 3. 点「移动」才发请求,且 payload 与原来完全一致(selected / id / album_id) + * 4. 「取消」只关弹窗 + * 5. methods.detail() → 内容渲染进 #image-detail-content,字段顺序 上传时间 → 图片名称 + * 6. 全程右侧抽屉一点没被这两项用上(#drawer-content 保持空、抽屉没被推开) + * + * 运行:node images-modal-dom.test.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { JSDOM } from 'jsdom'; + +const here = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname)); +const SRC = path.join(here, '..', 'src'); +const blade = fs.readFileSync(path.join(SRC, 'resources', 'views', 'user', 'images.blade.php'), 'utf8'); +const JQUERY_SRC = fs.readFileSync(path.join(here, 'node_modules/jquery/dist/jquery.js'), 'utf8'); + +// 三段内联脚本(按出现顺序);blade 表达式换成桩串(route() 之类) +function inlineScripts() { + const tail = blade.slice(blade.indexOf("@push('scripts')")); + const out = []; + const re = /', start); + return blade.slice(blade.indexOf('>', start) + 1, end); +} + +const IMAGE = { + id: 7, filename: 'beach.jpg', origin_name: 'IMG_0001.HEIC', url: 'https://i.example.com/beach.jpg', + thumb_url: 'https://i.example.com/beach-thumb.jpg', width: 4000, height: 3000, size: 2048, + mimetype: 'image/jpeg', md5: 'abcdef', sha1: '123456', permission: 0, uploaded_ip: '1.2.3.4', + created_at: '2026-09-01 10:00:00', album: { name: '旧相册' }, strategy: { name: '本机' }, links: {}, +}; + +function boot() { + const dom = new JSDOM(` + +
+ + beach.jpg + +
+ +
+ + + + + + + + + + + `, { runScripts: 'outside-only', pretendToBeVisual: true, url: 'https://lsky.test/user/images' }); + + const { window } = dom; + Object.defineProperty(window.navigator, 'userAgent', { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', configurable: true }); + + window.eval(JQUERY_SRC); + const $ = window.jQuery; + $.fn.justifiedGallery = function () { return this; }; + + const calls = { modalOpen: [], modalClose: [], puts: [], gets: [], toasts: [], infiniteScroll: [], attaches: [], refreshes: [] }; + + const stores = { + modal: { + open: (id) => calls.modalOpen.push(id), + close: (id) => calls.modalClose.push(id), + isOpen: () => false, + }, + }; + window.Alpine = { store: (n) => stores[n] }; + + window.axios = { + put: (url, data) => { calls.puts.push({ url, data }); return Promise.resolve({ data: { status: true, message: '移动成功' } }); }, + get: (url) => { calls.gets.push(url); return Promise.resolve({ data: { status: true, data: { image: IMAGE } } }); }, + delete: () => Promise.resolve({ data: { status: true, message: 'ok' } }), + }; + + window.DragSelect = class { + constructor() { window.DragSelect.last = this; this._sel = []; } + getSelection() { return this._sel; } + subscribe() {} setSelectables() {} addSelection() {} toggleSelection() {} clearSelection() {} + stop() {} break() {} + }; + window.Viewer = class { update() {} }; + window.ClipboardJS = class { on() { return this; } }; + window.context = { init() {}, attach: (s) => calls.attaches.push(s), isMenuOpen: () => false }; + window.toastr = { + success: (m) => calls.toasts.push(['success', m]), + warning: (m) => calls.toasts.push(['warning', m]), + error: (m) => calls.toasts.push(['error', m]), + }; + window.Swal = { fire: () => Promise.resolve({ isConfirmed: false }) }; + window.utils = { + formatSize: (bytes) => `${bytes} B`, + isMobile: () => false, + setCapacityProgress() {}, + infiniteScroll(selector, options) { + calls.infiniteScroll.push({ selector, options }); + return { refresh(params) { calls.refreshes.push(params); }, reset() {}, destroy() {} }; + }, + }; + + // jsdom 的 window.eval 每次都是独立的词法作用域(浏览器里 classic script 之间共享全局词法 + // 环境的行为在 vm 里没有),所以三段脚本拼成一个源一起跑,并在同一作用域里把内部引用抛出来。 + window.eval(inlineScripts().join('\n') + ` + window.__t = { + methods, + getAlbums, + drawer, + ds, + setSelectedAlbum(v) { selectedAlbum = v; }, + getSelectedAlbum() { return selectedAlbum; }, + }; + `); + + return { window, $, calls, t: window.__t }; +} + +const results = []; +function check(name, pass, detail = '') { + results.push({ name, pass, detail }); + console.log(`${pass ? ' PASS' : ' FAIL'} ${name}${detail ? ' → ' + detail : ''}`); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ================================================================ 移动到相册 +console.log('\n[移动到相册] 弹窗路径走通,抽屉一点没碰'); +{ + const { $, calls, t } = boot(); + t.ds._sel = [$('.images-item').get(0)]; + // 当前所在相册(用来验证「当前」标记 + album_id 原值) + t.setSelectedAlbum({ id: 9, name: '旧相册' }); + + t.methods.movements(); + + check('打开了新弹窗(modal.open("image-movements-modal"))', + calls.modalOpen.length === 1 && calls.modalOpen[0] === 'image-movements-modal', JSON.stringify(calls.modalOpen)); + check('内容渲染进 #image-movements-content,标题带已选数量', + $('#image-movements-content').text().includes('移动到相册') && $('#image-movements-content').text().includes('已选择 1 张图片')); + check('相册列表挂载在 #movements-albums(不是 #drawer-content)', + $('#movements-albums').length === 1 && $('#drawer-content').html() === '', + `drawer-content=${JSON.stringify($('#drawer-content').html())}`); + check('抽屉没有被推开(#drawer 的 right 没被动过)', + $('#drawer').css('right') !== '0px' && $('#drawer').css('right') !== '0', String($('#drawer').css('right'))); + check('底部「移动」「取消」在,且「移动」默认 disabled', + $('#movements-confirm').length === 1 && $('#movements-confirm').prop('disabled') === true && $('#movements-cancel').length === 1); + + const scroll = calls.infiniteScroll.at(-1); + check('相册列表走 utils.infiniteScroll,容器是 #movements-albums', + scroll?.selector === '#movements-albums', String(scroll?.selector)); + + // 喂一页相册数据(模拟 user.albums 的响应) + scroll.options.success.call({ finished: false }, { + status: true, + data: { albums: { data: [{ id: 1, name: '三亚', image_num: 2 }, { id: 9, name: '旧相册', image_num: 0 }], current_page: 1, last_page: 1 } }, + }); + + const rows = $('#movements-albums .movements-album'); + check('渲染出两行相册,data-id 正确', rows.length === 2 && rows.eq(0).data('id') === 1 && rows.eq(1).data('id') === 9, + `${rows.length} 行`); + check('每行整行可点(min-h-[44px] 的手机点击区)', rows.eq(0).attr('class').includes('min-h-[44px]')); + check('当前所在相册(id=9)带「当前」标记,别的行没有', + rows.eq(1).find('.ls-badge').text() === '当前' && rows.eq(0).find('.ls-badge').length === 0, + rows.eq(1).text().trim()); + check('行里没有 span(避免命中「点 span 加载更多」的委托)', rows.eq(0).find('span').length === 0); + + rows.eq(0).trigger('click'); + check('点一行:选中(data-selected=true + 品牌色高亮 + 对勾出现)', + rows.eq(0).attr('data-selected') === 'true' + && rows.eq(0).attr('class').includes('bg-brand-soft') + && ! rows.eq(0).find('.selected-mark').attr('class').includes('opacity-0')); + check('点一行:没有立刻发请求(等底部「移动」)', calls.puts.length === 0); + check('点一行:「移动」变为可用', $('#movements-confirm').prop('disabled') === false); + + rows.eq(1).trigger('click'); + check('单选:点第二行后第一行回到未选中', + rows.eq(0).attr('data-selected') === 'false' && rows.eq(1).attr('data-selected') === 'true' + && ! rows.eq(0).attr('class').includes('bg-brand-soft')); + check('单选:对勾也跟着切换', + rows.eq(0).find('.selected-mark').attr('class').includes('opacity-0') + && ! rows.eq(1).find('.selected-mark').attr('class').includes('opacity-0')); + + $('#movements-confirm').trigger('click'); + check('点「移动」:请求沿用原实现(同一 route + selected/id/album_id)', + calls.puts.length === 1 && calls.puts[0].url === '/stub' + && JSON.stringify(calls.puts[0].data) === JSON.stringify({ selected: [7], id: 9, album_id: 9 }), + JSON.stringify(calls.puts[0])); + + await sleep(20); + check('移动成功后:关弹窗 + toastr.success', + calls.modalClose.includes('image-movements-modal') && calls.toasts.some((t) => t[0] === 'success'), + JSON.stringify(calls.toasts)); +} + +// ================================================================ 取消 +console.log('\n[移动到相册] 「取消」只关弹窗'); +{ + const { $, calls, t } = boot(); + t.ds._sel = [$('.images-item').get(0)]; + t.methods.movements(); + $('#movements-cancel').trigger('click'); + check('点「取消」:关弹窗且没有发任何请求', + calls.modalClose.includes('image-movements-modal') && calls.puts.length === 0, + JSON.stringify(calls.modalClose)); +} + +// ================================================================ 详细信息 +console.log('\n[详细信息] 弹窗路径 + 字段顺序(上传时间第一、图片名称第二)'); +{ + const { $, calls, t } = boot(); + t.methods.detail($('.images-item').get(0)); + await sleep(20); + + check('请求走原接口(/user/images/:id)', calls.gets.length === 1 && calls.gets[0] === '/user/images/7', JSON.stringify(calls.gets)); + check('渲染进 #image-detail-content 并打开 #image-detail-modal', + $('#image-detail-content').text().includes('beach.jpg') + && calls.modalOpen.includes('image-detail-modal'), JSON.stringify(calls.modalOpen)); + check('内容没有进右侧抽屉(#drawer-content 仍是空的)', $('#drawer-content').html() === ''); + + const labels = $('#image-detail-content dt').map(function () { return $(this).text().trim(); }).get(); + check('字段一个不少(12 项,与改前一致)', labels.length === 12, labels.join(' / ')); + check('顺序:上传时间 第一、图片名称 第二', labels[0] === '上传时间' && labels[1] === '图片名称', labels.join(' / ')); + check('其余字段保持原有相对顺序', + JSON.stringify(labels.slice(2)) === JSON.stringify(['相册名称', '使用策略', '图片原始名称', '图片大小', '图片类型', '尺寸', 'MD5', 'SHA-128', '权限', '上传 IP']), + labels.slice(2).join(' / ')); + check('小缩略图用的是 thumb_url', $('#image-detail-content img').attr('src') === IMAGE.thumb_url); + check('值是渲染后的真实内容(不是占位符)', + $('#image-detail-content').text().includes('2026-09-01 10:00:00') && $('#image-detail-content').text().includes('2097152 B') + && ! $('#image-detail-content').text().includes('__')); + check('标签 13px、值 14px(text-[13px] / text-[14px])', + $('#image-detail-content dt').first().attr('class').includes('text-[13px]') + && $('#image-detail-content dd').first().attr('class').includes('text-[14px]')); + check('弹窗里没有复制链接/下载/删除这类快捷操作', + !/复制|下载|删除/.test($('#image-detail-content').text())); +} + + +// ================================================================ 抽屉本职 +console.log('\n[抽屉] 顶部工具栏的「相册列表」入口照旧走右侧抽屉'); +{ + const { $, calls, t } = boot(); + t.getAlbums(); + + check('getAlbums() 打开的是抽屉(right 推到 0)而不是弹窗', + String($('#drawer').css('right')) === '0px' && calls.modalOpen.length === 0, + `right=${$('#drawer').css('right')} modalOpen=${JSON.stringify(calls.modalOpen)}`); + check('相册列表内容渲染进 #drawer-content(#albums-container 在抽屉里)', + $('#drawer-content #albums-container').length === 1 && $('#image-movements-content').html() === ''); + check('抽屉标题带「我的相册」', + $('#drawer-title').text().includes('我的相册'), $('#drawer-title').text()); + check('抽屉里的列表仍挂在 #drawer-content 上做无限加载', + calls.infiniteScroll.at(-1)?.selector === '#drawer-content', String(calls.infiniteScroll.at(-1)?.selector)); + + calls.infiniteScroll.at(-1).options.success.call({ finished: false }, { + status: true, + data: { albums: { data: [{ id: 3, name: '旅行', image_num: 5, intro: '' }], current_page: 1, last_page: 1 } }, + }); + check('抽屉里渲染出相册行(.albums-item,旧标记没变)', + $('#drawer-content .albums-item').length === 1 && $('#drawer-content .albums-item').data('id') === 3); + + // 点相册 → 筛选图片(resetImages),行为与改前一致 + calls.refreshes.length = 0; + $('#drawer-content .albums-item').trigger('click'); + check('点抽屉里的相册:按该相册筛选图片(imagesInfinite.refresh 带上 album_id)', + calls.refreshes.length === 1 && calls.refreshes[0].album_id === 3, JSON.stringify(calls.refreshes)); + check('点抽屉里的相册:抽屉关闭(right 回到 -1000px)', + String($('#drawer').css('right')) === '-1000px', String($('#drawer').css('right'))); +} + +// ---------------------------------------------------------------- 汇总 +const failed = results.filter((r) => !r.pass); +console.log(`\n${results.length - failed.length}/${results.length} 通过`); +if (failed.length) { + console.log('\n失败项:'); + failed.forEach((f) => console.log(` - ${f.name}${f.detail ? ' → ' + f.detail : ''}`)); +} +process.exit(failed.length ? 1 : 0); diff --git a/test/images-modal.test.mjs b/test/images-modal.test.mjs new file mode 100644 index 0000000..16f6037 --- /dev/null +++ b/test/images-modal.test.mjs @@ -0,0 +1,202 @@ +/* + * 图片页「详细信息 / 移动到相册」改成居中卡片弹窗的回归测试(纯静态断言,不需要浏览器) + * + * 背景:这两项原来是画进右侧抽屉 #drawer 的;老师要求换成复用 x-modal 的居中卡片弹窗, + * 抽屉只保留「顶部工具栏 → 相册列表」这一本职。这个文件就是把那次改动的契约钉住: + * 1. 新弹窗的钩子都在,且两项确实不再走抽屉(drawer.open 那条调用没了) + * 2. 抽屉那一套钩子一个都没被改名/删除(菜单绑定、data-operate、data-id、相册列表模板…) + * 3. 「详细信息」字段顺序:上传时间第一、图片名称第二,其余字段一个不少 + * 4. 弹窗内容只用设计令牌(不出现硬编码颜色) + * 5. 「移动」的请求沿用原实现(同一个 route、同一份 payload) + * 6. 二级菜单「返回」的箭头与文字之间留了 6~8px 间距(改的是 .less,不是钉住的 context-js.js) + * 7. 钉住的 context-js.js(两份拷贝)没被碰过 —— 直接对 Dockerfile 里钉的 md5 + * + * 运行:node images-modal.test.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const here = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname)); +const SRC = path.join(here, '..', 'src'); +const read = (...p) => fs.readFileSync(path.join(SRC, ...p), 'utf8'); + +const blade = read('resources', 'views', 'user', 'images.blade.php'); +const less = read('resources', 'css', 'context-js.less'); +const contextJs = read('resources', 'js', 'context-js.js'); +const contextJsPub = read('public', 'js', 'context-js', 'context-js.js'); +const dockerfile = fs.readFileSync(path.join(here, '..', 'Dockerfile'), 'utf8'); + +const results = []; +function check(name, pass, detail = '') { + results.push({ name, pass, detail }); + console.log(`${pass ? ' PASS' : ' FAIL'} ${name}${detail ? ' → ' + detail : ''}`); +} + +// 取出一整块 +function tpl(id) { + const start = blade.indexOf(`', start); + return end < 0 ? '' : blade.slice(start, end + ''.length); +} + +const detailTpl = tpl('image-detail-tpl'); +const movementsContainerTpl = tpl('movements-container-tpl'); +const movementsItemTpl = tpl('movements-album-item-tpl'); + +// ---------------------------------------------------------------- 新弹窗钩子 +console.log('\n[新弹窗] 两个居中卡片弹窗(复用 x-modal)的钩子'); +{ + check('详细信息弹窗: + #image-detail-content', + blade.includes('') && blade.includes('id="image-detail-content"')); + check('移动到相册弹窗: + #image-movements-content', + blade.includes('') && blade.includes('id="image-movements-content"')); + check('弹窗按钮/容器:movements-albums / movements-confirm / movements-cancel', + blade.includes('id="movements-albums"') && blade.includes('id="movements-confirm"') && blade.includes('id="movements-cancel"')); + check('JS 里通过 Alpine 的 modal store 打开(modal.open(...))', + /const modal = Alpine\.store\('modal'\);/.test(blade) && blade.includes('modal.open(MOVEMENTS_MODAL)') && blade.includes('modal.open(DETAIL_MODAL)')); +} + +// ---------------------------------------------------------------- 不再走抽屉 +console.log('\n[换实现] 两项都不再渲染进右侧抽屉,但「移动」的请求实现没被重写'); +{ + check('详细信息不再调 drawer.open(原调用已消失)', + !blade.includes('drawer.open(item.filename, content)') && blade.includes("$('#image-detail-content').html(content);")); + check('移动到相册不再借用抽屉的相册列表(原 getAlbums({title: \'选择相册\'}) 已消失)', + !blade.includes("getAlbums({title: '选择相册'}")); + check('移动请求沿用原实现:同一个 route + selected/id/album_id 三个字段', + /axios\.put\('\{\{ route\('user\.images\.movement'\) \}\}', \{\s*selected: selected,\s*id: targetId,\s*album_id: selectedAlbum\.id \|\| 0,/.test(blade)); + check('第一版就带「取消」按钮,取消只关弹窗', + /#movements-cancel'\)\.off\('click'\)\.on\('click', _ => modal\.close\(MOVEMENTS_MODAL\)\)/.test(blade)); + check('移动成功后关弹窗 + resetImages + toastr(沿用原来的收尾)', + /modal\.close\(MOVEMENTS_MODAL\);\s*resetImages\(\);\s*toastr\.success\(response\.data\.message\);/.test(blade)); +} + +// ---------------------------------------------------------------- 抽屉本职保留 +console.log('\n[回归] 抽屉保留本职(顶部工具栏「相册列表」),且所有旧钩子都没被改名/删除'); +{ + const drawerHooks = [ + 'id="drawer-mask"', 'id="drawer"', 'id="drawer-title"', 'id="drawer-content"', + "const $drawer = $(\"#drawer\");", "const $drawerMask = $('#drawer-mask');", + 'const drawer = {', 'open(title, content, callback)', 'close(callback)', 'toggle(title, content, callback)', + "drawer.find('#drawer-title').html(title)", "drawer.find('#drawer-content').html(content)", + "utils.infiniteScroll('#drawer-content'", 'albumsInfinite', 'const getAlbums = (options, callback) =>', + 'drawer.toggle(title, content, function ()', 'drawer.close();', + ]; + const missing = drawerHooks.filter((h) => !blade.includes(h)); + check('抽屉钩子一个不少(id / drawer 对象 / 相册列表模板与无限加载)', missing.length === 0, missing.join(' | ')); + + const menuHooks = [ + 'href="javascript:getAlbums()"', + 'data-operate="movements"', 'data-operate="detail"', 'data-operate="remove"', + 'data-operate="delete"', "case 'movements':", "case 'detail':", + 'context.attach(IMAGES_ITEM', 'context.attach(IMAGES_SCROLL', + "id=\"images-grid\"", "id=\"images-scroll\"", 'class="images-item', + "id=\"albums-container-tpl\"", "id=\"albums-item-tpl\"", "id=\"image-detail-tpl\"", + 'methods.movements()', 'methods.detail(selected[0])', + 'new ClipboardJS(\'.dropdown-menu li a.copy\'', + ]; + const missingMenu = menuHooks.filter((h) => !blade.includes(h)); + check('菜单/工具栏绑定一个不少(data-operate、右键菜单 actions、相册列表入口)', missingMenu.length === 0, missingMenu.join(' | ')); + + check('工具栏「相册列表」入口仍然走抽屉(getAlbums 内部仍 drawer.toggle)', + blade.includes('drawer.toggle(title, content, function ()') && !blade.includes('modal.open(\'albums')); +} + +// ---------------------------------------------------------------- 详细信息字段顺序 +console.log('\n[详细信息] 字段顺序与一个都不能少'); +{ + const labels = ['上传时间', '图片名称', '相册名称', '使用策略', '图片原始名称', '图片大小', '图片类型', + '尺寸', 'MD5', 'SHA-128', '权限', '上传 IP']; + const missing = labels.filter((l) => !detailTpl.includes(`>${l}`)); + check('12 个字段全在(与改前一致,一个没删)', missing.length === 0, missing.join(' | ')); + + const at = (l) => detailTpl.indexOf(`>${l}`); + check('顺序:上传时间 排第一', at('上传时间') > -1 && at('上传时间') === Math.min(...labels.map(at))); + check('顺序:图片名称 紧跟在 上传时间 之后(第二个字段)', + at('图片名称') === labels.map(at).sort((a, b) => a - b)[1]); + check('其余字段保持原有相对顺序(相册名称 → 使用策略 → … → 上传 IP)', + ['相册名称', '使用策略', '图片原始名称', '图片大小', '图片类型', '尺寸', 'MD5', 'SHA-128', '权限', '上传 IP'] + .every((l, i, arr) => i === 0 || at(arr[i - 1]) < at(l))); + + check('顶部有小缩略图(__thumb_url__,JS 里也做了替换)', + detailTpl.includes('src="__thumb_url__"') && blade.includes('.replace(/__thumb_url__/g, image.thumb_url)')); + check('标签 13px / 值 14px,行高宽松(leading-6)', + (detailTpl.match(/text-\[13px\] leading-6 text-ink-3/g) || []).length === 12 + && (detailTpl.match(/text-\[14px\] leading-6 text-ink/g) || []).length === 12); + check('标签在左、值在右,窄屏可堆叠(sm:flex-row + flex-col)', + detailTpl.includes('flex flex-col gap-0.5 sm:flex-row sm:gap-4')); + check('弹窗里没有「复制链接/下载/删除」这类快捷操作', + !/copy|Clipboard|download|delete|删除|复制链接/.test(detailTpl)); +} + +// ---------------------------------------------------------------- 移动弹窗 +console.log('\n[移动到相册] 单选 + 当前相册标记 + 手机点击区 ≥44px'); +{ + check('列表是单选:点击只选中(selectRow),不立刻发请求', + blade.includes('const selectRow = ($row) =>') && !/on\('click', '\.movements-album', function \(\) \{\s*axios/.test(blade)); + check('当前所在相册标出来(__current_badge__ + selectedAlbum.id 比较)', + movementsItemTpl.includes('__current_badge__') && blade.includes('albums[i].id === selectedAlbum.id')); + check('每行点击区 ≥44px(min-h-[44px])', movementsItemTpl.includes('min-h-[44px]')); + check('底部「移动」「取消」都在,移动默认禁用(没选相册不许提交)', + /id="movements-confirm"[^>]*disabled/.test(movementsContainerTpl) && movementsContainerTpl.includes('id="movements-cancel"')); + check('列表数据源与抽屉里的相册列表同一个接口(route user.albums)', + blade.includes("url: '{{ route('user.albums') }}'")); +} + +// ---------------------------------------------------------------- 只用设计令牌 +console.log('\n[样式] 弹窗内容只用设计令牌,不硬编码颜色'); +{ + const blocks = { 'image-detail-tpl': detailTpl, 'movements-container-tpl': movementsContainerTpl, 'movements-album-item-tpl': movementsItemTpl }; + const bad = []; + for (const [name, text] of Object.entries(blocks)) { + if (/#[0-9a-fA-F]{3,8}\b/.test(text)) bad.push(`${name}: hex`); + if (/\b(?:rgb|rgba|hsl)\(/.test(text)) bad.push(`${name}: rgb/hsl`); + const hardcoded = text.match(/\b(?:text|bg|border)-(?:white|black|gray-\d+|red-\d+|green-\d+|blue-\d+|slate-\d+|zinc-\d+)\b/g); + if (hardcoded) bad.push(`${name}: ${[...new Set(hardcoded)].join(',')}`); + } + check('三个模板里没有硬编码颜色', bad.length === 0, bad.join(' | ')); + + const tokens = ['bg-surface', 'bg-surface-2', 'bg-surface-3', 'text-ink', 'text-ink-3', 'border-line', 'bg-brand-soft', 'text-brand']; + check('用到的都是设计令牌(surface / ink / line / brand)', + tokens.filter((t) => (detailTpl + movementsContainerTpl + movementsItemTpl).includes(t)).length >= 6); + check('选中态用令牌切换(border-brand / bg-brand-soft / text-brand)', + blade.includes(".toggleClass('border-brand bg-brand-soft text-brand', on)")); +} + +// ---------------------------------------------------------------- 二级菜单「返回」 +console.log('\n[二级菜单] 「< 返回」的箭头与文字之间留了 6~8px'); +{ + const m = less.match(/\.dropdown-context \.submenu-back > a > i\s*\{\s*margin-right:\s*(\d+)px;\s*\}/); + const px = m ? Number(m[1]) : NaN; + check('.less 里给了 .dropdown-context .submenu-back > a > i 一个 6~8px 的 margin-right', + !!m && px >= 6 && px <= 8, m ? `${px}px` : '没找到规则'); + check('改的是样式(.less),没有动钉住的 context-js.js 里的「返回」标记', + contextJs.includes('返回')); + // 选择器权重(0,3,2)压过工具类 .mr-1(0,1,0),所以不依赖打包顺序 + check('选择器权重高于工具类 .mr-1(3 个类 + 2 个元素 > 1 个类)', true, '.dropdown-context .submenu-back > a > i'); +} + +// ---------------------------------------------------------------- 钉住的补丁产物没被碰 +console.log('\n[钉住的产物] context-js.js(两份拷贝)与 Dockerfile 里钉的 md5 完全一致'); +{ + const pinned = (dockerfile.match(/^\s*'?([0-9a-f]{32})\s+\.\/(?:public|resources)\/js\/context-js(?:\/context-js)?\.js'?\s*\\?$/gm) || []) + .map((l) => l.trim()) + .map((l) => l.replace(/^'?/, '').split(/\s+/)[0]); + const md5 = (s) => crypto.createHash('md5').update(s).digest('hex'); + check('Dockerfile 里钉着两份 context-js.js 的 md5', pinned.length === 2, pinned.join(' / ')); + check('resources/js/context-js.js 未改动', pinned.includes(md5(contextJs)), md5(contextJs)); + check('public/js/context-js/context-js.js 未改动', pinned.includes(md5(contextJsPub)), md5(contextJsPub)); + check('那三个「资源版本串」标记还在(isIOSWebKit / LONG_PRESS_DELAY = 250 / submenu-inplace)', + contextJs.includes('isIOSWebKit') && contextJs.includes('LONG_PRESS_DELAY = 250') && contextJs.includes('submenu-inplace')); +} + +// ---------------------------------------------------------------- 汇总 +const failed = results.filter((r) => !r.pass); +console.log(`\n${results.length - failed.length}/${results.length} 通过`); +if (failed.length) { + console.log('\n失败项:'); + failed.forEach((f) => console.log(` - ${f.name}${f.detail ? ' → ' + f.detail : ''}`)); +} +process.exit(failed.length ? 1 : 0);