附件:支持视频 / 文档上传,可预览的浏览器直接看,预览不了的给下载
之前节点附件只能传图片和 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:
@@ -1192,3 +1192,36 @@ input[type="date"]::-webkit-calendar-picker-indicator { cursor: pointer; padding
|
|||||||
}
|
}
|
||||||
.node-member-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.node-member-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.node-member-emotion { font-size: 13px; line-height: 1; }
|
.node-member-emotion { font-size: 13px; line-height: 1; }
|
||||||
|
|
||||||
|
/* ── 19. 灯箱的视频 / PDF / 下载视图 ──────────────────────────────────────── */
|
||||||
|
.lightbox-video {
|
||||||
|
max-width: min(1100px, 92vw);
|
||||||
|
max-height: 74vh;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #000;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.lightbox-frame {
|
||||||
|
width: min(1000px, 92vw);
|
||||||
|
height: 78vh;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.lightbox-file {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||||
|
padding: 36px 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-width: min(420px, 88vw);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.lightbox-file-icon { font-size: 52px; line-height: 1; }
|
||||||
|
.lightbox-file-text {
|
||||||
|
margin: 0; font-size: 15px; font-weight: 650; color: var(--text);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.lightbox-file-hint { margin: 0; font-size: 13px; color: var(--text-mute); }
|
||||||
|
.lightbox-file-dl { display: inline-block; }
|
||||||
|
|||||||
@@ -107,7 +107,13 @@ class MediaController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 输出二进制内容;$download=true 时强制下载而不是内联预览 */
|
/**
|
||||||
|
* 输出二进制内容;$download=true 时强制下载而不是内联预览。
|
||||||
|
*
|
||||||
|
* 支持 HTTP Range(单区间):浏览器里的 <video> 要靠 206 才能拖动进度条,
|
||||||
|
* Safari 更是不给 Range 就直接不播。文件最大 40MB 且已整体读进 $body,
|
||||||
|
* 这里只做内存内切片,不再碰存储后端(OSS 的 Range GET 收益不大、复杂度不低)。
|
||||||
|
*/
|
||||||
private function stream(string $body, string $mime, string $fileName, bool $download): void
|
private function stream(string $body, string $mime, string $fileName, bool $download): void
|
||||||
{
|
{
|
||||||
// 别让 PHP 的报错/警告混进图片正文(那会让浏览器把图片判成损坏)
|
// 别让 PHP 的报错/警告混进图片正文(那会让浏览器把图片判成损坏)
|
||||||
@@ -118,14 +124,48 @@ class MediaController
|
|||||||
$ascii = str_replace(['"', '\\'], '_', $ascii);
|
$ascii = str_replace(['"', '\\'], '_', $ascii);
|
||||||
|
|
||||||
header('Content-Type: ' . $mime);
|
header('Content-Type: ' . $mime);
|
||||||
header('Content-Length: ' . strlen($body));
|
header('Accept-Ranges: bytes');
|
||||||
header('Content-Disposition: ' . ($download ? 'attachment' : 'inline')
|
|
||||||
. '; filename="' . $ascii . '"; filename*=UTF-8\'\'' . rawurlencode($fileName));
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
// 私有内容:允许浏览器缓存,但禁止中间代理缓存
|
// 私有内容:允许浏览器缓存,但禁止中间代理缓存
|
||||||
header('Cache-Control: private, max-age=86400');
|
header('Cache-Control: private, max-age=86400');
|
||||||
header('Pragma: private');
|
header('Pragma: private');
|
||||||
|
|
||||||
|
$size = strlen($body);
|
||||||
|
|
||||||
|
// 下载不接 Range(客户端要的是完整文件);多区间(带逗号)不支持,回退 200 全量
|
||||||
|
$rangeHeader = $download ? '' : (string)($_SERVER['HTTP_RANGE'] ?? '');
|
||||||
|
if ($rangeHeader !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', trim($rangeHeader), $m)
|
||||||
|
&& strpos($rangeHeader, ',') === false) {
|
||||||
|
if ($m[1] === '' && $m[2] === '') {
|
||||||
|
// bytes=- 无意义,回退 200
|
||||||
|
} elseif ($size > 0) {
|
||||||
|
if ($m[1] === '') {
|
||||||
|
// bytes=-N → 末尾 N 字节
|
||||||
|
$len = min((int)$m[2], $size);
|
||||||
|
$start = $size - $len;
|
||||||
|
$end = $size - 1;
|
||||||
|
} else {
|
||||||
|
$start = (int)$m[1];
|
||||||
|
$end = $m[2] === '' ? $size - 1 : min((int)$m[2], $size - 1);
|
||||||
|
}
|
||||||
|
if ($start > $end || $start >= $size) {
|
||||||
|
http_response_code(416);
|
||||||
|
header("Content-Range: bytes */$size");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code(206);
|
||||||
|
header("Content-Range: bytes $start-$end/$size");
|
||||||
|
header('Content-Length: ' . ($end - $start + 1));
|
||||||
|
header('Content-Disposition: inline; filename="' . $ascii . '"; filename*=UTF-8\'\''
|
||||||
|
. rawurlencode($fileName));
|
||||||
|
echo substr($body, $start, $end - $start + 1);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Length: ' . $size);
|
||||||
|
header('Content-Disposition: ' . ($download ? 'attachment' : 'inline')
|
||||||
|
. '; filename="' . $ascii . '"; filename*=UTF-8\'\'' . rawurlencode($fileName));
|
||||||
echo $body;
|
echo $body;
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-7
@@ -20,9 +20,17 @@ final class Uploads
|
|||||||
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
||||||
public const MAX_FILES = 30;
|
public const MAX_FILES = 30;
|
||||||
|
|
||||||
/** 单文件硬上限 */
|
/** 单文件硬上限(兜底值;实际限制见 handleFiles 里的分级逻辑) */
|
||||||
public const MAX_BYTES = 20971520; // 20MB
|
public const MAX_BYTES = 20971520; // 20MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非图片文件(视频 / PDF / Word 等)的单文件上限。
|
||||||
|
* 视频普遍比照片大,20MB 根本不够用;但这再大也大不过服务器的
|
||||||
|
* post_max_size(超了整个请求会被 PHP 静默丢弃),所以取 40MB,
|
||||||
|
* 给 50M 的 post_max_size 留出表单字段的余量。
|
||||||
|
*/
|
||||||
|
public const MAX_FILE_BYTES = 41943040; // 40MB
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 php.ini 里的 "8M" / "50M" 这类写法换算成字节数。
|
* 把 php.ini 里的 "8M" / "50M" 这类写法换算成字节数。
|
||||||
*
|
*
|
||||||
@@ -89,22 +97,26 @@ final class Uploads
|
|||||||
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$limit = $photoMax > 0 ? max($photoMax, self::MAX_BYTES) : self::MAX_BYTES;
|
|
||||||
if ($file['size'] > $limit) {
|
|
||||||
$errors[] = $file['name'] . ':超过大小上限';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* 先识别类型再限大小:图片走管理员设的 photo_max_mb(12MB),
|
||||||
|
视频 / PDF / Word 等非图片走更宽的 MAX_FILE_BYTES(40MB)。
|
||||||
|
detectFile 只读文件头 32 字节,先跑也不费。 */
|
||||||
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
||||||
$mime = $info['mime'];
|
$mime = $info['mime'];
|
||||||
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
||||||
$errors[] = $file['name'] . ':不支持的文件类型';
|
$errors[] = $file['name'] . ':不支持的文件类型';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($info['is_image'] && $photoMax > 0 && $file['size'] > $photoMax) {
|
if ($info['is_image']) {
|
||||||
|
$limit = $photoMax > 0 ? $photoMax : self::MAX_BYTES;
|
||||||
|
if ($file['size'] > $limit) {
|
||||||
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
} elseif ($file['size'] > self::MAX_FILE_BYTES) {
|
||||||
|
$errors[] = $file['name'] . ':文件超过 ' . (int)(self::MAX_FILE_BYTES / 1048576) . 'MB';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$raw = @file_get_contents($file['tmp_name']);
|
$raw = @file_get_contents($file['tmp_name']);
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
|
|||||||
+66
-15
@@ -1,23 +1,37 @@
|
|||||||
<?php
|
<?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>
|
* <a class="js-media" data-kind="video" href="<播放地址>" data-dl="<下载地址>" data-cap="名字">
|
||||||
* (没有 data-cap 时回落到 title / alt / href)
|
* (data-dl 可选,file 视图的下载按钮用它;没有就退回 href + &dl=1)
|
||||||
* 2. 在页面末尾 require 本模板(一份就够,多页共用同一份标记与脚本)
|
* 在页面末尾 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>
|
<div class="lightbox-backdrop" data-lb-close="1"></div>
|
||||||
|
|
||||||
<button type="button" class="lightbox-nav lightbox-prev" data-lb-prev="1" aria-label="上一张">‹</button>
|
<button type="button" class="lightbox-nav lightbox-prev" data-lb-prev="1" aria-label="上一张">‹</button>
|
||||||
|
|
||||||
<figure class="lightbox-body">
|
<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>
|
<figcaption class="lightbox-cap" id="lightbox-cap"></figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
@@ -34,14 +48,19 @@
|
|||||||
var box = document.getElementById('lightbox');
|
var box = document.getElementById('lightbox');
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
var img = document.getElementById('lightbox-img');
|
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 cap = document.getElementById('lightbox-cap');
|
||||||
var count = document.getElementById('lightbox-count');
|
var count = document.getElementById('lightbox-count');
|
||||||
|
|
||||||
var items = []; // 打开时现收集页面上所有 js-photo(DOM 顺序)
|
var items = []; // 打开时现收集页面上所有 js-photo / js-media(DOM 顺序)
|
||||||
var idx = 0;
|
var idx = 0;
|
||||||
|
|
||||||
function collect() {
|
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) {
|
function captionOf(a) {
|
||||||
@@ -49,19 +68,50 @@
|
|||||||
|| (a.querySelector('img') ? a.querySelector('img').alt : '') || '';
|
|| (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) {
|
function show(i) {
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
idx = (i + items.length) % items.length; // 循环切换
|
idx = (i + items.length) % items.length; // 循环切换
|
||||||
var a = items[idx];
|
var a = items[idx];
|
||||||
|
var kind = kindOf(a);
|
||||||
var full = a.getAttribute('data-full') || a.getAttribute('href');
|
var full = a.getAttribute('data-full') || a.getAttribute('href');
|
||||||
img.alt = captionOf(a);
|
var capText = captionOf(a);
|
||||||
cap.textContent = 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 : '';
|
count.textContent = items.length > 1 ? (idx + 1) + ' / ' + items.length : '';
|
||||||
img.src = full;
|
|
||||||
// 预加载相邻两张,切换不等待
|
// 预加载相邻两张图片(视频 / PDF 体积大,不预载)
|
||||||
[idx - 1, idx + 1].forEach(function (j) {
|
[idx - 1, idx + 1].forEach(function (j) {
|
||||||
var na = items[(j + items.length) % items.length];
|
var na = items[(j + items.length) % items.length];
|
||||||
if (!na) return;
|
if (!na || kindOf(na) !== 'image') return;
|
||||||
var pre = new Image();
|
var pre = new Image();
|
||||||
pre.src = na.getAttribute('data-full') || na.getAttribute('href');
|
pre.src = na.getAttribute('data-full') || na.getAttribute('href');
|
||||||
});
|
});
|
||||||
@@ -78,6 +128,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
showView('none');
|
||||||
box.classList.remove('open');
|
box.classList.remove('open');
|
||||||
box.setAttribute('aria-hidden', 'true');
|
box.setAttribute('aria-hidden', 'true');
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
@@ -85,7 +136,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function (ev) {
|
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) {
|
if (link) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
openAt(link);
|
openAt(link);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ foreach ([$postMaxBytes, $fileMaxBytes] as $b) {
|
|||||||
|
|
||||||
$iconByMime = [
|
$iconByMime = [
|
||||||
'application/pdf' => '📄',
|
'application/pdf' => '📄',
|
||||||
|
'video/mp4' => '🎬',
|
||||||
|
'video/quicktime' => '🎬',
|
||||||
'image/gif' => '🖼️',
|
'image/gif' => '🖼️',
|
||||||
'video/mp4' => '🎬',
|
'video/mp4' => '🎬',
|
||||||
'video/quicktime' => '🎬',
|
'video/quicktime' => '🎬',
|
||||||
@@ -60,12 +62,14 @@ $iconByMime = [
|
|||||||
<label>添加照片 / 附件</label>
|
<label>添加照片 / 附件</label>
|
||||||
|
|
||||||
<div class="drop-zone" id="drop-zone" tabindex="0" role="button" aria-label="选择或拖入文件">
|
<div class="drop-zone" id="drop-zone" tabindex="0" role="button" aria-label="选择或拖入文件">
|
||||||
<input type="file" name="files[]" id="file-input" multiple style="display:none;">
|
<input type="file" name="files[]" id="file-input" multiple style="display:none;"
|
||||||
|
accept="image/*,.heic,.heif,.pdf,.mp4,.mov,.doc,.docx,.xls,.xlsx,.zip">
|
||||||
<div class="drop-zone-inner">
|
<div class="drop-zone-inner">
|
||||||
<div class="drop-zone-icon">📷</div>
|
<div class="drop-zone-icon">📷</div>
|
||||||
<p class="drop-zone-title">点击选择,或把照片拖到这里</p>
|
<p class="drop-zone-title">点击选择,或把照片 / 视频拖到这里</p>
|
||||||
<p class="muted drop-zone-hint">
|
<p class="muted drop-zone-hint">
|
||||||
可一次选多张。支持 JPG / PNG / GIF / WebP / HEIC、PDF、MP4 等;单张上限 <?= $maxMb ?>MB。
|
照片(JPG / PNG / GIF / WebP / HEIC)、视频(MP4 / MOV)、文档(PDF / Word / Excel)、ZIP 都可以。
|
||||||
|
照片单张上限 <?= $maxMb ?>MB,视频与文档单个上限 <?= (int)(\App\Uploads::MAX_FILE_BYTES / 1048576) ?>MB。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +108,7 @@ $iconByMime = [
|
|||||||
|
|
||||||
<?php if (empty($attachments)): ?>
|
<?php if (empty($attachments)): ?>
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p>还没有照片 —— 把登机牌、车票、门票拍下来传上去吧</p>
|
<p>还没有附件 —— 把登机牌、车票、门票拍下来,或者把视频、PDF 传上去吧</p>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
@@ -121,6 +125,18 @@ $iconByMime = [
|
|||||||
$dl = media_att($attId, true);
|
$dl = media_att($attId, true);
|
||||||
$sizeTxt = $desc['size_text'];
|
$sizeTxt = $desc['size_text'];
|
||||||
|
|
||||||
|
/* 灯箱预览方式:图片 / 视频直接播,PDF 内嵌阅读器,
|
||||||
|
其它(Word / ZIP 等)浏览器预览不了 → 灯箱里给下载按钮 */
|
||||||
|
if ($isImg) {
|
||||||
|
$kind = 'image';
|
||||||
|
} elseif (strpos($mime, 'video/') === 0) {
|
||||||
|
$kind = 'video';
|
||||||
|
} elseif ($mime === 'application/pdf') {
|
||||||
|
$kind = 'pdf';
|
||||||
|
} else {
|
||||||
|
$kind = 'file';
|
||||||
|
}
|
||||||
|
|
||||||
$dims = '';
|
$dims = '';
|
||||||
if (!empty($att['width']) && !empty($att['height'])) {
|
if (!empty($att['width']) && !empty($att['height'])) {
|
||||||
$dims = (int)$att['width'] . '×' . (int)$att['height'];
|
$dims = (int)$att['width'] . '×' . (int)$att['height'];
|
||||||
@@ -134,7 +150,8 @@ $iconByMime = [
|
|||||||
?>
|
?>
|
||||||
<div class="photo-card">
|
<div class="photo-card">
|
||||||
<a href="<?= e($url) ?>" title="<?= e($name) ?>"
|
<a href="<?= e($url) ?>" title="<?= e($name) ?>"
|
||||||
<?= $isImg ? 'class="js-photo" data-cap="' . e($name) . '"' : 'target="_blank" rel="noopener"' ?>>
|
<?= $kind === 'image' ? 'class="js-photo" data-cap="' . e($name) . '"'
|
||||||
|
: 'class="js-media" data-kind="' . $kind . '" data-cap="' . e($name) . '" data-dl="' . e($dl) . '"' ?>>
|
||||||
<?php if ($isImg): ?>
|
<?php if ($isImg): ?>
|
||||||
<img src="<?= e($url) ?>" alt="<?= e($name) ?>" loading="lazy">
|
<img src="<?= e($url) ?>" alt="<?= e($name) ?>" loading="lazy">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -194,10 +211,21 @@ $iconByMime = [
|
|||||||
|
|
||||||
var csrf = <?= json_encode(csrf_token()) ?>;
|
var csrf = <?= json_encode(csrf_token()) ?>;
|
||||||
var maxBytes = <?= (int)$maxMb ?> * 1024 * 1024;
|
var maxBytes = <?= (int)$maxMb ?> * 1024 * 1024;
|
||||||
|
var fileMax = <?= (int)\App\Uploads::MAX_FILE_BYTES ?>;
|
||||||
var postLimit = <?= (int)$effBytes ?>;
|
var postLimit = <?= (int)$effBytes ?>;
|
||||||
var uploadUrl = <?= json_encode(url('attachments/upload')) ?>;
|
var uploadUrl = <?= json_encode(url('attachments/upload')) ?>;
|
||||||
var nodeId = <?= (int)$node['id'] ?>;
|
var nodeId = <?= (int)$node['id'] ?>;
|
||||||
|
|
||||||
|
var VIDEO_EXT = ['mp4', 'mov', 'm4v'];
|
||||||
|
var PDF_EXT = ['pdf'];
|
||||||
|
|
||||||
|
function iconFor(f) {
|
||||||
|
var ext = extOf(f.name);
|
||||||
|
if (VIDEO_EXT.indexOf(ext) >= 0) return '🎬';
|
||||||
|
if (PDF_EXT.indexOf(ext) >= 0) return '📄';
|
||||||
|
return '📎';
|
||||||
|
}
|
||||||
|
|
||||||
/** 服务端也可以拒,但 30 个的上限在这里先挡一道 */
|
/** 服务端也可以拒,但 30 个的上限在这里先挡一道 */
|
||||||
var MAX_FILES = 30;
|
var MAX_FILES = 30;
|
||||||
|
|
||||||
@@ -263,9 +291,13 @@ $iconByMime = [
|
|||||||
|
|
||||||
arr.forEach(function (f) {
|
arr.forEach(function (f) {
|
||||||
if (room <= 0) { rejected.push(f.name + ':一次最多 ' + MAX_FILES + ' 个'); return; }
|
if (room <= 0) { rejected.push(f.name + ':一次最多 ' + MAX_FILES + ' 个'); return; }
|
||||||
if (!isImageFile(f) && f.type === 'image/heic') { /* 仍允许直传 */ }
|
/* 大小分级:照片走 photo_max_mb;视频 / 文档走更宽的 fileMax(后端同款逻辑) */
|
||||||
if (isImageFile(f) && maxBytes > 0 && f.size > maxBytes) {
|
if (isImageFile(f) && maxBytes > 0 && f.size > maxBytes) {
|
||||||
rejected.push(f.name + ':超过 ' + (maxBytes / 1024 / 1024) + 'MB');
|
rejected.push(f.name + ':照片超过 ' + (maxBytes / 1048576) + 'MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isImageFile(f) && f.size > fileMax) {
|
||||||
|
rejected.push(f.name + ':文件超过 ' + (fileMax / 1048576) + 'MB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var url = isImageFile(f) ? URL.createObjectURL(f) : '';
|
var url = isImageFile(f) ? URL.createObjectURL(f) : '';
|
||||||
@@ -327,7 +359,7 @@ $iconByMime = [
|
|||||||
if (it.image && it.thumb) {
|
if (it.image && it.thumb) {
|
||||||
inner += '<img class="photo-thumb" src="' + esc(it.thumb) + '" alt="">';
|
inner += '<img class="photo-thumb" src="' + esc(it.thumb) + '" alt="">';
|
||||||
} else {
|
} else {
|
||||||
inner += '<div class="photo-thumb photo-thumb-icon">📎</div>';
|
inner += '<div class="photo-thumb photo-thumb-icon">' + iconFor(it.file) + '</div>';
|
||||||
}
|
}
|
||||||
inner += '<div class="photo-info">'
|
inner += '<div class="photo-info">'
|
||||||
+ '<span class="photo-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
|
+ '<span class="photo-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ $tripField = [
|
|||||||
$checkedDel = old_array('delete_attachments'); // 保存失败时恢复勾选状态
|
$checkedDel = old_array('delete_attachments'); // 保存失败时恢复勾选状态
|
||||||
?>
|
?>
|
||||||
<fieldset class="fieldset" id="photo-field">
|
<fieldset class="fieldset" id="photo-field">
|
||||||
<legend>照片附件(可多张)</legend>
|
<legend>附件(照片 / 视频 / 文档)</legend>
|
||||||
|
|
||||||
<?php if ($existingAtts !== []): ?>
|
<?php if ($existingAtts !== []): ?>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -252,19 +252,21 @@ $tripField = [
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>添加照片 / 附件</label>
|
<label>添加附件(照片 / 视频 / 文档)</label>
|
||||||
<?php /* 用 div + role=button 而不是 <label>:label 包着隐藏 input 时,
|
<?php /* 用 div + role=button 而不是 <label>:label 包着隐藏 input 时,
|
||||||
点击会在部分浏览器里触发两次文件对话框。这里统一由 JS 打开。 */ ?>
|
点击会在部分浏览器里触发两次文件对话框。这里统一由 JS 打开。 */ ?>
|
||||||
<div class="drop-zone" id="node-photo-drop" tabindex="0" role="button"
|
<div class="drop-zone" id="node-photo-drop" tabindex="0" role="button"
|
||||||
aria-label="选择或拖入照片">
|
aria-label="选择或拖入附件">
|
||||||
<input type="file" id="node-photo-input" name="files[]" multiple
|
<input type="file" id="node-photo-input" name="files[]" multiple
|
||||||
accept="image/*,application/pdf,.heic,.heif" style="display:none;">
|
accept="image/*,.heic,.heif,.pdf,.mp4,.mov,.doc,.docx,.xls,.xlsx,.zip"
|
||||||
|
style="display:none;">
|
||||||
<div class="drop-zone-inner">
|
<div class="drop-zone-inner">
|
||||||
<div class="drop-zone-icon">📷</div>
|
<div class="drop-zone-icon">📷</div>
|
||||||
<p class="drop-zone-title">点击选择,或把照片拖到这里</p>
|
<p class="drop-zone-title">点击选择,或把照片 / 视频 / 文件拖到这里</p>
|
||||||
<p class="muted drop-zone-hint">
|
<p class="muted drop-zone-hint">
|
||||||
可一次选多张;登机牌、车票、门票随手拍下来即可。
|
照片、视频(MP4 / MOV)、文档(PDF / Word / Excel)、ZIP 都可以。
|
||||||
单张上限 <?= (int)($maxPhotoMb ?? 12) ?>MB,一次最多 <?= (int)\App\Uploads::MAX_FILES ?> 个。
|
照片单张上限 <?= (int)($maxPhotoMb ?? 12) ?>MB,视频与文档单个上限 <?= (int)(\App\Uploads::MAX_FILE_BYTES / 1048576) ?>MB,
|
||||||
|
一次最多 <?= (int)\App\Uploads::MAX_FILES ?> 个。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -597,9 +599,12 @@ $tripField = [
|
|||||||
|
|
||||||
var MAX_FILES = <?= (int)\App\Uploads::MAX_FILES ?>;
|
var MAX_FILES = <?= (int)\App\Uploads::MAX_FILES ?>;
|
||||||
var MAX_ONE = <?= (int)($maxPhotoMb ?? 12) ?> * 1024 * 1024;
|
var MAX_ONE = <?= (int)($maxPhotoMb ?? 12) ?> * 1024 * 1024;
|
||||||
|
var FILE_MAX = <?= (int)\App\Uploads::MAX_FILE_BYTES ?>;
|
||||||
var POST_LIMIT = <?= (int)ini_bytes((string)ini_get('post_max_size')) ?>;
|
var POST_LIMIT = <?= (int)ini_bytes((string)ini_get('post_max_size')) ?>;
|
||||||
|
|
||||||
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
|
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
|
||||||
|
var VIDEO_EXT = ['mp4', 'mov', 'm4v'];
|
||||||
|
var PDF_EXT = ['pdf'];
|
||||||
var picked = []; // {uid, file, key, name, origName, type, origUrl, thumb, image, croppable, cropped, reviewed}
|
var picked = []; // {uid, file, key, name, origName, type, origUrl, thumb, image, croppable, cropped, reviewed}
|
||||||
var seq = 0;
|
var seq = 0;
|
||||||
|
|
||||||
@@ -619,6 +624,12 @@ $tripField = [
|
|||||||
if (f.type && f.type.indexOf('image/') === 0) return true;
|
if (f.type && f.type.indexOf('image/') === 0) return true;
|
||||||
return IMG_EXT.indexOf(extOf(f.name)) >= 0;
|
return IMG_EXT.indexOf(extOf(f.name)) >= 0;
|
||||||
}
|
}
|
||||||
|
function iconFor(f) {
|
||||||
|
var ext = extOf(f.name);
|
||||||
|
if (VIDEO_EXT.indexOf(ext) >= 0) return '🎬';
|
||||||
|
if (PDF_EXT.indexOf(ext) >= 0) return '📄';
|
||||||
|
return '📎';
|
||||||
|
}
|
||||||
// 浏览器画布解不开的格式(典型是 HEIC),裁不了,只能原图直传
|
// 浏览器画布解不开的格式(典型是 HEIC),裁不了,只能原图直传
|
||||||
function canvasSafe(f) {
|
function canvasSafe(f) {
|
||||||
var t = String(f.type || '');
|
var t = String(f.type || '');
|
||||||
@@ -695,7 +706,7 @@ $tripField = [
|
|||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
(it.thumb
|
(it.thumb
|
||||||
? '<img src="' + esc(it.thumb) + '" alt="">'
|
? '<img src="' + esc(it.thumb) + '" alt="">'
|
||||||
: '<div class="attachment-icon">' + esc((extOf(it.name).toUpperCase()) || 'FILE') + '</div>')
|
: '<div class="attachment-icon">' + iconFor(it) + '</div>')
|
||||||
+ (canSync
|
+ (canSync
|
||||||
? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>'
|
? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>'
|
||||||
: '')
|
: '')
|
||||||
@@ -760,6 +771,11 @@ $tripField = [
|
|||||||
errors.push(f.name + ':超过单张 ' + (MAX_ONE / 1024 / 1024) + 'MB');
|
errors.push(f.name + ':超过单张 ' + (MAX_ONE / 1024 / 1024) + 'MB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* 大小分级:照片走 photo_max_mb;视频 / 文档走更宽的 FILE_MAX(后端同款逻辑) */
|
||||||
|
if (!isImageFile(f) && f.size > FILE_MAX) {
|
||||||
|
errors.push(f.name + ':文件超过 ' + (FILE_MAX / 1024 / 1024) + 'MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
var image = isImageFile(f);
|
var image = isImageFile(f);
|
||||||
var url = image ? URL.createObjectURL(f) : '';
|
var url = image ? URL.createObjectURL(f) : '';
|
||||||
var croppable = image && canvasSafe(f);
|
var croppable = image && canvasSafe(f);
|
||||||
|
|||||||
Reference in New Issue
Block a user