节点表单: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:
@@ -73,7 +73,9 @@ foreach ($familyUsers as $u) {
|
||||
|
||||
<details class="inline-panel">
|
||||
<summary>+ 添加 / 修改参与人</summary>
|
||||
<form method="post" action="<?= url('members/store') ?>" class="form form-plain">
|
||||
<?php $mOldFilled = !empty($_SESSION['_old']); ?>
|
||||
<form method="post" action="<?= url('members/store') ?>" class="form form-plain"
|
||||
data-old="<?= $mOldFilled ? '1' : '0' ?>">
|
||||
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
|
||||
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
|
||||
|
||||
@@ -81,8 +83,9 @@ foreach ($familyUsers as $u) {
|
||||
<label>成员 *</label>
|
||||
<select name="user_id" required>
|
||||
<option value="">— 请选择 —</option>
|
||||
<?php $mSelUser = (string)old('user_id', ''); ?>
|
||||
<?php foreach ($familyUsers as $u): ?>
|
||||
<option value="<?= (int)$u['id'] ?>">
|
||||
<option value="<?= (int)$u['id'] ?>"<?= $mSelUser === (string)(int)$u['id'] ? ' selected' : '' ?>>
|
||||
<?= e($u['name']) ?><?= isset($joinedIds[(int)$u['id']]) ? '(已加入,保存将覆盖)' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
@@ -93,9 +96,11 @@ foreach ($familyUsers as $u) {
|
||||
<div class="form-row">
|
||||
<label>角色标签(可多选)</label>
|
||||
<div class="check-grid">
|
||||
<?php $mSelTags = old_array('role_tag_ids'); ?>
|
||||
<?php foreach ($roleTags as $tagId => $tagName): ?>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="role_tag_ids[]" value="<?= (int)$tagId ?>">
|
||||
<input type="checkbox" name="role_tag_ids[]" value="<?= (int)$tagId ?>"
|
||||
<?= in_array((string)(int)$tagId, $mSelTags, true) ? ' checked' : '' ?>>
|
||||
<span><?= e($tagName) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
@@ -112,14 +117,15 @@ foreach ($familyUsers as $u) {
|
||||
<div class="form-row">
|
||||
<label>心情(可选)</label>
|
||||
<div class="emotion-grid">
|
||||
<?php $mSelEmotion = (string)old('emotion', ''); ?>
|
||||
<label class="emotion-option">
|
||||
<input type="radio" name="emotion" value="" checked>
|
||||
<input type="radio" name="emotion" value=""<?= $mSelEmotion === '' ? ' checked' : '' ?>>
|
||||
<span class="emotion-face">🚫</span>
|
||||
<span class="emotion-text">不记录</span>
|
||||
</label>
|
||||
<?php foreach ($emotions as $emoji => $label): ?>
|
||||
<label class="emotion-option" title="<?= e($label) ?>">
|
||||
<input type="radio" name="emotion" value="<?= e($emoji) ?>">
|
||||
<input type="radio" name="emotion" value="<?= e($emoji) ?>"<?= $mSelEmotion === (string)$emoji ? ' checked' : '' ?>>
|
||||
<span class="emotion-face"><?= e($emoji) ?></span>
|
||||
<span class="emotion-text"><?= e($label) ?></span>
|
||||
</label>
|
||||
@@ -129,7 +135,8 @@ foreach ($familyUsers as $u) {
|
||||
|
||||
<div class="form-row">
|
||||
<label>签到时间(可选,留空表示未签到)</label>
|
||||
<input type="datetime-local" name="check_in_time">
|
||||
<?php $dtName = 'check_in_time'; $dtValue = old_dt('check_in_time', null); $dtRequired = false; $dtId = 'check-in-date'; ?>
|
||||
<?php require __DIR__ . '/../_datetime.php'; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
|
||||
@@ -165,22 +165,8 @@ $iconByMime = [
|
||||
</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>
|
||||
<?php /* ── 裁剪弹窗(与节点表单共用同一份模板,避免两边漂移) ───────────── */ ?>
|
||||
<?php require dirname(__DIR__) . '/_crop_modal.php'; ?>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+388
-40
@@ -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, '&').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.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>
|
||||
|
||||
Reference in New Issue
Block a user