Files
laoyang cbfdcb9d16 附件:支持视频 / 文档上传,可预览的浏览器直接看,预览不了的给下载
之前节点附件只能传图片和 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 渲染逐字节一致。
2026-09-23 16:22:01 +08:00

177 lines
7.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 照片 / 附件灯箱:点页面里带 class="js-photo"(图片)或 "js-media"(任意附件)的链接,
* 就在当前页弹窗里预览,左右箭头(或键盘 ←/→、触摸滑动)切换,不再新开窗口。
*
* 按链接的 data-kind 决定预览方式(没有 data-kind 视为 image):
* image —— <img> 大图
* video —— <video controls> 直接播放(media/show 支持 Range,可拖进度条)
* pdf —— <iframe> 内嵌(浏览器内建 PDF 阅读器)
* file —— 浏览器预览不了的(Word / ZIP 等):给出说明 + 下载按钮
*
* 用法:
* <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-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="" 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>
<button type="button" class="lightbox-nav lightbox-next" data-lb-next="1" aria-label="下一张">&#8250;</button>
<button type="button" class="lightbox-close" data-lb-close="1" aria-label="关闭">×</button>
<div class="lightbox-count" id="lightbox-count"></div>
</div>
<script>
(function () {
'use strict';
var box = document.getElementById('lightbox');
if (!box) return;
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 / js-media(DOM 顺序)
var idx = 0;
function collect() {
return Array.prototype.slice.call(document.querySelectorAll('a.js-photo, a.js-media'));
}
function captionOf(a) {
return a.getAttribute('data-cap') || a.getAttribute('title')
|| (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');
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 : '';
// 预加载相邻两张图片(视频 / PDF 体积大,不预载)
[idx - 1, idx + 1].forEach(function (j) {
var na = items[(j + items.length) % items.length];
if (!na || kindOf(na) !== 'image') return;
var pre = new Image();
pre.src = na.getAttribute('data-full') || na.getAttribute('href');
});
}
function openAt(anchor) {
items = collect();
var at = items.indexOf(anchor);
if (at < 0) { items = [anchor]; at = 0; }
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
show(at);
}
function close() {
showView('none');
box.classList.remove('open');
box.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
img.src = '';
}
document.addEventListener('click', function (ev) {
var link = ev.target.closest ? ev.target.closest('a.js-photo, a.js-media') : null;
if (link) {
ev.preventDefault();
openAt(link);
return;
}
if (!box.classList.contains('open')) return;
if (ev.target.closest('[data-lb-prev]')) { show(idx - 1); return; }
if (ev.target.closest('[data-lb-next]')) { show(idx + 1); return; }
if (ev.target.closest('[data-lb-close]')) { close(); }
});
document.addEventListener('keydown', function (ev) {
if (!box.classList.contains('open')) return;
if (ev.key === 'Escape') close();
else if (ev.key === 'ArrowLeft') show(idx - 1);
else if (ev.key === 'ArrowRight') show(idx + 1);
});
// 触摸滑动切图(手机上不用去够箭头)
var touchX = null, touchY = null;
box.addEventListener('touchstart', function (ev) {
if (ev.touches.length === 1) {
touchX = ev.touches[0].clientX;
touchY = ev.touches[0].clientY;
}
}, { passive: true });
box.addEventListener('touchend', function (ev) {
if (touchX === null || !ev.changedTouches.length) return;
var dx = ev.changedTouches[0].clientX - touchX;
var dy = ev.changedTouches[0].clientY - touchY;
if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy) * 1.5) {
show(dx < 0 ? idx + 1 : idx - 1);
}
touchX = touchY = null;
}, { passive: true });
})();
</script>