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 等。
This commit is contained in:
mole404 committed 2026-09-29 14:34:25 +08:00
1 parent 1a57f7660c
commit 30862d602f
8 files changed
+987 -132

No files matched your search

+2 -2
View File
@@ -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
+290 -62
View File
@@ -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 @@
</a>
</x-slot>
@@ -76,17 +79,26 @@
@@ -76,33 +79,56 @@
</x-dropdown>
</div>
</div>
@@ -2108,7 +2108,20 @@
</div>
<div id="drawer-content" class="overflow-y-auto"></div>
</div>
@@ -96,13 +108,16 @@
</div>
+ {{-- 图片「详细信息」与「移动到相册」改用居中卡片弹窗(复用 components/modal.blade.php,
+ 手机上它就是「底部抽屉式」——弹窗容器在 <640px 时贴底、不依赖 hover)。
+ 右侧抽屉 #drawer 保留它原来的本职:顶部工具栏的「相册列表」入口照旧走抽屉。 --}}
+ <x-modal id="image-detail-modal">
+ <div id="image-detail-content"></div>
+ </x-modal>
+
+ <x-modal id="image-movements-modal">
+ <div id="image-movements-content"></div>
+ </x-modal>
+
<script type="text/html" id="images-item-tpl">
<a href="javascript:void(0)" data-id="__id__" data-json='__json__' class="images-item relative cursor-default rounded outline outline-2 outline-offset-2 outline-transparent">
<div class="image-selector absolute z-[2] top-0 right-0 overflow-hidden cursor-pointer sm:hidden group-hover:block">
<div class="p-1 text-xl sm:text-2xl">
@@ -2128,7 +2141,7 @@
</div>
</div>
<img alt="__name__" data-original="__url__" src="__thumb_url__" width="__width__" height="__height__">
@@ -114,22 +129,22 @@
@@ -114,22 +140,22 @@
<div id="album-add" class="flex flex-col w-full hidden border rounded p-2">
<p class="error-message text-white p-2 mb-2 text-sm bg-red-500 rounded hidden"></p>
<form class="w-full space-y-2" action="/user/albums">
@@ -2158,7 +2171,7 @@
</a>
</script>
@@ -137,9 +152,9 @@
@@ -137,71 +163,106 @@
<div id="album-edit" data-id="__id__" class="flex flex-col w-full border rounded p-2">
<p class="error-message text-white p-2 mb-2 text-sm bg-red-500 rounded hidden"></p>
<form class="w-full space-y-2" action="/user/albums/__id__">
@@ -2171,71 +2184,143 @@
</form>
</div>
</script>
@@ -148,51 +163,51 @@
<div class="my-4 px-4 space-y-3">
<div>
<span class="text-sm font-semibold">相册名称</span>
+ {{-- 图片「详细信息」:居中卡片弹窗(不再渲染进右侧抽屉)。
+ 字段顺序按老师要求:**上传时间第一、图片名称紧跟其后**,其余字段一个没删、只换了位置
+ (相册名称 / 使用策略 / 图片原始名称 / 图片大小 / 图片类型 / 尺寸 / MD5 / SHA-128 / 权限 / 上传 IP)。
+ 排版:标签在左、值在右(窄屏 flex-col 自动上下堆叠);标签 13px、值 14px,行高 leading-6 宽松一些。
+ 顶部一张小缩略图。这里不放任何快捷操作(复制链接/下载/删除都不进这个弹窗)。 --}}
<script type="text/html" id="image-detail-tpl">
- <div class="my-4 px-4 space-y-3">
- <div>
- <span class="text-sm font-semibold">相册名称</span>
- <p class="my-2 break-words text-gray-700">__album_name__</p>
+ <p class="my-2 break-words text-ink">__album_name__</p>
</div>
<div>
<div class="text-sm font-semibold">使用策略</div>
- </div>
- <div>
- <div class="text-sm font-semibold">使用策略</div>
- <p class="my-2 break-words text-gray-600">__strategy_name__</p>
+ <p class="my-2 break-words text-ink-2">__strategy_name__</p>
</div>
<div>
<div class="text-sm font-semibold">图片名称</div>
- </div>
- <div>
- <div class="text-sm font-semibold">图片名称</div>
- <p class="my-2 break-words text-gray-600">__filename__</p>
+ <p class="my-2 break-words text-ink-2">__filename__</p>
</div>
<div>
<div class="text-sm font-semibold">图片原始名称</div>
- </div>
- <div>
- <div class="text-sm font-semibold">图片原始名称</div>
- <p class="my-2 break-words text-gray-600">__origin_name__</p>
+ <p class="my-2 break-words text-ink-2">__origin_name__</p>
</div>
<div>
<div class="text-sm font-semibold">图片大小</div>
- </div>
- <div>
- <div class="text-sm font-semibold">图片大小</div>
- <p class="my-2 break-words text-gray-600">__size__</p>
+ <p class="my-2 break-words text-ink-2">__size__</p>
</div>
<div>
<div class="text-sm font-semibold">图片类型</div>
- </div>
- <div>
- <div class="text-sm font-semibold">图片类型</div>
- <p class="my-2 break-words text-gray-600">__mimetype__</p>
+ <p class="my-2 break-words text-ink-2">__mimetype__</p>
</div>
<div>
<div class="text-sm font-semibold">尺寸</div>
- </div>
- <div>
- <div class="text-sm font-semibold">尺寸</div>
- <p class="my-2 break-words text-gray-600">__width__ * __height__</p>
+ <p class="my-2 break-words text-ink-2">__width__ * __height__</p>
</div>
<div>
<div class="text-sm font-semibold">MD5</div>
- </div>
- <div>
- <div class="text-sm font-semibold">MD5</div>
- <p class="my-2 break-words text-gray-600">__md5__</p>
+ <p class="my-2 break-words text-ink-2">__md5__</p>
</div>
<div>
<div class="text-sm font-semibold">SHA-128</div>
- </div>
- <div>
- <div class="text-sm font-semibold">SHA-128</div>
- <p class="my-2 break-words text-gray-600">__sha1__</p>
+ <p class="my-2 break-words text-ink-2">__sha1__</p>
</div>
<div>
<div class="text-sm font-semibold">权限</div>
- </div>
- <div>
- <div class="text-sm font-semibold">权限</div>
- <p class="my-2 break-words text-gray-600">__permission__</p>
+ <p class="my-2 break-words text-ink-2">__permission__</p>
</div>
<div>
<div class="text-sm font-semibold">上传 IP</div>
- </div>
- <div>
- <div class="text-sm font-semibold">上传 IP</div>
- <p class="my-2 break-words text-gray-600">__uploaded_ip__</p>
+ <p class="my-2 break-words text-ink-2">__uploaded_ip__</p>
</div>
<div>
<div class="text-sm font-semibold">上传时间</div>
- </div>
- <div>
- <div class="text-sm font-semibold">上传时间</div>
- <p class="my-2 break-words text-gray-600">__created_at__</p>
+ <p class="my-2 break-words text-ink-2">__created_at__</p>
+ <div class="mx-auto w-full max-w-2xl">
+ <img src="__thumb_url__" alt="__filename__" class="mb-4 h-20 w-20 rounded-lg border border-line bg-surface-2 object-cover">
+ <dl class="space-y-3">
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">上传时间</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__created_at__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片名称</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__filename__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">相册名称</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__album_name__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">使用策略</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__strategy_name__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片原始名称</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__origin_name__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片大小</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__size__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片类型</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__mimetype__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">尺寸</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__width__ * __height__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">MD5</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__md5__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">SHA-128</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__sha1__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">权限</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__permission__</dd>
+ </div>
+ <div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">上传 IP</dt>
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__uploaded_ip__</dd>
+ </div>
+ </dl>
+ </div>
+ </script>
+
+ {{-- 「移动到相册」弹窗:相册列表(单选)+ 底部「移动」「取消」。
+ 列表数据与顶部工具栏的「相册列表」同一个接口;点一行只选中,底部「移动」才提交。
+ 每行 min-h-[44px](手机点击区 ≥44px),当前所在相册带「当前」标记(__current_badge__)。 --}}
+ <script type="text/html" id="movements-container-tpl">
+ <div class="mx-auto flex w-full max-w-xl flex-col">
+ <p class="text-[15px] font-semibold text-ink">移动到相册</p>
+ <p class="mt-0.5 mb-3 text-[13px] text-ink-3">已选择 __count__ 张图片</p>
+ <div id="movements-albums" class="flex max-h-[50vh] w-full flex-col space-y-1 overflow-y-auto pr-1"></div>
+ <div class="mt-4 flex justify-end gap-2 border-t border-line pt-3">
+ <button type="button" id="movements-cancel" class="ls-btn h-11 px-4 sm:h-9">取消</button>
+ <button type="button" id="movements-confirm" class="ls-btn ls-btn-primary h-11 px-4 sm:h-9" disabled>移动</button>
</div>
</div>
</script>
@@ -201,7 +216,8 @@
+ {{-- 相册列表行:整行是一个点击区(min-h-[44px]),不用 span(避免被无限加载的
+ "点 span 加载更多"委托命中)。选中态由 JS 切换 border-brand/bg-brand-soft/text-brand。 --}}
+ <script type="text/html" id="movements-album-item-tpl">
+ <a href="javascript:void(0)" data-id="__id__" data-selected="false" class="movements-album flex min-h-[44px] w-full items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3">
+ <i class="selected-mark fas fa-check-circle w-4 shrink-0 text-brand opacity-0" aria-hidden="true"></i>
+ <div class="min-w-0 flex-1 truncate text-[14px]">__name__</div>
+ __current_badge__
+ <div class="shrink-0 text-[13px] text-ink-3">__image_num__</div>
+ </a>
+ </script>
+
@push('scripts')
<script src="{{ asset('js/justified-gallery/jquery.justifiedGallery.min.js') }}"></script>
<script src="{{ asset('js/viewer-js/viewer.min.js') }}"></script>
<script src="{{ asset('js/dragselect/ds.min.js') }}"></script>
@@ -2245,7 +2330,19 @@
<script src="{{ asset('js/clipboard/index.browser.js') }}"></script>
<script src="{{ asset('js/clipboard/clipboard.min.js') }}"></script>
<script>
@@ -255,6 +271,7 @@
@@ -225,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) {
@@ -255,6 +321,7 @@
const imagesInfinite = utils.infiniteScroll(IMAGES_SCROLL, {
url: '{{ route('user.images') }}',
classes: ['dragselect'],
@@ -2253,7 +2350,7 @@
success: function (response) {
if (!response.status) {
return toastr.error(response.message);
@@ -308,7 +325,7 @@
@@ -308,7 +375,7 @@
}
const getAlbums = (options, callback) => {
@@ -2262,7 +2359,7 @@
let content = $('#albums-container-tpl').html();
drawer.toggle(title, content, function () {
let $albums = $('#albums-container');
@@ -337,8 +354,8 @@
@@ -337,8 +404,8 @@
if (albums[i].id === selectedAlbum.id) {
// 选中的相册高亮
item = item
@@ -2273,7 +2370,7 @@
}
html += item;
@@ -486,10 +503,10 @@
@@ -486,10 +553,10 @@
operates = ['refresh'];
}
if (selected.length === 1) {
@@ -2286,7 +2383,7 @@
}
if (selected.length && selectedAlbum.id !== undefined) {
operates.push('remove');
@@ -509,6 +526,26 @@
@@ -509,6 +576,26 @@
ds.subscribe('elementselect', _ => bindOperates());
ds.subscribe('elementunselect', _ => bindOperates());
@@ -2313,7 +2410,119 @@
$photos.on('click', '.image-selector', function () {
ds.toggleSelection($(this).closest('a'));
bindOperates();
@@ -575,7 +612,7 @@
@@ -535,25 +622,94 @@
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
+ ? '<div class="ls-badge shrink-0 bg-brand-soft text-brand">当前</div>'
+ : '')
+ }
+
+ $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'));
@@ -575,7 +731,7 @@
permission() {
Swal.fire({
title: '选择一个权限',
@@ -2322,7 +2531,26 @@
input: 'select',
inputOptions: {
public: '公开',
@@ -853,6 +890,10 @@
@@ -684,6 +840,7 @@
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, '$$$$'))
@@ -697,7 +854,9 @@
.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);
}
@@ -853,6 +1012,10 @@
case 'delete': // 删除
methods.delete();
break;
+1 -1
View File
@@ -1 +1 @@
/*! tailwindcss v3.0.23 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Noto Sans SC,ui-sans-serif,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-size:100%;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],select,textarea{--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,select:focus,textarea:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{-webkit-print-color-adjust:exact;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;color-adjust:exact;padding-right:2.5rem}[multiple]{-webkit-print-color-adjust:unset;background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;color-adjust:unset;padding-right:.75rem}[type=checkbox],[type=radio]{-webkit-print-color-adjust:exact;--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;color-adjust:exact;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-usLine truncated
/*! tailwindcss v3.0.23 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Noto Sans SC,ui-sans-serif,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-size:100%;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],select,textarea{--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,select:focus,textarea:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{-webkit-print-color-adjust:exact;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;color-adjust:exact;padding-right:2.5rem}[multiple]{-webkit-print-color-adjust:unset;background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;color-adjust:unset;padding-right:.75rem}[type=checkbox],[type=radio]{-webkit-print-color-adjust:exact;--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;color-adjust:exact;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-usLine truncated
+1 -1
View File
@@ -1 +1 @@
.nav-header{color:var(--lsky-text-3);display:block;font-size:12.5px;font-weight:700;line-height:20px;padding:3px 15px;text-shadow:none;text-transform:uppercase}.dropdown-menu{background-clip:padding-box;background-color:var(--lsky-surface);border:0;border-radius:6px;box-shadow:var(--lsky-shadow-pop);display:none;float:left;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;left:0;list-style:none;margin:2px 0 0;min-width:160px;padding:5px 0;position:absolute;text-align:left;top:100%;z-index:1000}.dropdown-menu.pull-right{left:auto;right:0}.dropdown-menu .divider{background-color:var(--lsky-surface-3);border-bottom:1px solid var(--lsky-border);height:1px;margin:9px 1px;overflow:hidden}.dropdown-menu a{clear:both;color:var(--lsky-text);display:block;font-weight:400;line-height:20px;padding:5px 20px;text-decoration:none;white-space:nowrap}.dropdown-menu .disabled>a:hover{background-color:transparent;cursor:default;text-decoration:none}.dropdown-menu .active>a,.dropdown-menu .active>a:hover,.dropdown-menu .dropdown-submenu:hover>a,.dropdown-menu li>a:focus,.dropdown-menu li>a:hover{background-color:var(--lsky-accent);background-image:none;background-repeat:repeat-x;color:#fff;text-decoration:none}.dropdown-menu .active>a,.dropdown-menu .active>a:hover{outline:0}.dropdown-menu .disabled>a,.dropdown-menu .disabled>a:hover{color:var(--lsky-text-3)}.dropdown-menu .open{*z-index:1000}.dropdown-menu .open>.dropdown-menu{display:block}.dropdown-menu .pull-right>.dropdown-menu{left:auto;right:0}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-bottom:4px solid var(--lsky-border-2);border-top:0;content:"\2191"}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{bottom:100%;margin-bottom:1px;top:auto}.dropdown-submenu{position:relative}.dropdown-submenu>.dropdown-menu{border-radius:0 6px 6px 6px;left:100%;margin-left:-1px;margin-top:-6px;top:0}.dropdown-submenu>.dropdown-menu.drop-left{left:-100%}.dropdown-submenu:hover .dropdown-menu{display:block}.dropdown-submenu:hover>a:after{border-left-color:var(--lsky-surface)}.dropdown-submenu>a:after{border-bottom:5px solid transparent;border-left:5px solid transparent;border-left-color:var(--lsky-border-2);border-right:0 solid transparent;border-top:5px solid transparent;content:" ";display:block;float:right;height:0;margin-right:-10px;margin-top:5px;width:0}.dropdown .dropdown-menu .nav-header{padding-left:20px;padding-right:20px}.dropdown-context .nav-header{cursor:default}.dropdown-context .dropdown-submenu:hover .dropdown-menu{display:none}.dropdown-context .dropdown-submenu:hover>.dropdown-menu{display:block}.dropdown-context-up:before,.dropdown-context:before{border-bottom:7px solid rgba(0,0,0,.2);border-left:7px solid transparent;border-right:7px solid transparent;content:"";display:inline-block;left:9px;position:absolute;top:-7px}.dropdown-context-up:after,.dropdown-context:after{border-bottom:6px solid var(--lsky-surface);border-left:6px solid transparent;border-right:6px solid transparent;content:"";display:inline-block;left:10px;position:absolute;top:-6px}.dropdown-context-up:after,.dropdown-context-up:before{bottom:-7px;top:auto;z-index:9999}.dropdown-context-up:before{border-bottom:none;border-left:7px solid transparent;border-right:7px solid transparent;border-top:7px solid var(--lsky-border-2)}.dropdown-context-up:after{border-bottom:none;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid var(--lsky-surface)}.dropdown-context-sub:after,.dropdown-context-sub:before{display:none}.compressed-context a{font-size:13.5px;padding-bottom:0;padding-left:14px;padding-top:0}.compressed-context .divider{margin:5px 1px}.compressed-context .nav-header{padding:1px 13px}
.nav-header{color:var(--lsky-text-3);display:block;font-size:12.5px;font-weight:700;line-height:20px;padding:3px 15px;text-shadow:none;text-transform:uppercase}.dropdown-menu{background-clip:padding-box;background-color:var(--lsky-surface);border:0;border-radius:6px;box-shadow:var(--lsky-shadow-pop);display:none;float:left;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;left:0;list-style:none;margin:2px 0 0;min-width:160px;padding:5px 0;position:absolute;text-align:left;top:100%;z-index:1000}.dropdown-menu.pull-right{left:auto;right:0}.dropdown-menu .divider{background-color:var(--lsky-surface-3);border-bottom:1px solid var(--lsky-border);height:1px;margin:9px 1px;overflow:hidden}.dropdown-menu a{clear:both;color:var(--lsky-text);display:block;font-weight:400;line-height:20px;padding:5px 20px;text-decoration:none;white-space:nowrap}.dropdown-menu .disabled>a:hover{background-color:transparent;cursor:default;text-decoration:none}.dropdown-menu .active>a,.dropdown-menu .active>a:hover,.dropdown-menu .dropdown-submenu:hover>a,.dropdown-menu li>a:focus,.dropdown-menu li>a:hover{background-color:var(--lsky-accent);background-image:none;background-repeat:repeat-x;color:#fff;text-decoration:none}.dropdown-menu .active>a,.dropdown-menu .active>a:hover{outline:0}.dropdown-menu .disabled>a,.dropdown-menu .disabled>a:hover{color:var(--lsky-text-3)}.dropdown-menu .open{*z-index:1000}.dropdown-menu .open>.dropdown-menu{display:block}.dropdown-menu .pull-right>.dropdown-menu{left:auto;right:0}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-bottom:4px solid var(--lsky-border-2);border-top:0;content:"\2191"}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{bottom:100%;margin-bottom:1px;top:auto}.dropdown-submenu{position:relative}.dropdown-submenu>.dropdown-menu{border-radius:0 6px 6px 6px;left:100%;margin-left:-1px;margin-top:-6px;top:0}.dropdown-submenu>.dropdown-menu.drop-left{left:-100%}.dropdown-submenu:hover .dropdown-menu{display:block}.dropdown-submenu:hover>a:after{border-left-color:var(--lsky-surface)}.dropdown-submenu>a:after{border-bottom:5px solid transparent;border-left:5px solid transparent;border-left-color:var(--lsky-border-2);border-right:0 solid transparent;border-top:5px solid transparent;content:" ";display:block;float:right;height:0;margin-right:-10px;margin-top:5px;width:0}.dropdown .dropdown-menu .nav-header{padding-left:20px;padding-right:20px}.dropdown-context .nav-header{cursor:default}.dropdown-context .dropdown-submenu:hover .dropdown-menu{display:none}.dropdown-context .dropdown-submenu:hover>.dropdown-menu{display:block}.dropdown-context .submenu-back>a>i{margin-right:7px}.dropdown-context-up:before,.dropdown-context:before{border-bottom:7px solid rgba(0,0,0,.2);border-left:7px solid transparent;border-right:7px solid transparent;content:"";display:inline-block;left:9px;position:absolute;top:-7px}.dropdown-context-up:after,.dropdown-context:after{border-bottom:6px solid var(--lsky-surface);border-left:6px solid transparent;border-right:6px solid transparent;content:"";display:inline-block;left:10px;position:absolute;top:-6px}.dropdown-context-up:after,.dropdown-context-up:before{bottom:-7px;top:auto;z-index:9999}.dropdown-context-up:before{border-bottom:none;border-left:7px solid transparent;border-right:7px solid transparent;border-top:7px solid var(--lsky-border-2)}.dropdown-context-up:after{border-bottom:none;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid var(--lsky-surface)}.dropdown-context-sub:after,.dropdown-context-sub:before{display:none}.compressed-context a{font-size:13.5px;padding-bottom:0;padding-left:14px;padding-top:0}.compressed-context .divider{margin:5px 1px}.compressed-context .nav-header{padding:1px 13px}
+7
View File
@@ -167,6 +167,13 @@
}
}
}
/* 手机端二级菜单「就地展开」时,顶部那行「< 返回」里的箭头与文字贴在一起很难看:
箭头是图标(fas fa-chevron-left,标记里自带 mr-1 ≈ 4px),这里统一补到 7px 间距。
三条类选择器的权重高于工具类 .mr-1,所以不依赖 CSS 打包顺序;其余样式一个字没动。 */
.dropdown-context .submenu-back > a > i {
margin-right: 7px;
}
.dropdown-context:before, .dropdown-context-up:before {
position: absolute;
top: -7px;
+188 -66
View File
@@ -104,6 +104,17 @@
</div>
</div>
{{-- 图片「详细信息」与「移动到相册」改用居中卡片弹窗(复用 components/modal.blade.php,
手机上它就是「底部抽屉式」——弹窗容器在 <640px 时贴底、不依赖 hover)。
右侧抽屉 #drawer 保留它原来的本职:顶部工具栏的「相册列表」入口照旧走抽屉。 --}}
<x-modal id="image-detail-modal">
<div id="image-detail-content"></div>
</x-modal>
<x-modal id="image-movements-modal">
<div id="image-movements-content"></div>
</x-modal>
<script type="text/html" id="images-item-tpl">
<a href="javascript:void(0)" data-id="__id__" data-json='__json__' class="images-item relative cursor-default rounded outline outline-2 outline-offset-2 outline-transparent">
<div class="image-selector absolute z-[2] top-0 right-0 overflow-hidden cursor-pointer sm:hidden group-hover:block">
@@ -159,59 +170,93 @@
</div>
</script>
{{-- 图片「详细信息」:居中卡片弹窗(不再渲染进右侧抽屉)。
字段顺序按老师要求:**上传时间第一、图片名称紧跟其后**,其余字段一个没删、只换了位置
(相册名称 / 使用策略 / 图片原始名称 / 图片大小 / 图片类型 / 尺寸 / MD5 / SHA-128 / 权限 / 上传 IP)。
排版:标签在左、值在右(窄屏 flex-col 自动上下堆叠);标签 13px、值 14px,行高 leading-6 宽松一些。
顶部一张小缩略图。这里不放任何快捷操作(复制链接/下载/删除都不进这个弹窗)。 --}}
<script type="text/html" id="image-detail-tpl">
<div class="my-4 px-4 space-y-3">
<div>
<span class="text-sm font-semibold">相册名称</span>
<p class="my-2 break-words text-ink">__album_name__</p>
</div>
<div>
<div class="text-sm font-semibold">使用策略</div>
<p class="my-2 break-words text-ink-2">__strategy_name__</p>
</div>
<div>
<div class="text-sm font-semibold">图片名称</div>
<p class="my-2 break-words text-ink-2">__filename__</p>
</div>
<div>
<div class="text-sm font-semibold">图片原始名称</div>
<p class="my-2 break-words text-ink-2">__origin_name__</p>
</div>
<div>
<div class="text-sm font-semibold">图片大小</div>
<p class="my-2 break-words text-ink-2">__size__</p>
</div>
<div>
<div class="text-sm font-semibold">图片类型</div>
<p class="my-2 break-words text-ink-2">__mimetype__</p>
</div>
<div>
<div class="text-sm font-semibold">尺寸</div>
<p class="my-2 break-words text-ink-2">__width__ * __height__</p>
</div>
<div>
<div class="text-sm font-semibold">MD5</div>
<p class="my-2 break-words text-ink-2">__md5__</p>
</div>
<div>
<div class="text-sm font-semibold">SHA-128</div>
<p class="my-2 break-words text-ink-2">__sha1__</p>
</div>
<div>
<div class="text-sm font-semibold">权限</div>
<p class="my-2 break-words text-ink-2">__permission__</p>
</div>
<div>
<div class="text-sm font-semibold">上传 IP</div>
<p class="my-2 break-words text-ink-2">__uploaded_ip__</p>
</div>
<div>
<div class="text-sm font-semibold">上传时间</div>
<p class="my-2 break-words text-ink-2">__created_at__</p>
<div class="mx-auto w-full max-w-2xl">
<img src="__thumb_url__" alt="__filename__" class="mb-4 h-20 w-20 rounded-lg border border-line bg-surface-2 object-cover">
<dl class="space-y-3">
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">上传时间</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__created_at__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片名称</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__filename__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">相册名称</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__album_name__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">使用策略</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__strategy_name__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片原始名称</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__origin_name__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片大小</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__size__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片类型</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__mimetype__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">尺寸</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__width__ * __height__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">MD5</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__md5__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">SHA-128</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__sha1__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">权限</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__permission__</dd>
</div>
<div class="flex flex-col gap-0.5 sm:flex-row sm:gap-4">
<dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">上传 IP</dt>
<dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__uploaded_ip__</dd>
</div>
</dl>
</div>
</script>
{{-- 「移动到相册」弹窗:相册列表(单选)+ 底部「移动」「取消」。
列表数据与顶部工具栏的「相册列表」同一个接口;点一行只选中,底部「移动」才提交。
每行 min-h-[44px](手机点击区 ≥44px),当前所在相册带「当前」标记(__current_badge__)。 --}}
<script type="text/html" id="movements-container-tpl">
<div class="mx-auto flex w-full max-w-xl flex-col">
<p class="text-[15px] font-semibold text-ink">移动到相册</p>
<p class="mt-0.5 mb-3 text-[13px] text-ink-3">已选择 __count__ 张图片</p>
<div id="movements-albums" class="flex max-h-[50vh] w-full flex-col space-y-1 overflow-y-auto pr-1"></div>
<div class="mt-4 flex justify-end gap-2 border-t border-line pt-3">
<button type="button" id="movements-cancel" class="ls-btn h-11 px-4 sm:h-9">取消</button>
<button type="button" id="movements-confirm" class="ls-btn ls-btn-primary h-11 px-4 sm:h-9" disabled>移动</button>
</div>
</div>
</script>
{{-- 相册列表行:整行是一个点击区(min-h-[44px]),不用 span(避免被无限加载的
"点 span 加载更多"委托命中)。选中态由 JS 切换 border-brand/bg-brand-soft/text-brand。 --}}
<script type="text/html" id="movements-album-item-tpl">
<a href="javascript:void(0)" data-id="__id__" data-selected="false" class="movements-album flex min-h-[44px] w-full items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3">
<i class="selected-mark fas fa-check-circle w-4 shrink-0 text-brand opacity-0" aria-hidden="true"></i>
<div class="min-w-0 flex-1 truncate text-[14px]">__name__</div>
__current_badge__
<div class="shrink-0 text-[13px] text-ink-3">__image_num__</div>
</a>
</script>
@push('scripts')
<script src="{{ asset('js/justified-gallery/jquery.justifiedGallery.min.js') }}"></script>
<script src="{{ asset('js/viewer-js/viewer.min.js') }}"></script>
@@ -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
? '<div class="ls-badge shrink-0 bg-brand-soft text-brand">当前</div>'
: '')
}
$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);
}
+296
View File
@@ -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 = /<script>([\s\S]*?)<\/script>/g;
let m;
while ((m = re.exec(tail))) {
out.push(m[1].replace(/\{\{[\s\S]*?\}\}/g, '/stub').replace(/\{!![\s\S]*?!!\}/g, ''));
}
return out;
}
function tpl(id) {
const start = blade.indexOf(`<script type="text/html" id="${id}">`);
const end = blade.indexOf('</script>', 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(`<!DOCTYPE html><html><body>
<span id="header-title"></span>
<div id="images-scroll"><div id="images-grid">
<a class="images-item" data-id="7" href="javascript:void(0)"
data-json='${JSON.stringify(IMAGE).replace(/'/g, '&#39;')}'>
<img alt="beach.jpg" data-original="https://i.example.com/beach.jpg" src="https://i.example.com/beach-thumb.jpg">
</a>
</div></div>
<div id="drawer-mask" class="hidden"></div>
<div id="drawer"><span id="drawer-title"></span><div id="drawer-content"></div></div>
<div id="image-detail-modal" class="hidden"><div id="image-detail-content"></div></div>
<div id="image-movements-modal" class="hidden"><div id="image-movements-content"></div></div>
<a data-operate="movements" class="hidden" href="javascript:void(0)">移动到相册</a>
<a data-operate="detail" class="hidden" href="javascript:void(0)">详细信息</a>
<input id="search">
<script type="text/html" id="image-detail-tpl">${tpl('image-detail-tpl')}</script>
<script type="text/html" id="movements-container-tpl">${tpl('movements-container-tpl')}</script>
<script type="text/html" id="movements-album-item-tpl">${tpl('movements-album-item-tpl')}</script>
<script type="text/html" id="albums-container-tpl"><div id="albums-container"></div></script>
<script type="text/html" id="albums-item-tpl"><a class="albums-item" data-id="__id__" data-json='__json__' title="__intro__"><span class="name">__name__</span></a></script>
</body></html>`, { 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);
+202
View File
@@ -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 : ''}`);
}
// 取出一整块 <script type="text/html" id="xxx"> … </script>
function tpl(id) {
const start = blade.indexOf(`<script type="text/html" id="${id}">`);
if (start < 0) return '';
const end = blade.indexOf('</script>', start);
return end < 0 ? '' : blade.slice(start, end + '</script>'.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('详细信息弹窗:<x-modal id="image-detail-modal"> + #image-detail-content',
blade.includes('<x-modal id="image-detail-modal">') && blade.includes('id="image-detail-content"'));
check('移动到相册弹窗:<x-modal id="image-movements-modal"> + #image-movements-content',
blade.includes('<x-modal id="image-movements-modal">') && 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}</dt>`));
check('12 个字段全在(与改前一致,一个没删)', missing.length === 0, missing.join(' | '));
const at = (l) => detailTpl.indexOf(`>${l}</dt>`);
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('<i class="fas fa-chevron-left mr-1"></i>返回'));
// 选择器权重(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);