节点表单: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
+25
View File
@@ -444,6 +444,31 @@ input[type="datetime-local"], input[type="date"] { cursor: pointer; }
input[type="datetime-local"]::-webkit-calendar-picker-indicator, input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="date"]::-webkit-calendar-picker-indicator { cursor: pointer; padding: 2px; } input[type="date"]::-webkit-calendar-picker-indicator { cursor: pointer; padding: 2px; }
/* ── 日期 + 24 小时制「时:分」组合输入(templates/_datetime.php)──────────────
原生 datetime-local 的显示格式由浏览器 UI 语言/系统时间格式决定,中文环境渲染成
「下午 03:30」,页面和元素的 lang 都压不住(实测),所以拆成 date + 时/分两个下拉。
24 小时制由控件本身决定,浏览器改不掉。 */
.dt-input { display: flex; align-items: center; gap: 6px; }
.dt-input .dt-date { flex: 1 1 120px; min-width: 0; }
.dt-input .dt-hour,
.dt-input .dt-minute {
flex: 0 0 64px; width: 64px; padding: 9px 4px;
text-align: center; text-align-last: center;
}
.dt-input .dt-colon { color: var(--text-mute); font-weight: 650; }
/* 裁剪入口(节点表单):「逐张裁剪」按钮 + 一句说明 */
.crop-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
.crop-toolbar .muted { margin-top: 0; font-size: 12px; }
/* 节点表单待上传卡片上的「已裁剪」角标。
.photo-badge 本来是给照片页 .photo-item(flex 纵向布局)的流式元素用的,
这里换到 .photo-card(position:relative)就得改成绝对定位,压在缩略图左上角。 */
.photo-card > .photo-badge {
position: absolute; top: 6px; left: 6px; margin: 0;
background: rgba(18, 133, 90, .92); color: #fff;
}
.form-actions { .form-actions {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border);
+5 -1
View File
@@ -28,8 +28,12 @@ class MemberController
(int)($_POST['user_id'] ?? 0), (int)($_POST['user_id'] ?? 0),
array_map('intval', $roleTagIds), array_map('intval', $roleTagIds),
trim((string)($_POST['emotion'] ?? '')), trim((string)($_POST['emotion'] ?? '')),
(string)($_POST['check_in_time'] ?? '') // 「日期 + 24 小时制时/分」组合控件;留空时 read_dt_input 返回 null,语义是「未签到」
(string)(read_dt_input('check_in_time') ?? '')
); );
if (!$result['ok']) {
flash_old($_POST); // 失败也别让人白填
}
flash('error', $result['ok'] ? '已保存参与人' : $result['error']); flash('error', $result['ok'] ? '已保存参与人' : $result['error']);
redirect('nodes/show', ['id' => $nodeId], 'members'); redirect('nodes/show', ['id' => $nodeId], 'members');
+8 -14
View File
@@ -64,6 +64,8 @@ class NodeController
$input = $this->collectInput($db, $activity, $category); $input = $this->collectInput($db, $activity, $category);
if ($input['error'] !== null) { if ($input['error'] !== null) {
// 校验失败必须把用户刚填的内容原样带回去,否则一重定向表单就是空的,等于白填
flash_old($_POST);
flash('error', $input['error']); flash('error', $input['error']);
redirect('nodes/create', ['activity_id' => $activity['id'], 'category' => $category]); redirect('nodes/create', ['activity_id' => $activity['id'], 'category' => $category]);
} }
@@ -118,6 +120,7 @@ class NodeController
$input = $this->collectInput($db, $activity, $category, $node); $input = $this->collectInput($db, $activity, $category, $node);
if ($input['error'] !== null) { if ($input['error'] !== null) {
flash_old($_POST); // 同 store():失败不能让用户白填
flash('error', $input['error']); flash('error', $input['error']);
redirect('nodes/edit', ['id' => (int)$node['id']]); redirect('nodes/edit', ['id' => (int)$node['id']]);
} }
@@ -378,8 +381,9 @@ class NodeController
*/ */
private function collectInput(\PDO $db, array $activity, string $category, ?array $node = null): array private function collectInput(\PDO $db, array $activity, string $category, ?array $node = null): array
{ {
$startTime = $this->normalizeDateTime((string)($_POST['start_time'] ?? '')); // 「日期 + 24 小时制时/分」组合控件(read_dt_input 里说明了为什么不用 datetime-local)
$endTime = $this->normalizeDateTime((string)($_POST['end_time'] ?? '')); $startTime = read_dt_input('start_time');
$endTime = read_dt_input('end_time');
if ($startTime === null || $endTime === null) { if ($startTime === null || $endTime === null) {
return ['error' => '开始和结束时间必填', 'data' => []]; return ['error' => '开始和结束时间必填', 'data' => []];
} }
@@ -451,17 +455,6 @@ class NodeController
return ['error' => null, 'data' => $data]; return ['error' => null, 'data' => $data];
} }
/** datetime-local 传上来是 2026-09-12T10:30,统一成 MySQL 认的 "Y-m-d H:i:s" */
private function normalizeDateTime(string $value): ?string
{
$value = trim($value);
if ($value === '') return null;
$value = str_replace('T', ' ', $value);
$ts = strtotime($value);
if ($ts === false) return null;
return date('Y-m-d H:i:s', $ts);
}
private function fetchOwnActivity(int $id): array private function fetchOwnActivity(int $id): array
{ {
if ($id <= 0) { if ($id <= 0) {
@@ -530,7 +523,8 @@ class NodeController
/** /**
* 请求体超限的统一提示。 * 请求体超限的统一提示。
* *
* 这时 $_POST 已经被 PHP 丢掉了,拿不到 activity_id,只能尽量退回一个合理页面。 * 这时 $_POST 已经被 PHP 丢掉了,**无从回填**(服务端拿不到任何字段),
* 只能尽量退回一个合理页面;已填内容的恢复靠表单里的 sessionStorage 草稿。
*/ */
private function flashTooLarge(): void private function flashTooLarge(): void
{ {
+107
View File
@@ -88,6 +88,113 @@ function csrf_token(): string
return $_SESSION['_csrf']; return $_SESSION['_csrf'];
} }
/**
* 把这次提交的原始字段存进 session,供紧接着的那次渲染回填。
*
* 为什么需要:控制器校验失败时只能 redirect(POST/Redirect/GET),一重定向表单就被重新渲染成
* 空的了——用户填了半天的内容全没了。把 $_POST 原样留着,模板用 old() 取回来即可。
*
* 生命周期是「一次渲染」:layout.php 渲染完正文就 unset(见那里的注释),
* 所以它不会跟着用户跑到别的页面,也不会把上一次的残留混进下一次新建。
*/
function flash_old(array $data): void
{
$_SESSION['_old'] = $data;
}
/** 回填值:读取上一次提交的同名字段;没有就给 $default。数组字段(如 xxx[])不参与回填 */
function old(string $key, ?string $default = null): ?string
{
$v = $_SESSION['_old'][$key] ?? null;
if ($v === null || is_array($v)) {
return $default;
}
return (string)$v;
}
/** 回填「多选」字段(如 delete_attachments[])——用于恢复勾选状态 */
function old_array(string $key): array
{
$v = $_SESSION['_old'][$key] ?? null;
if (!is_array($v)) {
return [];
}
return array_map(static fn($x) => (string)$x, $v);
}
/**
* 回填「日期 + 时:分」组合控件(见 templates/_datetime.php)。
*
* 三个子字段要合并回一个 "Y-m-d H:i:s" 才好喂给模板里的拆分逻辑;
* 只要日期存在就认为用户在填(时分缺省按 00 补)。
*/
function old_dt(string $name, ?string $fallback): string
{
$date = old($name . '_date');
if ($date === null || trim($date) === '') {
return (string)($fallback ?? '');
}
$h = old($name . '_hour', '00');
$m = old($name . '_minute', '00');
return trim($date) . ' ' . str_pad((string)$h, 2, '0', STR_PAD_LEFT)
. ':' . str_pad((string)$m, 2, '0', STR_PAD_LEFT) . ':00';
}
/** 把各种写法的时间串统一成 MySQL 认的 "Y-m-d H:i:s";认不出来返回 null */
function normalize_dt_string(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
// datetime-local 传上来是 2026-09-12T10:30
$value = str_replace('T', ' ', $value);
$ts = strtotime($value);
if ($ts === false) {
return null;
}
return date('Y-m-d H:i:s', $ts);
}
/**
* 读取「日期 + 24 小时制时/分」三个控件拼成的时间。
*
* ⚠️ 为什么不用 <input type="datetime-local">:它的**显示格式由浏览器 UI 语言 /
* 系统时间格式决定**,页面和元素的 lang 属性完全无效。实测(chromium 1243):
* --lang=zh-CN → 「2026/09/23 下午 03:30」(12 小时制)
* --lang=en-GB → 「23/09/2026 15:30」(24 小时制)
* 连 <html lang="zh-CN"> + 元素 lang="en-GB" 都压不过它,所以只能换成
* 「原生日期选择器 + 时/分两个下拉」,24 小时制是控件本身决定的,不可能被浏览器改掉。
*
* 兼容旧的单个字段名(值形如 2026-09-23T15:30)——旧书签、接口测试可能还在这么传。
*/
function read_dt_input(string $name): ?string
{
$date = trim((string)($_POST[$name . '_date'] ?? ''));
$hRaw = trim((string)($_POST[$name . '_hour'] ?? ''));
$mRaw = trim((string)($_POST[$name . '_minute'] ?? ''));
if ($date === '' && $hRaw === '' && $mRaw === '') {
return normalize_dt_string((string)($_POST[$name] ?? ''));
}
if ($date === '') {
return null; // 只选了时分、没选日期 → 视为没填
}
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $mt)) {
return null;
}
if (!checkdate((int)$mt[2], (int)$mt[3], (int)$mt[1])) {
return null;
}
$h = $hRaw === '' ? 0 : (int)$hRaw;
$m = $mRaw === '' ? 0 : (int)$mRaw;
if ($h < 0 || $h > 23 || $m < 0 || $m > 59) {
return null;
}
return sprintf('%s %02d:%02d:00', $date, $h, $m);
}
/** /**
* 校验 CSRF。两个通道都收: * 校验 CSRF。两个通道都收:
* 表单 → 隐藏字段 _csrf * 表单 → 隐藏字段 _csrf
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* 裁剪弹窗(Cropper.js 的容器)。两个页面共用:
* · templates/nodes/attachments.php(照片页)
* · templates/nodes/form.php(节点表单)
* 抽成一份是为了避免两处漂移——id 改了哪边忘了另一边,弹窗就静默打不开。
*
* JS 约定(两个页面的脚本都按这套 id 找):
* #crop-modal 弹窗容器(.open 时显示)
* #crop-title 标题
* #crop-hint 提示行
* #crop-image 每次打开都要**新建**一个 img 换进去(Cropper 不能复用旧实例,会状态残留)
* #crop-cancel / #crop-reset / #crop-apply 三个按钮
*
* 可覆盖变量:
* $cropHint 提示文案(默认为通用文案)
*/
$cropHint = $cropHint ?? '拖动选框选好范围后点「确定」。不想裁就直接点「确定」——会按原图上传,不会重新压缩。';
?>
<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" id="crop-hint"><?= e($cropHint) ?></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 unset($cropHint); ?>
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* 「日期 + 24 小时制时:分」组合输入控件。
*
* ⚠️ 为什么不用 <input type="datetime-local">:它的显示格式由**浏览器 UI 语言 /
* 系统时间格式**决定,<html lang> 与元素 lang 都压不过它(实测 chromium 1243:
* --lang=zh-CN 渲染成「下午 03:30」,用户会混淆;--lang=en-GB 才是 24 小时制)。
* 拆成「原生日期选择器 + 时/分两个下拉」后,24 小时制由控件本身决定,浏览器改不掉。
* 日期部分仍是原生 date 控件(layout.php 里的 showPicker 全局脚本对它同样生效,
* 点输入框任意位置都能弹出日历)。
*
* 调用方式:include 前 set 好这几个变量(模板里只有它们会被读):
* $dtName 字段基名,如 'start_time' → 产出 *_date / *_hour / *_minute 三个字段
* $dtValue 可空;"Y-m-d H:i:s" / "Y-m-d H:i" / null。服务端取值用 read_dt_input($dtName)
* $dtRequired 日期是否必填(默认 false;必填时在日期控件上加 required)
* $dtId 可选;给日期控件的 id(供 <label for>)
*/
$dtName = (string)($dtName ?? 'time');
$dtValue = $dtValue ?? null;
$dtRequired = (bool)($dtRequired ?? false);
$dtId = (string)($dtId ?? '');
$dtTs = (!empty($dtValue) && ($t = strtotime((string)$dtValue))) ? $t : false;
$dtDate = $dtTs ? date('Y-m-d', $dtTs) : '';
$dtHour = $dtTs ? (int)date('G', $dtTs) : 0; // G = 0..23,天然 24 小时制
$dtMin = $dtTs ? (int)date('i', $dtTs) : 0;
?>
<div class="dt-input">
<input type="date" class="dt-date" name="<?= e($dtName) ?>_date"<?= $dtId !== '' ? ' id="' . e($dtId) . '"' : '' ?>
value="<?= e($dtDate) ?>"<?= $dtRequired ? ' required' : '' ?>
aria-label="日期">
<select class="dt-hour" name="<?= e($dtName) ?>_hour" aria-label="时(24 小时制)">
<?php for ($h = 0; $h < 24; $h++): $hv = sprintf('%02d', $h); ?>
<option value="<?= $hv ?>"<?= $h === $dtHour ? ' selected' : '' ?>><?= $hv ?></option>
<?php endfor; ?>
</select>
<span class="dt-colon" aria-hidden="true">:</span>
<select class="dt-minute" name="<?= e($dtName) ?>_minute" aria-label="分">
<?php for ($m = 0; $m < 60; $m++): $mv = sprintf('%02d', $m); ?>
<option value="<?= $mv ?>"<?= $m === $dtMin ? ' selected' : '' ?>><?= $mv ?></option>
<?php endfor; ?>
</select>
</div>
<?php /* 用完即清,避免残留的 $dtName 影响下一次 include */ unset($dtName, $dtValue, $dtRequired, $dtId, $dtTs, $dtDate, $dtHour, $dtMin); ?>
+6
View File
@@ -110,6 +110,12 @@ $__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·';
<?php endif; ?> <?php endif; ?>
<?php require $content; ?> <?php require $content; ?>
</main> </main>
<?php
/* 上一次提交的原始字段(flash_old() 存的)只服务这一次渲染,用完立刻清掉。
不能不清:不清的话「上次失败时填的内容」会一直留在 session 里,
用户下次随便开一个别的表单就会被莫名回填。 */
unset($_SESSION['_old']);
?>
<?php if ($__isLoggedIn): ?> <?php if ($__isLoggedIn): ?>
<footer class="footer">家庭出行记录</footer> <footer class="footer">家庭出行记录</footer>
+13 -6
View File
@@ -73,7 +73,9 @@ foreach ($familyUsers as $u) {
<details class="inline-panel"> <details class="inline-panel">
<summary>+ 添加 / 修改参与人</summary> <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="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>"> <input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
@@ -81,8 +83,9 @@ foreach ($familyUsers as $u) {
<label>成员 *</label> <label>成员 *</label>
<select name="user_id" required> <select name="user_id" required>
<option value="">— 请选择 —</option> <option value="">— 请选择 —</option>
<?php $mSelUser = (string)old('user_id', ''); ?>
<?php foreach ($familyUsers as $u): ?> <?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']]) ? '(已加入,保存将覆盖)' : '' ?> <?= e($u['name']) ?><?= isset($joinedIds[(int)$u['id']]) ? '(已加入,保存将覆盖)' : '' ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
@@ -93,9 +96,11 @@ foreach ($familyUsers as $u) {
<div class="form-row"> <div class="form-row">
<label>角色标签(可多选)</label> <label>角色标签(可多选)</label>
<div class="check-grid"> <div class="check-grid">
<?php $mSelTags = old_array('role_tag_ids'); ?>
<?php foreach ($roleTags as $tagId => $tagName): ?> <?php foreach ($roleTags as $tagId => $tagName): ?>
<label class="checkbox-label"> <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> <span><?= e($tagName) ?></span>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
@@ -112,14 +117,15 @@ foreach ($familyUsers as $u) {
<div class="form-row"> <div class="form-row">
<label>心情(可选)</label> <label>心情(可选)</label>
<div class="emotion-grid"> <div class="emotion-grid">
<?php $mSelEmotion = (string)old('emotion', ''); ?>
<label class="emotion-option"> <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-face">🚫</span>
<span class="emotion-text">不记录</span> <span class="emotion-text">不记录</span>
</label> </label>
<?php foreach ($emotions as $emoji => $label): ?> <?php foreach ($emotions as $emoji => $label): ?>
<label class="emotion-option" title="<?= e($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-face"><?= e($emoji) ?></span>
<span class="emotion-text"><?= e($label) ?></span> <span class="emotion-text"><?= e($label) ?></span>
</label> </label>
@@ -129,7 +135,8 @@ foreach ($familyUsers as $u) {
<div class="form-row"> <div class="form-row">
<label>签到时间(可选,留空表示未签到)</label> <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>
<div class="form-actions"> <div class="form-actions">
+2 -16
View File
@@ -165,22 +165,8 @@ $iconByMime = [
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php /* ── 裁剪弹窗 ─────────────────────────────────────────────── */ ?> <?php /* ── 裁剪弹窗(与节点表单共用同一份模板,避免两边漂移) ───────────── */ ?>
<div class="modal" id="crop-modal" aria-hidden="true"> <?php require dirname(__DIR__) . '/_crop_modal.php'; ?>
<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> <script>
(function () { (function () {
+388 -40
View File
@@ -1,7 +1,18 @@
<?php <?php
$isEdit = $isEdit ?? false; $isEdit = $isEdit ?? false;
$n = $node; // null=新建;数组=编辑 $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 = [ $statusLabels = [
'planned' => '待执行', 'planned' => '待执行',
'ongoing' => '进行中', 'ongoing' => '进行中',
@@ -13,13 +24,25 @@ $statusOptions = $category === 'plan'
? ['planned', 'completed', 'cancelled'] ? ['planned', 'completed', 'cancelled']
: ['completed', 'ongoing', 'planned', 'cancelled']; : ['completed', 'ongoing', 'planned', 'cancelled'];
$dtLocal = static function ($value): string { // 起点 / 终点:库里的 JSON 先解出来,再让上一次提交的原始值覆盖它
if (empty($value)) return ''; $sLocRaw = json_decode_field($n['start_location'] ?? null);
$ts = strtotime((string)$value); $eLocRaw = json_decode_field($n['end_location'] ?? null);
return $ts ? date('Y-m-d\TH:i', $ts) : ''; $sLoc = [
}; 'name' => $oldStr('start_location_name', $sLocRaw['name'] ?? ''),
$sLoc = json_decode_field($n['start_location'] ?? null); 'address' => $oldStr('start_location_address', $sLocRaw['address'] ?? ''),
$eLoc = json_decode_field($n['end_location'] ?? null); '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> </div>
<form method="post" action="<?= url($isEdit ? 'nodes/update' : 'nodes/store') ?>" <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="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="activity_id" value="<?= (int)$activity['id'] ?>"> <input type="hidden" name="activity_id" value="<?= (int)$activity['id'] ?>">
<input type="hidden" name="node_category" value="<?= e($category) ?>"> <input type="hidden" name="node_category" value="<?= e($category) ?>">
@@ -63,8 +88,9 @@ $tripField = [
<label>关联计划节点(可选)</label> <label>关联计划节点(可选)</label>
<select name="original_plan_node_id"> <select name="original_plan_node_id">
<option value="">— 不关联,此为临时新增 —</option> <option value="">— 不关联,此为临时新增 —</option>
<?php $selPlan = $oldStr('original_plan_node_id', ''); ?>
<?php foreach ($planNodes as $p): ?> <?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'])) ?> <?= e($p['title'] ?: '(无标题)') ?> · <?= date('m-d H:i', strtotime($p['start_time'])) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
@@ -76,9 +102,10 @@ $tripField = [
<div class="form-row"> <div class="form-row">
<label>节点类型 *</label> <label>节点类型 *</label>
<select name="node_type_id" required> <select name="node_type_id" required>
<?php $selType = $oldStr('node_type_id', (string)($n['node_type_id'] ?? 0)); ?>
<?php foreach ($nodeTypes as $t): ?> <?php foreach ($nodeTypes as $t): ?>
<option value="<?= (int)$t['id'] ?>" <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']) ?> <?= e($t['icon'] . ' ' . $t['name']) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
@@ -88,18 +115,22 @@ $tripField = [
<div class="form-row two-col"> <div class="form-row two-col">
<div> <div>
<label>开始时间 *</label> <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>
<div> <div>
<label>结束时间 *</label> <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> </div>
<div class="form-row"> <div class="form-row">
<label>标题</label> <label>标题</label>
<input type="text" name="title" maxlength="200" placeholder="例如:出发去奥森" <input type="text" name="title" maxlength="200" placeholder="例如:出发去奥森"
value="<?= e($n['title'] ?? '') ?>"> value="<?= e($oldStr('title', $n['title'] ?? '')) ?>">
</div> </div>
<fieldset class="fieldset"> <fieldset class="fieldset">
@@ -109,9 +140,10 @@ $tripField = [
<label for="travel-mode-select">选择方式</label> <label for="travel-mode-select">选择方式</label>
<select name="travel_mode_id" id="travel-mode-select"> <select name="travel_mode_id" id="travel-mode-select">
<option value="" data-mode-type="">— 不使用 —</option> <option value="" data-mode-type="">— 不使用 —</option>
<?php $selMode = $oldStr('travel_mode_id', (string)($n['travel_mode_id'] ?? '')); ?>
<?php foreach ($travelModes as $tm): ?> <?php foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>" <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'] : '')) ?> <?= e(($modeLabels[$tm['mode_type']] ?? $tm['mode_type']) . (!empty($tm['plate_number']) ? ' · ' . $tm['plate_number'] : '')) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
@@ -126,7 +158,7 @@ $tripField = [
<label id="trip-number-label" for="trip-number-input">车次 / 航班号</label> <label id="trip-number-label" for="trip-number-input">车次 / 航班号</label>
<input type="text" name="trip_number" id="trip-number-input" maxlength="50" <input type="text" name="trip_number" id="trip-number-input" maxlength="50"
placeholder="例如:G1234、CA1234" 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 class="muted" id="trip-number-hint"></div>
</div> </div>
@@ -178,14 +210,16 @@ $tripField = [
<div class="form-row"> <div class="form-row">
<label>详细描述</label> <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> </div>
<?php <?php
/* 照片附件:随节点表单一起提交。 /* 照片附件:随节点表单一起提交。
新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」; 新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」;
编辑时把已有照片带出来,可勾选删除。 */ 编辑时把已有照片带出来,可勾选删除。
新选的图片可以先裁剪:不想裁就直接点「确定」,会按原图上传。 */
$existingAtts = $attachments ?? []; $existingAtts = $attachments ?? [];
$checkedDel = old_array('delete_attachments'); // 保存失败时恢复勾选状态
?> ?>
<fieldset class="fieldset" id="photo-field"> <fieldset class="fieldset" id="photo-field">
<legend>照片附件(可多张)</legend> <legend>照片附件(可多张)</legend>
@@ -202,7 +236,8 @@ $tripField = [
?> ?>
<label class="photo-pick" title="<?= e($attDesc['name']) ?>"> <label class="photo-pick" title="<?= e($attDesc['name']) ?>">
<?php /* checkbox 排在最前:CSS 用的是 input:checked ~ img 这种兄弟选择器 */ ?> <?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): ?> <?php if ($attIsImg): ?>
<img src="<?= e(media_att($attId)) ?>" alt="" loading="lazy"> <img src="<?= e(media_att($attId)) ?>" alt="" loading="lazy">
<?php else: ?> <?php else: ?>
@@ -235,6 +270,12 @@ $tripField = [
</div> </div>
<div class="form-hint" id="node-photo-status" role="status"></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="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> </div>
</fieldset> </fieldset>
@@ -254,7 +295,7 @@ $tripField = [
<div class="form-row"> <div class="form-row">
<label>取消原因(状态选「已取消」时填写)</label> <label>取消原因(状态选「已取消」时填写)</label>
<input type="text" name="cancel_reason" maxlength="500" placeholder="例如:下雨,临时改行程" <input type="text" name="cancel_reason" maxlength="500" placeholder="例如:下雨,临时改行程"
value="<?= e($n['cancel_reason'] ?? '') ?>"> value="<?= e($oldStr('cancel_reason', $n['cancel_reason'] ?? '')) ?>">
</div> </div>
</fieldset> </fieldset>
<?php endif; ?> <?php endif; ?>
@@ -265,6 +306,9 @@ $tripField = [
</div> </div>
</form> </form>
<?php /* ── 裁剪弹窗:与「照片与附件」页共用同一份模板 ────────────────────── */ ?>
<?php require __DIR__ . '/../_crop_modal.php'; ?>
<script> <script>
(function () { (function () {
// ===== 交通字段:只一个输入框,标签随出行方式变 ===== // ===== 交通字段:只一个输入框,标签随出行方式变 =====
@@ -529,18 +573,26 @@ $tripField = [
}); });
} }
// ===== 节点表单里的照片附件 ===== // ===== 节点表单里的照片附件(含裁剪)=====
// 与「照片与附件」页不同:这里不做裁剪,选好就跟着表单一起提交。 //
// 提交形态与「照片与附件」页保持同一套约定:
// 裁过的图 → 转成 base64 放进 #node-cropped-json(cropped[]),走服务端 handleCropped;
// 没裁的 → 还留在 input.files 里(files[]),省掉一次无谓的编码。
// 「确定」时如果选框还是整图(用户没动过),就当作没裁,**保留原文件**——
// 不重新编码既不损画质,也不平白多出 37% 的 base64 流量。
// //
// 必须在这一层挡住「总量超 post_max_size」:PHP 遇到超限请求会把 $_POST 与 // 必须在这一层挡住「总量超 post_max_size」:PHP 遇到超限请求会把 $_POST 与
// $_FILES **整个丢掉**,控制器只能走到 csrf_check() 失败、报「请求已过期」, // $_FILES **整个丢掉**,控制器只能走到 csrf_check() 失败、报「请求已过期」,
// 用户完全看不懂发生了什么。提前拦住才能给一句能行动的话。 // 用户完全看不懂发生了什么。提前拦住才能给一句能行动的话。
(function () { (function () {
var form = document.getElementById('node-form'); var form = document.getElementById('node-form');
var dropZone = document.getElementById('node-photo-drop'); var dropZone = document.getElementById('node-photo-drop');
var input = document.getElementById('node-photo-input'); var input = document.getElementById('node-photo-input');
var preview = document.getElementById('node-photo-preview'); var preview = document.getElementById('node-photo-preview');
var status = document.getElementById('node-photo-status'); 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; if (!form || !dropZone || !input || !preview) return;
var MAX_FILES = <?= (int)\App\Uploads::MAX_FILES ?>; 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 POST_LIMIT = <?= (int)ini_bytes((string)ini_get('post_max_size')) ?>;
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif']; 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) { function extOf(name) {
var i = String(name || '').lastIndexOf('.'); var i = String(name || '').lastIndexOf('.');
@@ -558,6 +619,13 @@ $tripField = [
if (f.type && f.type.indexOf('image/') === 0) return true; if (f.type && f.type.indexOf('image/') === 0) return true;
return IMG_EXT.indexOf(extOf(f.name)) >= 0; 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) { function fmtSize(n) {
n = Number(n) || 0; n = Number(n) || 0;
if (n < 1024) return n + ' B'; if (n < 1024) return n + ' B';
@@ -569,6 +637,19 @@ $tripField = [
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); .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 —— 决定「移除」按钮是否有意义。 // 能不能改写 input.files —— 决定「移除」按钮是否有意义。
// 只判断 DataTransfer 存在是不够的:必须真塞一个文件、再读回来验证赋值生效。 // 只判断 DataTransfer 存在是不够的:必须真塞一个文件、再读回来验证赋值生效。
@@ -586,15 +667,18 @@ $tripField = [
} }
})(); })();
/** 只有「没裁过的」走原文件通道;裁过的已经转成 base64,再带上原文件就重复了 */
function syncInput() { function syncInput() {
if (!canSync) return; if (!canSync) return;
var dt = new DataTransfer(); 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; input.files = dt.files;
} }
function totalBytes() { 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) { function setStatus(text, isError) {
@@ -609,38 +693,60 @@ $tripField = [
var card = document.createElement('div'); var card = document.createElement('div');
card.className = 'photo-card'; card.className = 'photo-card';
card.innerHTML = card.innerHTML =
(it.url (it.thumb
? '<img src="' + it.url + '" alt="">' ? '<img src="' + esc(it.thumb) + '" alt="">'
: '<div class="attachment-icon">' + esc(extOf(it.file.name).toUpperCase() || 'FILE') + '</div>') : '<div class="attachment-icon">' + esc((extOf(it.name).toUpperCase()) || 'FILE') + '</div>')
+ (canSync + (canSync
? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>' ? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>'
: '') : '')
+ (it.cropped ? '<span class="photo-badge">已裁剪</span>' : '')
+ '<div class="photo-cap">' + '<div class="photo-cap">'
+ '<span class="photo-cap-name" title="' + esc(it.file.name) + '">' + esc(it.file.name) + '</span>' + '<span class="photo-cap-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
+ '<span class="photo-cap-sub">' + esc(fmtSize(it.file.size)) + '</span>' + '<span class="photo-cap-sub">' + esc(fmtSize(bytesOf(it))) + '</span>'
+ '</div>'; + '</div>';
preview.appendChild(card); preview.appendChild(card);
}); });
syncInput();
syncCropJson();
if (toolbar) {
var any = picked.some(function (it) { return it.croppable; });
toolbar.style.display = any ? '' : 'none';
}
if (!picked.length) { if (!picked.length) {
setStatus('', false); setStatus('', false);
return; return;
} }
var total = totalBytes(); var total = totalBytes();
var over = POST_LIMIT > 0 && total > POST_LIMIT; var over = POST_LIMIT > 0 && total > POST_LIMIT;
var croppedCount = picked.filter(function (it) { return it.cropped; }).length;
setStatus( setStatus(
picked.length + ' 个待上传 · 合计约 ' + fmtSize(total) picked.length + ' 个待上传 · 合计约 ' + fmtSize(total)
+ (croppedCount > 0 ? '(' + croppedCount + ' 张已裁剪)' : '')
+ (POST_LIMIT > 0 ? '(服务器单次上限 ' + fmtSize(POST_LIMIT) + ')' : '') + (POST_LIMIT > 0 ? '(服务器单次上限 ' + fmtSize(POST_LIMIT) + ')' : '')
+ (over ? ' —— 超了。请分几次保存,或先保存节点、再进「📷 照片」页上传。' : ''), + (over ? ' —— 超了。请分几次保存,或先保存节点、再进「📷 照片」页上传。' : ''),
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) { function addFiles(list) {
var arr = Array.prototype.slice.call(list || []); var arr = Array.prototype.slice.call(list || []);
if (!arr.length) return; if (!arr.length) return;
var errors = []; var errors = [];
var addedCroppable = false;
arr.forEach(function (f) { arr.forEach(function (f) {
if (picked.length >= MAX_FILES) { if (picked.length >= MAX_FILES) {
errors.push(f.name + ':一次最多 ' + MAX_FILES + ' 个'); errors.push(f.name + ':一次最多 ' + MAX_FILES + ' 个');
@@ -654,24 +760,42 @@ $tripField = [
errors.push(f.name + ':超过单张 ' + (MAX_ONE / 1024 / 1024) + 'MB'); errors.push(f.name + ':超过单张 ' + (MAX_ONE / 1024 / 1024) + 'MB');
return; return;
} }
var image = isImageFile(f);
var url = image ? URL.createObjectURL(f) : '';
var croppable = image && canvasSafe(f);
if (croppable) addedCroppable = true;
picked.push({ picked.push({
uid: ++seq,
file: f, file: f,
key: key, 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(); 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) { function removeAt(idx) {
var it = picked[idx]; var it = picked[idx];
if (!it) return; 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); picked.splice(idx, 1);
syncInput();
render(); render();
} }
@@ -711,6 +835,153 @@ $tripField = [
window.addEventListener('dragover', function (ev) { ev.preventDefault(); }); window.addEventListener('dragover', function (ev) { ev.preventDefault(); });
window.addEventListener('drop', 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) { form.addEventListener('submit', function (ev) {
var total = totalBytes(); var total = totalBytes();
if (POST_LIMIT > 0 && total > POST_LIMIT) { if (POST_LIMIT > 0 && total > POST_LIMIT) {
@@ -718,8 +989,85 @@ $tripField = [
setStatus('这次要传约 ' + fmtSize(total) + ',超过服务器单次请求上限 ' setStatus('这次要传约 ' + fmtSize(total) + ',超过服务器单次请求上限 '
+ fmtSize(POST_LIMIT) + '。请分几次保存,或先保存节点、再进「📷 照片」页上传。', true); + fmtSize(POST_LIMIT) + '。请分几次保存,或先保存节点、再进「📷 照片」页上传。', true);
dropZone.scrollIntoView({ block: 'center' }); 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> </script>