Files
TripLog/templates/nodes/attachments.php
T
laoyang f9a624ef4b 交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- trip_nodes 的历史列保留但不再写入也不再渲染,编辑一次即被显式清成 NULL
- 节点表单按出行方式切换号码标签(航班号 / 车次 / 班次 / 线路 / 航次)

照片
- 一个节点可传多张照片,前端支持逐张裁剪(Cropper.js,只把裁过的走 base64)
- MIME 由自建的 src/Mime.php 按内容识别:getimagesize → 魔数嗅探 → 扩展名映射,
  刻意不依赖 fileinfo —— 线上同时装着 PHP 7.4(没装该扩展)与 8.1
- 单张体积上限、可传张数在设置页可调

双存储后端
- 本站 uploads/ 或阿里云 OSS,在「存储设置」页配 endpoint / 桶名 / 前缀 / 内网开关;
  OSS 配置不完整时静默回退本地,避免管理员填错就把上传整个弄挂
- OSS 鉴权支持 ECS 实例 RAM 角色换 STS 临时凭证(临时凭证缓存在 /tmp,不落 Web 目录),
  也支持直接填 AccessKey
- OSS V1 签名手写实现,不引 SDK(本项目无 composer / 无 autoload)

媒体代理
- 照片与活动封面一律经 media/show、media/cover 代理输出,先校验家庭归属再吐字节
- 页面里不出现 OSS 域名,也不出现本地真实路径;桶保持私有
- 响应带 nosniff,并关闭 display_errors,免得 PHP 报错混进图片正文把图弄坏

数据库
- 新增迁移机制(src/Migrate.php + tools/migrate.php,仅 CLI 可跑)
- 建 app_settings 表;attachments 增加 storage / object_key / mime_type / size_bytes /
  width / height / is_image 与 idx_storage
- MySQL 8 不支持「加列时带 IF NOT EXISTS」,迁移一律先查 information_schema

界面
- 顶栏只留品牌与「新建活动」,其余入口收进右上角下拉菜单(details/summary,零 JS 可开合)
- 新增「存储设置」页:当前生效存储、数据库结构版本、是否 ECS 运行环境三张状态卡,
  外加测试连接与角色探测
- 样式表整体重写

验证
- 模板渲染检查 234 项、SQL 列名静态检查、模板到 CSS 的类覆盖检查、内联 JS 语法检查全通过
- 已部署到 trip.yangyq.net 并完成线上验证:双 PHP(8.1 / 7.4)14 条路由渲染字节数完全一致、
  26 项内容断言全部通过、照片代理取件与磁盘原件逐字节一致(69/69 字节)
2026-09-22 23:09:37 +08:00

582 lines
23 KiB
PHP
Raw 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
/**
* 节点照片 / 附件
*
* 交互:选或拖入多张 → 暂存区逐张预览(图片可裁剪)→ 一次提交。
* 提交时只把「裁过的图」转成 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' => '🎬',
];
?>
<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;">
<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、PDF、MP4 等;单张上限 <?= $maxMb ?>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>还没有照片 —— 把登机牌、车票、门票拍下来传上去吧</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'];
$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) ?>" target="_blank" rel="noopener" title="<?= e($name) ?>">
<?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 /* ── 裁剪弹窗 ─────────────────────────────────────────────── */ ?>
<div class="modal" id="crop-modal" aria-hidden="true">
<div class="modal-backdrop" data-close="1"></div>
<div class="modal-content modal-content-wide">
<h3 class="modal-title" id="crop-title">裁剪照片</h3>
<p class="modal-hint">拖动选框选好范围,确认后只上传裁剪结果。</p>
<div class="crop-container crop-container-wide">
<img id="crop-image" alt="">
</div>
<div class="modal-actions">
<button type="button" class="btn" id="crop-cancel">取消</button>
<button type="button" class="btn" id="crop-reset">重置</button>
<button type="button" class="btn-primary" id="crop-apply">确认裁剪</button>
</div>
</div>
</div>
<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 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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;
}
}
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">📎</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>