-
相册名称
+
++ {{-- 图片「详细信息」:居中卡片弹窗(不再渲染进右侧抽屉)。
++ 字段顺序按老师要求:**上传时间第一、图片名称紧跟其后**,其余字段一个没删、只换了位置
++ (相册名称 / 使用策略 / 图片原始名称 / 图片大小 / 图片类型 / 尺寸 / 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(`
+
+
+
+
+
+
+
移动到相册
+
详细信息
+
+
+
+
+
+
+ `, { 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);