节点表单:24 小时制时间控件、附件可裁剪、保存失败不丢已填内容

1) 时间选择不再出现「上午/下午」
   datetime-local 的显示格式由浏览器 UI 语言决定(中文环境渲染成
   「下午 03:30」),页面与元素的 lang 都压不住(截图实测)。
   拆成「原生 date 控件 + 时下拉(00-23) + 分下拉(00-59)」组合控件
   (templates/_datetime.php),24 小时制由控件本身决定,浏览器改不掉。
   服务端 read_dt_input() 把三段拼回 Y-m-d H:i:s 并校验,兼容旧单字段。
   节点起止时间与成员签到时间一并换掉。

2) 附件可裁剪,不想裁点「确定」就按原图上传
   裁剪弹窗抽成 templates/_crop_modal.php 与照片页共用;表单里选完图
   自动逐张进入裁剪,整图选框=没裁(保留原文件、不重新编码、省 base64
   流量),裁过的走 cropped 通道(隐藏域 JSON,原生表单提交也能带),
   没裁的留在 input.files,已裁卡片带角标,可随时重置重来。

3) 节点保存失败不再清空整页
   服务端:失败分支 flash_old($_POST),重开的表单按 old()/old_array()/
   old_dt() 全字段回填(含勾选删除状态),渲染完即清(layout.php)。
   前端:sessionStorage 草稿兜底服务端拿不到 $_POST 的情况
   (post_max_size 超限、会话过期),恢复条件收窄到「页面有错误提示条」,
   避免陈旧草稿污染新表单。

已验证:本地 php -l 全量 0 错、模板渲染 275 项、表单交互(真实浏览器)
73 项——含真实裁剪一刀、草稿恢复、三段式时间选择;远端 e2e 289 项
0 失败(存储设置测试期间归一为 local,不再写用户真实 OSS 桶,收尾还原);
部署后双 PHP 渲染逐字节一致、59 文件哈希校验一致。
This commit is contained in:
2026-09-23 09:11:42 +08:00
parent 44ffee6c92
commit 86e0481a19
10 changed files with 633 additions and 77 deletions
+388 -40
View File
@@ -1,7 +1,18 @@
<?php
$isEdit = $isEdit ?? false;
$n = $node; // null=新建;数组=编辑
$curStatus = $n['status'] ?? ($category === 'plan' ? 'planned' : 'completed');
/**
* 回填:上一次提交失败时,**用户刚填的值优先于库里的值**。
* flash_old() 把 $_POST 原样存进 session,layout.php 渲染完就 unset(用完即清),
* 所以这里不需要担心残留污染下一次新建。
*/
$oldFilled = !empty($_SESSION['_old']);
$oldStr = static function (string $k, $fb = ''): string {
return (string)old($k, (string)$fb);
};
$curStatus = $oldStr('status', (string)($n['status'] ?? ($category === 'plan' ? 'planned' : 'completed')));
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
@@ -13,13 +24,25 @@ $statusOptions = $category === 'plan'
? ['planned', 'completed', 'cancelled']
: ['completed', 'ongoing', 'planned', 'cancelled'];
$dtLocal = static function ($value): string {
if (empty($value)) return '';
$ts = strtotime((string)$value);
return $ts ? date('Y-m-d\TH:i', $ts) : '';
};
$sLoc = json_decode_field($n['start_location'] ?? null);
$eLoc = json_decode_field($n['end_location'] ?? null);
// 起点 / 终点:库里的 JSON 先解出来,再让上一次提交的原始值覆盖它
$sLocRaw = json_decode_field($n['start_location'] ?? null);
$eLocRaw = json_decode_field($n['end_location'] ?? null);
$sLoc = [
'name' => $oldStr('start_location_name', $sLocRaw['name'] ?? ''),
'address' => $oldStr('start_location_address', $sLocRaw['address'] ?? ''),
'lat' => $oldStr('start_location_lat', $sLocRaw['lat'] ?? ''),
'lng' => $oldStr('start_location_lng', $sLocRaw['lng'] ?? ''),
];
$eLoc = [
'name' => $oldStr('end_location_name', $eLocRaw['name'] ?? ''),
'address' => $oldStr('end_location_address', $eLocRaw['address'] ?? ''),
'lat' => $oldStr('end_location_lat', $eLocRaw['lat'] ?? ''),
'lng' => $oldStr('end_location_lng', $eLocRaw['lng'] ?? ''),
];
// 时间:优先用上一次提交的三段式(_date/_hour/_minute),否则用库里存的
$startTimeVal = old_dt('start_time', $n['start_time'] ?? null);
$endTimeVal = old_dt('end_time', $n['end_time'] ?? null);
/**
* 交通信息只留一个输入框:飞机只要航班号(航空公司是隐含的),火车只要车次。
@@ -50,7 +73,9 @@ $tripField = [
</div>
<form method="post" action="<?= url($isEdit ? 'nodes/update' : 'nodes/store') ?>"
enctype="multipart/form-data" class="form" id="node-form">
enctype="multipart/form-data" class="form" id="node-form"
data-old="<?= $oldFilled ? '1' : '0' ?>"
data-draft-key="<?= e(($isEdit ? 'edit|' . (int)$n['id'] : 'create|' . (int)$activity['id'] . '|' . $category)) ?>">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="activity_id" value="<?= (int)$activity['id'] ?>">
<input type="hidden" name="node_category" value="<?= e($category) ?>">
@@ -63,8 +88,9 @@ $tripField = [
<label>关联计划节点(可选)</label>
<select name="original_plan_node_id">
<option value="">— 不关联,此为临时新增 —</option>
<?php $selPlan = $oldStr('original_plan_node_id', ''); ?>
<?php foreach ($planNodes as $p): ?>
<option value="<?= (int)$p['id'] ?>">
<option value="<?= (int)$p['id'] ?>"<?= $selPlan === (string)(int)$p['id'] ? ' selected' : '' ?>>
<?= e($p['title'] ?: '(无标题)') ?> · <?= date('m-d H:i', strtotime($p['start_time'])) ?>
</option>
<?php endforeach; ?>
@@ -76,9 +102,10 @@ $tripField = [
<div class="form-row">
<label>节点类型 *</label>
<select name="node_type_id" required>
<?php $selType = $oldStr('node_type_id', (string)($n['node_type_id'] ?? 0)); ?>
<?php foreach ($nodeTypes as $t): ?>
<option value="<?= (int)$t['id'] ?>"
<?= (int)($n['node_type_id'] ?? 0) === (int)$t['id'] ? ' selected' : '' ?>>
<?= $selType === (string)(int)$t['id'] ? ' selected' : '' ?>>
<?= e($t['icon'] . ' ' . $t['name']) ?>
</option>
<?php endforeach; ?>
@@ -88,18 +115,22 @@ $tripField = [
<div class="form-row two-col">
<div>
<label>开始时间 *</label>
<input type="datetime-local" name="start_time" required value="<?= e($dtLocal($n['start_time'] ?? null)) ?>">
<?php /* 24 小时制组合控件:原生 datetime-local 的「上午/下午」显示由浏览器
UI 语言决定,页面管不住(用户反馈过混淆),所以拆成日期 + 时/分下拉 */ ?>
<?php $dtName = 'start_time'; $dtValue = $startTimeVal; $dtRequired = true; $dtId = 'start-time-date'; ?>
<?php require __DIR__ . '/../_datetime.php'; ?>
</div>
<div>
<label>结束时间 *</label>
<input type="datetime-local" name="end_time" required value="<?= e($dtLocal($n['end_time'] ?? null)) ?>">
<?php $dtName = 'end_time'; $dtValue = $endTimeVal; $dtRequired = true; $dtId = 'end-time-date'; ?>
<?php require __DIR__ . '/../_datetime.php'; ?>
</div>
</div>
<div class="form-row">
<label>标题</label>
<input type="text" name="title" maxlength="200" placeholder="例如:出发去奥森"
value="<?= e($n['title'] ?? '') ?>">
value="<?= e($oldStr('title', $n['title'] ?? '')) ?>">
</div>
<fieldset class="fieldset">
@@ -109,9 +140,10 @@ $tripField = [
<label for="travel-mode-select">选择方式</label>
<select name="travel_mode_id" id="travel-mode-select">
<option value="" data-mode-type="">— 不使用 —</option>
<?php $selMode = $oldStr('travel_mode_id', (string)($n['travel_mode_id'] ?? '')); ?>
<?php foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>"
<?= (int)($n['travel_mode_id'] ?? 0) === (int)$tm['id'] ? ' selected' : '' ?>>
<?= $selMode === (string)(int)$tm['id'] ? ' selected' : '' ?>>
<?= e(($modeLabels[$tm['mode_type']] ?? $tm['mode_type']) . (!empty($tm['plate_number']) ? ' · ' . $tm['plate_number'] : '')) ?>
</option>
<?php endforeach; ?>
@@ -126,7 +158,7 @@ $tripField = [
<label id="trip-number-label" for="trip-number-input">车次 / 航班号</label>
<input type="text" name="trip_number" id="trip-number-input" maxlength="50"
placeholder="例如:G1234、CA1234"
value="<?= e($n['trip_number'] ?? '') ?>">
value="<?= e($oldStr('trip_number', $n['trip_number'] ?? '')) ?>">
<div class="muted" id="trip-number-hint"></div>
</div>
@@ -178,14 +210,16 @@ $tripField = [
<div class="form-row">
<label>详细描述</label>
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($n['description'] ?? '') ?></textarea>
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($oldStr('description', $n['description'] ?? '')) ?></textarea>
</div>
<?php
/* 照片附件:随节点表单一起提交。
新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」;
编辑时把已有照片带出来,可勾选删除。 */
编辑时把已有照片带出来,可勾选删除。
新选的图片可以先裁剪:不想裁就直接点「确定」,会按原图上传。 */
$existingAtts = $attachments ?? [];
$checkedDel = old_array('delete_attachments'); // 保存失败时恢复勾选状态
?>
<fieldset class="fieldset" id="photo-field">
<legend>照片附件(可多张)</legend>
@@ -202,7 +236,8 @@ $tripField = [
?>
<label class="photo-pick" title="<?= e($attDesc['name']) ?>">
<?php /* checkbox 排在最前:CSS 用的是 input:checked ~ img 这种兄弟选择器 */ ?>
<input type="checkbox" name="delete_attachments[]" value="<?= $attId ?>">
<input type="checkbox" name="delete_attachments[]" value="<?= $attId ?>"
<?= in_array((string)$attId, $checkedDel, true) ? ' checked' : '' ?>>
<?php if ($attIsImg): ?>
<img src="<?= e(media_att($attId)) ?>" alt="" loading="lazy">
<?php else: ?>
@@ -235,6 +270,12 @@ $tripField = [
</div>
<div class="form-hint" id="node-photo-status" role="status"></div>
<div class="photo-grid photo-grid-sm" id="node-photo-preview"></div>
<div class="crop-toolbar" id="node-crop-toolbar" style="display:none;">
<button type="button" class="btn btn-sm" id="node-photo-cropall">逐张裁剪</button>
<span class="muted">不想裁就逐张点「确定」,会按原图上传。</span>
</div>
<?php /* 裁剪结果走 base64(cropped[]),没裁的走原文件(files[])——与照片页同一套约定 */ ?>
<input type="hidden" name="cropped" id="node-cropped-json" value="">
</div>
</fieldset>
@@ -254,7 +295,7 @@ $tripField = [
<div class="form-row">
<label>取消原因(状态选「已取消」时填写)</label>
<input type="text" name="cancel_reason" maxlength="500" placeholder="例如:下雨,临时改行程"
value="<?= e($n['cancel_reason'] ?? '') ?>">
value="<?= e($oldStr('cancel_reason', $n['cancel_reason'] ?? '')) ?>">
</div>
</fieldset>
<?php endif; ?>
@@ -265,6 +306,9 @@ $tripField = [
</div>
</form>
<?php /* ── 裁剪弹窗:与「照片与附件」页共用同一份模板 ────────────────────── */ ?>
<?php require __DIR__ . '/../_crop_modal.php'; ?>
<script>
(function () {
// ===== 交通字段:只一个输入框,标签随出行方式变 =====
@@ -529,18 +573,26 @@ $tripField = [
});
}
// ===== 节点表单里的照片附件 =====
// 与「照片与附件」页不同:这里不做裁剪,选好就跟着表单一起提交。
// ===== 节点表单里的照片附件(含裁剪)=====
//
// 提交形态与「照片与附件」页保持同一套约定:
// 裁过的图 → 转成 base64 放进 #node-cropped-json(cropped[]),走服务端 handleCropped;
// 没裁的 → 还留在 input.files 里(files[]),省掉一次无谓的编码。
// 「确定」时如果选框还是整图(用户没动过),就当作没裁,**保留原文件**——
// 不重新编码既不损画质,也不平白多出 37% 的 base64 流量。
//
// 必须在这一层挡住「总量超 post_max_size」:PHP 遇到超限请求会把 $_POST 与
// $_FILES **整个丢掉**,控制器只能走到 csrf_check() 失败、报「请求已过期」,
// 用户完全看不懂发生了什么。提前拦住才能给一句能行动的话。
(function () {
var form = document.getElementById('node-form');
var dropZone = document.getElementById('node-photo-drop');
var input = document.getElementById('node-photo-input');
var preview = document.getElementById('node-photo-preview');
var status = document.getElementById('node-photo-status');
var form = document.getElementById('node-form');
var dropZone = document.getElementById('node-photo-drop');
var input = document.getElementById('node-photo-input');
var preview = document.getElementById('node-photo-preview');
var status = document.getElementById('node-photo-status');
var cropJson = document.getElementById('node-cropped-json');
var toolbar = document.getElementById('node-crop-toolbar');
var cropAll = document.getElementById('node-photo-cropall');
if (!form || !dropZone || !input || !preview) return;
var MAX_FILES = <?= (int)\App\Uploads::MAX_FILES ?>;
@@ -548,7 +600,16 @@ $tripField = [
var POST_LIMIT = <?= (int)ini_bytes((string)ini_get('post_max_size')) ?>;
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
var picked = []; // [{file, key, url}]
var picked = []; // {uid, file, key, name, origName, type, origUrl, thumb, image, croppable, cropped, reviewed}
var seq = 0;
// ---- 裁剪弹窗(id 与 templates/_crop_modal.php 一一对应)----
var modal = document.getElementById('crop-modal');
var cropImg = document.getElementById('crop-image');
var cropTitle = document.getElementById('crop-title');
var cropper = null;
var cropUid = null;
var cropStopped = false; // 用户点了「取消」就结束本次逐张裁剪
function extOf(name) {
var i = String(name || '').lastIndexOf('.');
@@ -558,6 +619,13 @@ $tripField = [
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';
@@ -569,6 +637,19 @@ $tripField = [
.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.slice(-2) === '==' ? 2 : (b64.slice(-1) === '=' ? 1 : 0);
return Math.floor(b64.length * 3 / 4) - pad;
}
function bytesOf(it) { return it.cropped ? dataUrlBytes(it.cropped) : it.file.size; }
function find(uid) {
for (var i = 0; i < picked.length; i++) if (picked[i].uid === uid) return picked[i];
return null;
}
// 能不能改写 input.files —— 决定「移除」按钮是否有意义。
// 只判断 DataTransfer 存在是不够的:必须真塞一个文件、再读回来验证赋值生效。
@@ -586,15 +667,18 @@ $tripField = [
}
})();
/** 只有「没裁过的」走原文件通道;裁过的已经转成 base64,再带上原文件就重复了 */
function syncInput() {
if (!canSync) return;
var dt = new DataTransfer();
picked.forEach(function (it) { dt.items.add(it.file); });
picked.forEach(function (it) {
if (!it.cropped) dt.items.add(it.file);
});
input.files = dt.files;
}
function totalBytes() {
return picked.reduce(function (sum, it) { return sum + it.file.size; }, 0);
return picked.reduce(function (sum, it) { return sum + bytesOf(it); }, 0);
}
function setStatus(text, isError) {
@@ -609,38 +693,60 @@ $tripField = [
var card = document.createElement('div');
card.className = 'photo-card';
card.innerHTML =
(it.url
? '<img src="' + it.url + '" alt="">'
: '<div class="attachment-icon">' + esc(extOf(it.file.name).toUpperCase() || 'FILE') + '</div>')
(it.thumb
? '<img src="' + esc(it.thumb) + '" alt="">'
: '<div class="attachment-icon">' + esc((extOf(it.name).toUpperCase()) || 'FILE') + '</div>')
+ (canSync
? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>'
: '')
+ (it.cropped ? '<span class="photo-badge">已裁剪</span>' : '')
+ '<div class="photo-cap">'
+ '<span class="photo-cap-name" title="' + esc(it.file.name) + '">' + esc(it.file.name) + '</span>'
+ '<span class="photo-cap-sub">' + esc(fmtSize(it.file.size)) + '</span>'
+ '<span class="photo-cap-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
+ '<span class="photo-cap-sub">' + esc(fmtSize(bytesOf(it))) + '</span>'
+ '</div>';
preview.appendChild(card);
});
syncInput();
syncCropJson();
if (toolbar) {
var any = picked.some(function (it) { return it.croppable; });
toolbar.style.display = any ? '' : 'none';
}
if (!picked.length) {
setStatus('', false);
return;
}
var total = totalBytes();
var over = POST_LIMIT > 0 && total > POST_LIMIT;
var croppedCount = picked.filter(function (it) { return it.cropped; }).length;
setStatus(
picked.length + ' 个待上传 · 合计约 ' + fmtSize(total)
+ (croppedCount > 0 ? '(' + croppedCount + ' 张已裁剪)' : '')
+ (POST_LIMIT > 0 ? '(服务器单次上限 ' + fmtSize(POST_LIMIT) + ')' : '')
+ (over ? ' —— 超了。请分几次保存,或先保存节点、再进「📷 照片」页上传。' : ''),
over
);
}
/** 把裁剪结果写进隐藏字段(原生表单提交也能带上 base64) */
function syncCropJson() {
if (!cropJson) return;
var arr = [];
picked.forEach(function (it) {
if (it.cropped) arr.push({ name: it.name, data: it.cropped });
});
cropJson.value = arr.length ? JSON.stringify(arr) : '';
}
function addFiles(list) {
var arr = Array.prototype.slice.call(list || []);
if (!arr.length) return;
var errors = [];
var addedCroppable = false;
arr.forEach(function (f) {
if (picked.length >= MAX_FILES) {
errors.push(f.name + ':一次最多 ' + MAX_FILES + ' 个');
@@ -654,24 +760,42 @@ $tripField = [
errors.push(f.name + ':超过单张 ' + (MAX_ONE / 1024 / 1024) + 'MB');
return;
}
var image = isImageFile(f);
var url = image ? URL.createObjectURL(f) : '';
var croppable = image && canvasSafe(f);
if (croppable) addedCroppable = true;
picked.push({
uid: ++seq,
file: f,
key: key,
url: isImageFile(f) ? URL.createObjectURL(f) : ''
name: f.name || ('photo.' + (extOf(f.name) || 'jpg')),
origName: f.name || ('photo.' + (extOf(f.name) || 'jpg')),
type: f.type || '',
origUrl: url,
thumb: url,
image: image,
croppable: croppable,
cropped: null,
reviewed: false
});
});
syncInput();
render();
if (errors.length) setStatus(errors.join(';'), true);
if (errors.length) { setStatus(errors.join(';'), true); return; }
// 新选进来的图片里有能裁的 → 自动开始逐张裁剪;不想裁就一路点「确定」或直接「取消」
if (addedCroppable && modal && typeof window.Cropper !== 'undefined') {
cropStopped = false;
advanceCrop();
}
}
function removeAt(idx) {
var it = picked[idx];
if (!it) return;
if (it.url) { try { URL.revokeObjectURL(it.url); } catch (e) {} }
if (it.origUrl) { try { URL.revokeObjectURL(it.origUrl); } catch (e) {} }
if (cropUid === it.uid) closeCrop();
picked.splice(idx, 1);
syncInput();
render();
}
@@ -711,6 +835,153 @@ $tripField = [
window.addEventListener('dragover', function (ev) { ev.preventDefault(); });
window.addEventListener('drop', function (ev) { ev.preventDefault(); });
// ── 裁剪 ──────────────────────────────────────────────────
function advanceCrop() {
if (cropStopped) return;
for (var i = 0; i < picked.length; i++) {
if (picked[i].croppable && !picked[i].reviewed) {
openCrop(picked[i].uid);
return;
}
}
}
function openCrop(uid) {
var it = find(uid);
if (!it || !it.croppable) return;
if (typeof window.Cropper === 'undefined') {
it.reviewed = true;
setStatus('裁剪组件没加载出来,这张会按原图上传。', false);
return;
}
cropUid = uid;
var pending = picked.filter(function (x) { return x.croppable && !x.reviewed; }).length;
cropTitle.textContent = '裁剪:' + it.name + (pending > 1 ? '(还有 ' + (pending - 1) + ' 张待处理)' : '');
// 每次都换一个新的 img 节点,避免 Cropper 复用旧实例时状态残留
var fresh = document.createElement('img');
fresh.id = 'crop-image';
fresh.alt = '';
cropImg.parentNode.replaceChild(fresh, cropImg);
cropImg = fresh;
cropImg.onerror = function () {
var cur = find(cropUid);
if (cur) { cur.croppable = false; cur.reviewed = true; }
closeCrop();
render();
setStatus('浏览器解不开这张图(可能是 HEIC 等格式),会按原图上传。', false);
};
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 // 自由比例
});
// 挂到 window 上:无头浏览器交互测试要驱动 Cropper(拖拽在无头下不可靠),
// 线上也是现成的调试入口,不引入额外状态。
window.__nodeCropper = cropper;
};
cropImg.src = it.cropped ? it.cropped : (it.thumb || it.origUrl);
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;
}
if (modal) {
modal.addEventListener('click', function (ev) {
if (ev.target.getAttribute && ev.target.getAttribute('data-close') === '1') {
stopCropPass();
}
});
document.getElementById('crop-cancel').addEventListener('click', stopCropPass);
document.getElementById('crop-reset').addEventListener('click', function () {
if (cropper) cropper.reset();
});
document.getElementById('crop-apply').addEventListener('click', applyCrop);
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && modal.classList.contains('open')) stopCropPass();
});
}
/** 「取消」= 这张跳过,并且本次逐张裁剪到此为止(用户明确表示不想裁) */
function stopCropPass() {
var cur = cropUid !== null ? find(cropUid) : null;
if (cur) cur.reviewed = true;
cropStopped = true;
closeCrop();
render();
}
function applyCrop() {
if (!cropper || cropUid === null) { closeCrop(); return; }
var it = find(cropUid);
if (!it) { closeCrop(); return; }
// 选框还是整图 = 用户没真的裁。保留原文件,不重新编码。
var d = null, img = null;
try { d = cropper.getData(true); img = cropper.getImageData(); } catch (e) {}
var full = !!(d && img
&& d.x <= 0 && d.y <= 0
&& d.width >= Math.round(img.naturalWidth) - 1
&& d.height >= Math.round(img.naturalHeight) - 1);
if (full) {
it.cropped = null;
it.thumb = it.origUrl;
it.name = it.origName;
} else {
// 限制长边,避免 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.origName.replace(/\.[^.]+$/, '') + (mime === 'image/png' ? '.png'
: mime === 'image/webp' ? '.webp' : '.jpg');
}
it.reviewed = true;
closeCrop();
render();
advanceCrop(); // 还有没处理过的图就继续弹下一张
}
if (cropAll) {
cropAll.addEventListener('click', function () {
cropStopped = false;
picked.forEach(function (it) { if (it.croppable) it.reviewed = false; });
advanceCrop();
});
}
// ── 提交前的总量拦截 ──────────────────────────────────────
form.addEventListener('submit', function (ev) {
var total = totalBytes();
if (POST_LIMIT > 0 && total > POST_LIMIT) {
@@ -718,8 +989,85 @@ $tripField = [
setStatus('这次要传约 ' + fmtSize(total) + ',超过服务器单次请求上限 '
+ fmtSize(POST_LIMIT) + '。请分几次保存,或先保存节点、再进「📷 照片」页上传。', true);
dropZone.scrollIntoView({ block: 'center' });
return;
}
saveDraft(); // 提交瞬间把最新值再存一份,失败回填就靠它
});
// ── 填写草稿(sessionStorage)────────────────────────────
// 为什么需要:校验失败这类错误,服务端能把 $_POST 原样回填(old());
// 但「请求体超限」「会话过期」这类情况 PHP 连 $_POST 都拿不到,服务端无从回填。
// 只能靠浏览器自己留一份。
//
// 什么时候恢复:**页面上有错误提示条**时才恢复。这是刻意收窄的:
// · 保存成功 → 跳到活动详情页(没有表单)→ 草稿留着;
// · 下次再开一个全新节点表单 → 页面没有错误条 → 草稿被清掉、也不会回填,
// 所以不会出现「新建一个节点,表单里却有上上次的内容」。
var DRAFT_KEY = 'trip-node-draft|' + String(form.getAttribute('data-draft-key') || '');
var serverFilled = form.getAttribute('data-old') === '1';
var saveTimer = null;
function draftable(el) {
if (!el.name || el.disabled) return false;
if (el.name === '_csrf' || el.name === 'cropped') return false;
if (el.type === 'file' || el.type === 'submit' || el.type === 'button') return false;
return true;
}
function saveDraft() {
var data = {};
Array.prototype.forEach.call(form.elements, function (el) {
if (!draftable(el)) return;
if (el.type === 'checkbox') {
if (!data[el.name]) data[el.name] = [];
if (el.checked) data[el.name].push(el.value);
return;
}
data[el.name] = el.value;
});
try { sessionStorage.setItem(DRAFT_KEY, JSON.stringify(data)); } catch (e) { /* 存不下就算了 */ }
}
function restoreDraft(data) {
Array.prototype.forEach.call(form.elements, function (el) {
if (!draftable(el) || !(el.name in data)) return;
var v = data[el.name];
if (el.type === 'checkbox') {
el.checked = Array.isArray(v) && v.indexOf(el.value) >= 0;
return;
}
el.value = String(v == null ? '' : v);
});
// 时/分下拉恢复后要重刷「出行方式」那块的标签显隐
if (typeof refreshTransport === 'function') refreshTransport();
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 400);
}
form.addEventListener('input', scheduleSave);
form.addEventListener('change', scheduleSave);
(function initDraft() {
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(DRAFT_KEY) || 'null'); } catch (e) {}
if (!saved) return;
var hasAlert = !!document.querySelector('main .alert');
if (!hasAlert) {
// 不是从「保存失败」回来的 —— 这是全新一次填写,旧草稿不该再出现
try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
return;
}
if (serverFilled) {
// 服务端已经把用户刚填的值原样填回来了,草稿是冗余的
try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
return;
}
restoreDraft(saved);
setStatus('已恢复上次没保存成功的内容(照片需要重新选择)。', false);
})();
})();
})();
</script>