2026-09-22 23:09:37 +08:00
|
|
|
|
<?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' => '📄',
|
|
|
|
|
|
'image/gif' => '🖼️',
|
|
|
|
|
|
'video/mp4' => '🎬',
|
|
|
|
|
|
'video/quicktime' => '🎬',
|
|
|
|
|
|
];
|
|
|
|
|
|
?>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<div>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<h1>照片与附件</h1>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<p class="muted">
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>"><?= e($node['activity_title']) ?></a>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
·
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<a href="<?= url('nodes/show', ['id' => $node['id']]) ?>"><?= e($node['title'] ?: '无标题节点') ?></a>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
·
|
|
|
|
|
|
<?= $node['node_category'] === 'plan' ? '计划' : '执行' ?>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<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>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<?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">
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
|
|
|
|
|
|
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-row">
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<label>添加照片 / 附件</label>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="drop-zone" id="drop-zone" tabindex="0" role="button" aria-label="选择或拖入文件">
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<input type="file" name="files[]" id="file-input" multiple style="display:none;">
|
|
|
|
|
|
<div class="drop-zone-inner">
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<div class="drop-zone-icon">📷</div>
|
|
|
|
|
|
<p class="drop-zone-title">点击选择,或把照片拖到这里</p>
|
|
|
|
|
|
<p class="muted drop-zone-hint">
|
|
|
|
|
|
可一次选多张。支持 JPG / PNG / GIF / WebP / HEIC、PDF、MP4 等;单张上限 <?= $maxMb ?>MB。
|
2026-09-22 22:03:08 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-actions">
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<button type="submit" class="btn-primary" id="btn-submit" disabled>上传到 <?= e($driverLabel) ?></button>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<h2 class="section-title" id="photos">
|
|
|
|
|
|
已上传(<?= count($attachments) ?>)
|
|
|
|
|
|
</h2>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
|
|
|
|
|
<?php if (empty($attachments)): ?>
|
|
|
|
|
|
<div class="empty">
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<p>还没有照片 —— 把登机牌、车票、门票拍下来传上去吧</p>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<?php else: ?>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<div class="photo-grid">
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<?php foreach ($attachments as $att):
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$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'];
|
|
|
|
|
|
|
|
|
|
|
|
$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);
|
2026-09-22 22:03:08 +08:00
|
|
|
|
?>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<div class="photo-card">
|
|
|
|
|
|
<a href="<?= e($url) ?>" target="_blank" rel="noopener" title="<?= e($name) ?>">
|
|
|
|
|
|
<?php if ($isImg): ?>
|
|
|
|
|
|
<img src="<?= e($url) ?>" alt="<?= e($name) ?>" loading="lazy">
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<?php else: ?>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
<div class="attachment-icon"><?= $icon ?></div>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<?php endif; ?>
|
|
|
|
|
|
</a>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
</div>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-22 22:03:08 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<?php endforeach; ?>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<?php endif; ?>
|
|
|
|
|
|
|
2026-09-23 09:11:42 +08:00
|
|
|
|
<?php /* ── 裁剪弹窗(与节点表单共用同一份模板,避免两边漂移) ───────────── */ ?>
|
|
|
|
|
|
<?php require dirname(__DIR__) . '/_crop_modal.php'; ?>
|
2026-09-22 23:09:37 +08:00
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
|
<script>
|
|
|
|
|
|
(function () {
|
2026-09-22 23:09:37 +08:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
var form = document.getElementById('upload-form');
|
|
|
|
|
|
var dropZone = document.getElementById('drop-zone');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
var fileInput = document.getElementById('file-input');
|
2026-09-22 23:09:37 +08:00
|
|
|
|
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');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
var modal = document.getElementById('crop-modal');
|
|
|
|
|
|
var cropImg = document.getElementById('crop-image');
|
|
|
|
|
|
var cropTitle = document.getElementById('crop-title');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
var csrf = <?= json_encode(csrf_token()) ?>;
|
|
|
|
|
|
var maxBytes = <?= (int)$maxMb ?> * 1024 * 1024;
|
|
|
|
|
|
var postLimit = <?= (int)$effBytes ?>;
|
|
|
|
|
|
var uploadUrl = <?= json_encode(url('attachments/upload')) ?>;
|
|
|
|
|
|
var nodeId = <?= (int)$node['id'] ?>;
|
|
|
|
|
|
|
|
|
|
|
|
/** 服务端也可以拒,但 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; }
|
|
|
|
|
|
if (!isImageFile(f) && f.type === 'image/heic') { /* 仍允许直传 */ }
|
|
|
|
|
|
if (isImageFile(f) && maxBytes > 0 && f.size > maxBytes) {
|
|
|
|
|
|
rejected.push(f.name + ':超过 ' + (maxBytes / 1024 / 1024) + '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;
|
|
|
|
|
|
}
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|
2026-09-22 23:09:37 +08:00
|
|
|
|
render();
|
|
|
|
|
|
}
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
function render() {
|
|
|
|
|
|
stage.innerHTML = '';
|
|
|
|
|
|
if (!items.length) {
|
|
|
|
|
|
stageWrap.style.display = 'none';
|
|
|
|
|
|
btnSubmit.disabled = true;
|
|
|
|
|
|
updateSubmitLabel();
|
2026-09-22 22:03:08 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-22 23:09:37 +08:00
|
|
|
|
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">📎</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);
|
|
|
|
|
|
});
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|
2026-09-22 23:09:37 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-09-22 22:03:08 +08:00
|
|
|
|
})();
|
2026-09-22 23:09:37 +08:00
|
|
|
|
</script>
|