节点表单: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:
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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); ?>
|
||||||
@@ -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); ?>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
+383
-35
@@ -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,8 +573,13 @@ $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() 失败、报「请求已过期」,
|
||||||
@@ -541,6 +590,9 @@ $tripField = [
|
|||||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').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 —— 决定「移除」按钮是否有意义。
|
// 能不能改写 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user