之前节点附件只能传图片和 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 渲染逐字节一致。
604 lines
24 KiB
PHP
604 lines
24 KiB
PHP
<?php
|
||
/**
|
||
* 节点照片 / 附件
|
||
*
|
||
* 交互:选或拖入多张 → 暂存区逐张预览(图片可裁剪)→ 一次提交。
|
||
* 提交时只把「裁过的图」转成 base64(cropped[]),没裁的走原文件(files[]),省流量。
|
||
* 浏览一律走 media/show 代理,页面里**永远不出现存储后端的真实地址**。
|
||
*/
|
||
use App\Attachments;
|
||
use App\Storage;
|
||
|
||
$maxMb = (int)($maxMb ?? 12);
|
||
$driverLabel = $driverLabel ?? Storage::label();
|
||
$ossFallback = Storage::ossMisconfigured();
|
||
|
||
$postMaxBytes = ini_bytes((string)ini_get('post_max_size'));
|
||
$fileMaxBytes = ini_bytes((string)ini_get('upload_max_filesize'));
|
||
$effBytes = 0;
|
||
foreach ([$postMaxBytes, $fileMaxBytes] as $b) {
|
||
if ($b > 0 && ($effBytes === 0 || $b < $effBytes)) $effBytes = $b;
|
||
}
|
||
|
||
$iconByMime = [
|
||
'application/pdf' => '📄',
|
||
'video/mp4' => '🎬',
|
||
'video/quicktime' => '🎬',
|
||
'image/gif' => '🖼️',
|
||
'video/mp4' => '🎬',
|
||
'video/quicktime' => '🎬',
|
||
];
|
||
?>
|
||
<div class="page-header">
|
||
<div>
|
||
<h1>照片与附件</h1>
|
||
<p class="muted">
|
||
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>"><?= e($node['activity_title']) ?></a>
|
||
·
|
||
<a href="<?= url('nodes/show', ['id' => $node['id']]) ?>"><?= e($node['title'] ?: '无标题节点') ?></a>
|
||
·
|
||
<?= $node['node_category'] === 'plan' ? '计划' : '执行' ?>
|
||
</p>
|
||
</div>
|
||
<div class="actions">
|
||
<a href="<?= url('nodes/show', ['id' => $node['id']]) ?>" class="btn">返回节点</a>
|
||
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($ossFallback): ?>
|
||
<div class="alert alert-warn">
|
||
已选择「阿里云 OSS」但配置不完整,照片暂存到本站服务器。请到
|
||
<a href="<?= url('settings/index') ?>">存储设置</a> 补全 Endpoint / Bucket / 角色名(或 AccessKey)。
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<form method="post" action="<?= url('attachments/upload') ?>" enctype="multipart/form-data"
|
||
class="form" id="upload-form">
|
||
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
|
||
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
|
||
|
||
<div class="form-row">
|
||
<label>添加照片 / 附件</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;"
|
||
accept="image/*,.heic,.heif,.pdf,.mp4,.mov,.doc,.docx,.xls,.xlsx,.zip">
|
||
<div class="drop-zone-inner">
|
||
<div class="drop-zone-icon">📷</div>
|
||
<p class="drop-zone-title">点击选择,或把照片 / 视频拖到这里</p>
|
||
<p class="muted drop-zone-hint">
|
||
照片(JPG / PNG / GIF / WebP / HEIC)、视频(MP4 / MOV)、文档(PDF / Word / Excel)、ZIP 都可以。
|
||
照片单张上限 <?= $maxMb ?>MB,视频与文档单个上限 <?= (int)(\App\Uploads::MAX_FILE_BYTES / 1048576) ?>MB。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="form-hint">
|
||
登机牌、车票、门票随手拍下来即可 —— 航班号 / 车次在节点里填,照片用来留档。
|
||
<?php if ($effBytes > 0): ?>
|
||
本次请求总上限约 <?= e(human_size($effBytes)) ?>,超了会被服务器截断,可分几批上传。
|
||
<?php endif; ?>
|
||
</p>
|
||
</div>
|
||
|
||
<div id="stage-wrap" style="display:none;">
|
||
<div class="stage-head">
|
||
<span class="stage-count" id="stage-count"></span>
|
||
<div class="stage-tools">
|
||
<button type="button" class="btn btn-sm" id="btn-clear">清空</button>
|
||
</div>
|
||
</div>
|
||
<div class="photo-stage" id="photo-stage"></div>
|
||
</div>
|
||
|
||
<div class="upload-progress" id="progress-wrap" style="display:none;">
|
||
<div class="upload-track"><div class="upload-bar" id="progress-bar"></div></div>
|
||
<div class="upload-text" id="progress-text">正在上传…</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn-primary" id="btn-submit" disabled>上传到 <?= e($driverLabel) ?></button>
|
||
</div>
|
||
</form>
|
||
|
||
<h2 class="section-title" id="photos">
|
||
已上传(<?= count($attachments) ?>)
|
||
</h2>
|
||
|
||
<?php if (empty($attachments)): ?>
|
||
<div class="empty">
|
||
<p>还没有附件 —— 把登机牌、车票、门票拍下来,或者把视频、PDF 传上去吧</p>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="photo-grid">
|
||
<?php foreach ($attachments as $att):
|
||
$attId = (int)$att['id'];
|
||
$isImg = Attachments::isImageRow($att);
|
||
$stored = Attachments::storageOf($att);
|
||
$badge = $stored === Storage::DRIVER_OSS ? 'OSS' : '本地';
|
||
$desc = Attachments::describe($att);
|
||
$name = $desc['name'];
|
||
$mime = (string)($att['mime_type'] ?? '');
|
||
$icon = $iconByMime[$mime] ?? '📎';
|
||
$url = media_att($attId);
|
||
$dl = media_att($attId, true);
|
||
$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 = '';
|
||
if (!empty($att['width']) && !empty($att['height'])) {
|
||
$dims = (int)$att['width'] . '×' . (int)$att['height'];
|
||
}
|
||
|
||
$capParts = array_filter([
|
||
$att['uploader_name'] ?: '未知',
|
||
$sizeTxt,
|
||
$dims,
|
||
], static fn($v) => $v !== '' && $v !== null);
|
||
?>
|
||
<div class="photo-card">
|
||
<a href="<?= e($url) ?>" title="<?= e($name) ?>"
|
||
<?= $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): ?>
|
||
<img src="<?= e($url) ?>" alt="<?= e($name) ?>" loading="lazy">
|
||
<?php else: ?>
|
||
<div class="attachment-icon"><?= $icon ?></div>
|
||
<?php endif; ?>
|
||
</a>
|
||
|
||
<span class="photo-tag photo-tag-<?= $stored === Storage::DRIVER_OSS ? 'oss' : 'local' ?>">
|
||
<?= e($badge) ?>
|
||
</span>
|
||
|
||
<div class="photo-cap" title="<?= e($name) ?>">
|
||
<span class="photo-cap-name"><?= e($name) ?></span>
|
||
<span class="photo-cap-sub"><?= e(implode(' · ', $capParts)) ?></span>
|
||
<span class="photo-cap-links">
|
||
<a href="<?= e($url) ?>" target="_blank" rel="noopener">查看</a>
|
||
<a href="<?= e($dl) ?>">下载</a>
|
||
</span>
|
||
</div>
|
||
|
||
<form method="post" action="<?= url('attachments/destroy') ?>" class="photo-del-form"
|
||
onsubmit="return confirm('确定删除「<?= e($name) ?>」?');">
|
||
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
|
||
<input type="hidden" name="id" value="<?= $attId ?>">
|
||
<button type="submit" class="photo-del" title="删除">×</button>
|
||
</form>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php /* ── 裁剪弹窗(与节点表单共用同一份模板,避免两边漂移) ───────────── */ ?>
|
||
<?php require dirname(__DIR__) . '/_crop_modal.php'; ?>
|
||
|
||
<?php /* ── 照片灯箱:已上传的图片在当前页弹窗浏览,左右箭头切换 ────────── */ ?>
|
||
<?php require dirname(__DIR__) . '/_lightbox.php'; ?>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
var form = document.getElementById('upload-form');
|
||
var dropZone = document.getElementById('drop-zone');
|
||
var fileInput = document.getElementById('file-input');
|
||
var stageWrap = document.getElementById('stage-wrap');
|
||
var stage = document.getElementById('photo-stage');
|
||
var stageCount= document.getElementById('stage-count');
|
||
var btnClear = document.getElementById('btn-clear');
|
||
var btnSubmit = document.getElementById('btn-submit');
|
||
var progWrap = document.getElementById('progress-wrap');
|
||
var progBar = document.getElementById('progress-bar');
|
||
var progText = document.getElementById('progress-text');
|
||
|
||
var modal = document.getElementById('crop-modal');
|
||
var cropImg = document.getElementById('crop-image');
|
||
var cropTitle = document.getElementById('crop-title');
|
||
|
||
var csrf = <?= json_encode(csrf_token()) ?>;
|
||
var maxBytes = <?= (int)$maxMb ?> * 1024 * 1024;
|
||
var fileMax = <?= (int)\App\Uploads::MAX_FILE_BYTES ?>;
|
||
var postLimit = <?= (int)$effBytes ?>;
|
||
var uploadUrl = <?= json_encode(url('attachments/upload')) ?>;
|
||
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 个的上限在这里先挡一道 */
|
||
var MAX_FILES = 30;
|
||
|
||
var items = []; // 暂存项
|
||
var seq = 0;
|
||
var urls = []; // 需要 revoke 的 objectURL
|
||
var cropper = null;
|
||
var cropUid = null;
|
||
|
||
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
|
||
|
||
function extOf(name) {
|
||
var i = String(name || '').lastIndexOf('.');
|
||
return i < 0 ? '' : String(name).slice(i + 1).toLowerCase();
|
||
}
|
||
|
||
function isImageFile(f) {
|
||
if (f.type && f.type.indexOf('image/') === 0) return true;
|
||
return IMG_EXT.indexOf(extOf(f.name)) >= 0;
|
||
}
|
||
|
||
// 浏览器画布解不开的格式(典型是 HEIC),裁不了,只能原图直传
|
||
function canvasSafe(f) {
|
||
var t = String(f.type || '');
|
||
if (t === 'image/heic' || t === 'image/heif') return false;
|
||
if (!t && ['heic', 'heif'].indexOf(extOf(f.name)) >= 0) return false;
|
||
return true;
|
||
}
|
||
|
||
function fmtSize(n) {
|
||
n = Number(n) || 0;
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
return (n / 1024 / 1024).toFixed(2) + ' MB';
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function dataUrlBytes(dataUrl) {
|
||
var i = String(dataUrl).indexOf(',');
|
||
if (i < 0) return 0;
|
||
var b64 = String(dataUrl).slice(i + 1);
|
||
var pad = b64.endsWith('==') ? 2 : (b64.endsWith('=') ? 1 : 0);
|
||
return Math.floor(b64.length * 3 / 4) - pad;
|
||
}
|
||
|
||
function find(uid) {
|
||
for (var i = 0; i < items.length; i++) if (items[i].uid === uid) return items[i];
|
||
return null;
|
||
}
|
||
|
||
// ── 加入暂存区 ──────────────────────────────────────────────
|
||
function addFiles(list) {
|
||
var arr = Array.prototype.slice.call(list || []);
|
||
if (!arr.length) return;
|
||
|
||
var rejected = [];
|
||
var room = MAX_FILES - items.length;
|
||
|
||
arr.forEach(function (f) {
|
||
if (room <= 0) { rejected.push(f.name + ':一次最多 ' + MAX_FILES + ' 个'); return; }
|
||
/* 大小分级:照片走 photo_max_mb;视频 / 文档走更宽的 fileMax(后端同款逻辑) */
|
||
if (isImageFile(f) && maxBytes > 0 && f.size > maxBytes) {
|
||
rejected.push(f.name + ':照片超过 ' + (maxBytes / 1048576) + 'MB');
|
||
return;
|
||
}
|
||
if (!isImageFile(f) && f.size > fileMax) {
|
||
rejected.push(f.name + ':文件超过 ' + (fileMax / 1048576) + 'MB');
|
||
return;
|
||
}
|
||
var url = isImageFile(f) ? URL.createObjectURL(f) : '';
|
||
if (url) urls.push(url);
|
||
items.push({
|
||
uid: ++seq,
|
||
file: f,
|
||
name: f.name || ('photo.' + (extOf(f.name) || 'jpg')),
|
||
size: f.size,
|
||
type: f.type || '',
|
||
image: isImageFile(f),
|
||
croppable: isImageFile(f) && canvasSafe(f),
|
||
cropped: null, // 裁剪后的 dataURL
|
||
thumb: url // objectURL 或 dataURL
|
||
});
|
||
room--;
|
||
});
|
||
|
||
if (rejected.length) alert(rejected.join('\n'));
|
||
render();
|
||
}
|
||
|
||
function removeItem(uid) {
|
||
for (var i = 0; i < items.length; i++) {
|
||
if (items[i].uid === uid) {
|
||
if (items[i].thumb && items[i].thumb.indexOf('blob:') === 0) {
|
||
try { URL.revokeObjectURL(items[i].thumb); } catch (e) {}
|
||
}
|
||
items.splice(i, 1);
|
||
break;
|
||
}
|
||
}
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
stage.innerHTML = '';
|
||
if (!items.length) {
|
||
stageWrap.style.display = 'none';
|
||
btnSubmit.disabled = true;
|
||
updateSubmitLabel();
|
||
return;
|
||
}
|
||
stageWrap.style.display = 'block';
|
||
|
||
var total = 0;
|
||
items.forEach(function (it) {
|
||
total += it.cropped ? dataUrlBytes(it.cropped) : it.size;
|
||
});
|
||
stageCount.textContent = items.length + ' 个待上传 · 约 ' + fmtSize(total);
|
||
btnSubmit.disabled = false;
|
||
updateSubmitLabel();
|
||
|
||
items.forEach(function (it) {
|
||
var el = document.createElement('div');
|
||
el.className = 'photo-item';
|
||
|
||
var inner = '';
|
||
if (it.image && it.thumb) {
|
||
inner += '<img class="photo-thumb" src="' + esc(it.thumb) + '" alt="">';
|
||
} else {
|
||
inner += '<div class="photo-thumb photo-thumb-icon">' + iconFor(it.file) + '</div>';
|
||
}
|
||
inner += '<div class="photo-info">'
|
||
+ '<span class="photo-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
|
||
+ fmtSize(it.cropped ? dataUrlBytes(it.cropped) : it.size)
|
||
+ '</div>';
|
||
if (it.cropped) {
|
||
inner += '<span class="photo-badge">已裁剪</span>';
|
||
} else if (it.image && !it.croppable) {
|
||
inner += '<span class="photo-badge photo-badge-plain">原图上传</span>';
|
||
}
|
||
inner += '<div class="photo-actions">'
|
||
+ '<button type="button" data-act="crop" data-uid="' + it.uid + '"'
|
||
+ (it.croppable ? '' : ' disabled title="该格式无法在浏览器中裁剪"') + '>裁剪</button>'
|
||
+ '<button type="button" data-act="del" data-uid="' + it.uid + '">移除</button>'
|
||
+ '</div>';
|
||
|
||
el.innerHTML = inner;
|
||
stage.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function updateSubmitLabel() {
|
||
if (btnSubmit.disabled) return;
|
||
btnSubmit.textContent = '上传 ' + items.length + ' 个到 <?= e($driverLabel) ?>';
|
||
}
|
||
|
||
stage.addEventListener('click', function (ev) {
|
||
var btn = ev.target.closest ? ev.target.closest('button[data-act]') : null;
|
||
if (!btn) return;
|
||
var uid = parseInt(btn.getAttribute('data-uid'), 10);
|
||
if (btn.getAttribute('data-act') === 'del') removeItem(uid);
|
||
else if (btn.getAttribute('data-act') === 'crop') openCrop(uid);
|
||
});
|
||
|
||
btnClear.addEventListener('click', function () {
|
||
if (!items.length) return;
|
||
if (!confirm('清空暂存区里的 ' + items.length + ' 个文件?')) return;
|
||
items.slice().forEach(function (it) { removeItem(it.uid); });
|
||
});
|
||
|
||
// ── 选择 / 拖放 ────────────────────────────────────────────
|
||
dropZone.addEventListener('click', function () { fileInput.click(); });
|
||
dropZone.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); fileInput.click(); }
|
||
});
|
||
fileInput.addEventListener('change', function () {
|
||
addFiles(fileInput.files);
|
||
fileInput.value = ''; // 允许连续选同一个文件
|
||
});
|
||
|
||
['dragenter', 'dragover'].forEach(function (t) {
|
||
dropZone.addEventListener(t, function (ev) {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
dropZone.classList.add('dragging');
|
||
});
|
||
});
|
||
['dragleave', 'dragend'].forEach(function (t) {
|
||
dropZone.addEventListener(t, function (ev) {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
dropZone.classList.remove('dragging');
|
||
});
|
||
});
|
||
dropZone.addEventListener('drop', function (ev) {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
dropZone.classList.remove('dragging');
|
||
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) {
|
||
addFiles(ev.dataTransfer.files);
|
||
}
|
||
});
|
||
// 别让文件掉在页面其它地方被浏览器直接打开
|
||
window.addEventListener('dragover', function (ev) { ev.preventDefault(); });
|
||
window.addEventListener('drop', function (ev) { ev.preventDefault(); });
|
||
|
||
// ── 裁剪 ──────────────────────────────────────────────────
|
||
function openCrop(uid) {
|
||
var it = find(uid);
|
||
if (!it || !it.croppable) return;
|
||
if (typeof window.Cropper === 'undefined') {
|
||
alert('裁剪组件没加载出来,将按原图上传。');
|
||
return;
|
||
}
|
||
cropUid = uid;
|
||
cropTitle.textContent = '裁剪:' + it.name;
|
||
|
||
// 每次都换一个新的 img 节点,避免 Cropper 复用旧实例时状态残留
|
||
var fresh = document.createElement('img');
|
||
fresh.id = 'crop-image';
|
||
fresh.alt = '';
|
||
cropImg.parentNode.replaceChild(fresh, cropImg);
|
||
cropImg = fresh;
|
||
|
||
cropImg.onerror = function () {
|
||
alert('浏览器解不开这张图(可能是 HEIC 等格式),将按原图上传。');
|
||
closeCrop();
|
||
var cur = find(cropUid);
|
||
if (cur) cur.croppable = false;
|
||
render();
|
||
};
|
||
cropImg.onload = function () {
|
||
if (typeof window.Cropper === 'undefined') return;
|
||
cropper = new window.Cropper(cropImg, {
|
||
viewMode: 1,
|
||
dragMode: 'move',
|
||
autoCropArea: 1,
|
||
responsive: true,
|
||
background: false,
|
||
aspectRatio: NaN // 自由比例
|
||
});
|
||
};
|
||
cropImg.src = it.cropped ? it.cropped : (it.thumb || URL.createObjectURL(it.file));
|
||
|
||
modal.classList.add('open');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closeCrop() {
|
||
modal.classList.remove('open');
|
||
modal.setAttribute('aria-hidden', 'true');
|
||
document.body.style.overflow = '';
|
||
if (cropper) { cropper.destroy(); cropper = null; }
|
||
cropUid = null;
|
||
}
|
||
|
||
modal.addEventListener('click', function (ev) {
|
||
if (ev.target.getAttribute && ev.target.getAttribute('data-close') === '1') closeCrop();
|
||
});
|
||
document.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Escape' && modal.classList.contains('open')) closeCrop();
|
||
});
|
||
document.getElementById('crop-cancel').addEventListener('click', closeCrop);
|
||
document.getElementById('crop-reset').addEventListener('click', function () {
|
||
if (cropper) cropper.reset();
|
||
});
|
||
|
||
document.getElementById('crop-apply').addEventListener('click', function () {
|
||
if (!cropper || cropUid === null) { closeCrop(); return; }
|
||
var it = find(cropUid);
|
||
if (!it) { closeCrop(); return; }
|
||
|
||
// 限制长边,避免 base64 把请求撑爆(登机牌 1800px 已经足够看清)
|
||
var canvas = cropper.getCroppedCanvas({
|
||
maxWidth: 1800,
|
||
maxHeight: 1800,
|
||
imageSmoothingQuality: 'high',
|
||
fillColor: '#ffffff'
|
||
});
|
||
if (!canvas) { alert('裁剪失败,请重试。'); return; }
|
||
|
||
var mime = /^image\/(png|webp)$/.test(it.type) ? it.type : 'image/jpeg';
|
||
var dataUrl;
|
||
try {
|
||
dataUrl = canvas.toDataURL(mime, 0.9);
|
||
} catch (e) {
|
||
alert('导出裁剪结果失败:' + e.message);
|
||
return;
|
||
}
|
||
|
||
it.cropped = dataUrl;
|
||
it.thumb = dataUrl;
|
||
it.name = it.name.replace(/\.[^.]+$/, '') + (mime === 'image/png' ? '.png'
|
||
: mime === 'image/webp' ? '.webp' : '.jpg');
|
||
closeCrop();
|
||
render();
|
||
});
|
||
|
||
// ── 提交 ──────────────────────────────────────────────────
|
||
form.addEventListener('submit', function (ev) {
|
||
ev.preventDefault();
|
||
if (!items.length || btnSubmit.disabled) return;
|
||
|
||
var fd = new FormData();
|
||
fd.append('_csrf', csrf);
|
||
fd.append('node_id', String(nodeId));
|
||
|
||
var croppedArr = [];
|
||
var total = 0;
|
||
items.forEach(function (it) {
|
||
if (it.cropped) {
|
||
croppedArr.push({ name: it.name, data: it.cropped });
|
||
total += dataUrlBytes(it.cropped);
|
||
} else {
|
||
fd.append('files[]', it.file, it.name);
|
||
total += it.size;
|
||
}
|
||
});
|
||
if (croppedArr.length) fd.append('cropped', JSON.stringify(croppedArr));
|
||
|
||
if (postLimit > 0 && total > postLimit) {
|
||
alert('这次要传约 ' + fmtSize(total) + ',超过服务器单次请求上限 ' + fmtSize(postLimit)
|
||
+ '。请分几批上传。');
|
||
return;
|
||
}
|
||
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', uploadUrl, true);
|
||
xhr.setRequestHeader('X-CSRF-Token', csrf);
|
||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||
|
||
progWrap.style.display = 'block';
|
||
btnSubmit.disabled = true;
|
||
progBar.style.width = '0%';
|
||
progText.textContent = '正在上传 ' + items.length + ' 个文件…';
|
||
|
||
xhr.upload.onprogress = function (e) {
|
||
if (!e.lengthComputable) return;
|
||
var pct = Math.round(e.loaded / e.total * 100);
|
||
progBar.style.width = pct + '%';
|
||
progText.textContent = '正在上传… ' + pct + '%(' + fmtSize(e.loaded) + ' / ' + fmtSize(e.total) + ')';
|
||
};
|
||
|
||
xhr.onload = function () {
|
||
// 控制器最后是 redirect,XHR 会自动跟到目标页,所以 2xx/3xx 都算成功
|
||
if (xhr.status >= 200 && xhr.status < 400) {
|
||
progBar.style.width = '100%';
|
||
progText.textContent = '上传完成,正在刷新…';
|
||
window.location.href = location.pathname + location.search + '#photos';
|
||
return;
|
||
}
|
||
fail('上传失败(HTTP ' + xhr.status + '):' + snippet(xhr.responseText));
|
||
};
|
||
xhr.onerror = function () { fail('网络错误,上传没成功'); };
|
||
xhr.onabort = function () { fail('上传被中断'); };
|
||
|
||
function snippet(html) {
|
||
var t = String(html || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
return t.slice(0, 160);
|
||
}
|
||
|
||
function fail(msg) {
|
||
progWrap.style.display = 'none';
|
||
btnSubmit.disabled = false;
|
||
alert(msg);
|
||
}
|
||
|
||
xhr.send(fd);
|
||
});
|
||
|
||
render();
|
||
})();
|
||
</script>
|