附件:支持视频 / 文档上传,可预览的浏览器直接看,预览不了的给下载

之前节点附件只能传图片和 PDF(accept 过滤 + 20MB 上限),视频传不上来。
本批放开:MP4 / MOV / PDF / Word / Excel / ZIP 都能作为附件上传,
仍走同一份 Uploads::handleFiles(MIME 白名单三层识别不变),存进已配置的 OSS。

大小分级:照片仍走 photo_max_mb(管理员可调),视频与文档放宽到 40MB
(Uploads::MAX_FILE_BYTES,给线上 post_max_size=50M 留余量),先 detect 再按
is_image 选上限。前端 form.php / attachments.php 同步拦截并按类型细分暂存区图标。

预览走灯箱四类型视图:图片(img)/ 视频(<video>,配合 media/show 新增的
HTTP Range 单区间 206/416,进度条可拖、Safari 也能播)/ PDF(iframe 内嵌
阅读器)/ 其它(Word / ZIP 等浏览器预览不了 → 灯箱里给下载按钮)。
dl=1 下载不接 Range;多区间回退 200 全量。

已验证:本地全链 11 步全绿(渲染检查 308 项、表单交互 73 项);
远端 e2e 0 失败(新增 mp4 上传落库 video/mp4、Range 206/416/后缀区间/
多区间回退/dl 不接 Range 专项断言);部署后双 PHP 渲染逐字节一致。
This commit is contained in:
2026-09-23 16:22:01 +08:00
parent c50bc7de2b
commit cbfdcb9d16
6 changed files with 231 additions and 47 deletions
+70 -19
View File
@@ -1,23 +1,37 @@
<?php
/**
* 照片灯箱(Lightbox):点页面里带 class="js-photo" 的链接,就在当前页弹窗里看大图,
* 左右箭头(或键盘 ←/→、触摸滑动)切上一张/下一张,不再新开窗口。
* 照片 / 附件灯箱:点页面里带 class="js-photo"(图片)或 "js-media"(任意附件)的链接,
* 就在当前页弹窗里预览,左右箭头(或键盘 ←/→、触摸滑动)切换,不再新开窗口。
*
* 按链接的 data-kind 决定预览方式(没有 data-kind 视为 image):
* image —— <img> 大图
* video —— <video controls> 直接播放(media/show 支持 Range,可拖进度条)
* pdf —— <iframe> 内嵌(浏览器内建 PDF 阅读器)
* file —— 浏览器预览不了的(Word / ZIP 等):给出说明 + 下载按钮
*
* 用法:
* 1. 图片链接写成 <a class="js-photo" href="<大图地址>" data-cap="<说明文字>">…</a>
* (没有 data-cap 时回落到 title / alt / href)
* 2. 在页面末尾 require 本模板(一份就够,多页共用同一份标记与脚本)
* <a class="js-media" data-kind="video" href="<播放地址>" data-dl="<下载地址>" data-cap="名字">
* (data-dl 可选,file 视图的下载按钮用它;没有就退回 href + &dl=1)
* 在页面末尾 require 本模板(一份就够)。
*
* 依赖外部变量:无。
*/
?>
<div class="lightbox" id="lightbox" aria-hidden="true" role="dialog" aria-label="图片浏览">
<div class="lightbox" id="lightbox" aria-hidden="true" role="dialog" aria-label="附件浏览">
<div class="lightbox-backdrop" data-lb-close="1"></div>
<button type="button" class="lightbox-nav lightbox-prev" data-lb-prev="1" aria-label="上一张">&#8249;</button>
<figure class="lightbox-body">
<img class="lightbox-img" id="lightbox-img" alt="">
<img class="lightbox-img" id="lightbox-img" alt="" hidden>
<video class="lightbox-video" id="lightbox-video" controls playsinline preload="metadata" hidden></video>
<iframe class="lightbox-frame" id="lightbox-frame" title="预览" hidden></iframe>
<div class="lightbox-file" id="lightbox-file" hidden>
<div class="lightbox-file-icon">📎</div>
<p class="lightbox-file-text" id="lightbox-file-name"></p>
<p class="lightbox-file-hint">这种格式浏览器打不开,点击下面按钮下载后查看</p>
<a class="btn-primary lightbox-file-dl" id="lightbox-file-dl" href="#">下载文件</a>
</div>
<figcaption class="lightbox-cap" id="lightbox-cap"></figcaption>
</figure>
@@ -31,17 +45,22 @@
(function () {
'use strict';
var box = document.getElementById('lightbox');
var box = document.getElementById('lightbox');
if (!box) return;
var img = document.getElementById('lightbox-img');
var cap = document.getElementById('lightbox-cap');
var count = document.getElementById('lightbox-count');
var img = document.getElementById('lightbox-img');
var video = document.getElementById('lightbox-video');
var frame = document.getElementById('lightbox-frame');
var fileBox = document.getElementById('lightbox-file');
var fileName = document.getElementById('lightbox-file-name');
var fileDl = document.getElementById('lightbox-file-dl');
var cap = document.getElementById('lightbox-cap');
var count = document.getElementById('lightbox-count');
var items = []; // 打开时现收集页面上所有 js-photo(DOM 顺序)
var items = []; // 打开时现收集页面上所有 js-photo / js-media(DOM 顺序)
var idx = 0;
function collect() {
return Array.prototype.slice.call(document.querySelectorAll('a.js-photo'));
return Array.prototype.slice.call(document.querySelectorAll('a.js-photo, a.js-media'));
}
function captionOf(a) {
@@ -49,19 +68,50 @@
|| (a.querySelector('img') ? a.querySelector('img').alt : '') || '';
}
function kindOf(a) {
var k = a.getAttribute('data-kind');
return (k === 'video' || k === 'pdf' || k === 'file') ? k : 'image';
}
function downloadOf(a) {
return a.getAttribute('data-dl')
|| (a.getAttribute('href') || '').replace(/&dl=1$/, '') + '&dl=1';
}
function showView(kind) {
img.hidden = kind !== 'image';
video.hidden = kind !== 'video';
frame.hidden = kind !== 'pdf';
fileBox.hidden = kind !== 'file';
if (kind !== 'video') { video.pause(); video.removeAttribute('src'); video.load(); }
if (kind !== 'pdf') { frame.src = 'about:blank'; }
}
function show(i) {
if (!items.length) return;
idx = (i + items.length) % items.length; // 循环切换
var a = items[idx];
var kind = kindOf(a);
var full = a.getAttribute('data-full') || a.getAttribute('href');
img.alt = captionOf(a);
cap.textContent = captionOf(a);
var capText = captionOf(a);
showView(kind);
if (kind === 'image') img.src = full;
else if (kind === 'video') { video.src = full; video.load(); }
else if (kind === 'pdf') frame.src = full;
else {
fileName.textContent = capText || '附件';
fileDl.href = downloadOf(a);
}
img.alt = capText;
cap.textContent = capText;
count.textContent = items.length > 1 ? (idx + 1) + ' / ' + items.length : '';
img.src = full;
// 预加载相邻两张,切换不等待
// 预加载相邻两张图片(视频 / PDF 体积大,不预载)
[idx - 1, idx + 1].forEach(function (j) {
var na = items[(j + items.length) % items.length];
if (!na) return;
if (!na || kindOf(na) !== 'image') return;
var pre = new Image();
pre.src = na.getAttribute('data-full') || na.getAttribute('href');
});
@@ -78,6 +128,7 @@
}
function close() {
showView('none');
box.classList.remove('open');
box.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
@@ -85,7 +136,7 @@
}
document.addEventListener('click', function (ev) {
var link = ev.target.closest ? ev.target.closest('a.js-photo') : null;
var link = ev.target.closest ? ev.target.closest('a.js-photo, a.js-media') : null;
if (link) {
ev.preventDefault();
openAt(link);