Files
TripLog/templates/nodes/form.php
T
laoyang 142b187911 节点表单:参与人直接勾选 + 起点终点记录详细地址(批 13)
参与人(用户反馈:不想等全部填完、保存之后才回详情页加人):
- 节点表单里新增「参与人(直接勾选)」区块:家庭成员按胶囊复选框列出,
  保存即生效;编辑时预勾选已有人。
- Members::syncForNode() 只做增删,**不动留下来的那些人的情绪 / 签到 /
  角色标签**(重新勾一遍不该把之前记的心情清掉)。
- 安全性:新增走 Members::save(),逐条校验「用户属于该活动所在家庭」,
  夹带别家 user_id 进不来;只有请求带 members_present=1(表单确实渲染了
  参与人区块)才同步 —— 否则没这个字段的请求会把已有成员清空。

起点/终点记录详细地址(用户反馈:高德联想只返回简化地名):
- 联想项选中后不再是「青岛北站」就完事:
  ① 有坐标 → 逆地理编码(AMap.Geocoder.getAddress)取该点的
     formattedAddress;
  ② 没坐标 → 按「区县 + 名称」正查,顺带把坐标补回来。
- 详细地址写进 start/end_location 的 address 字段,输入框下方实时显示
  「详细地址:…」,节点详情页本来就展示这一项。
- 路线地图补点(只有地名没坐标时)改为**优先用详细地址**查询,
  比简化地名定位准。

已验证:本地全链 12 步全绿(渲染检查 350 项、真 AMap 浏览器验证 11 项、
表单交互 75 项);远端 e2e 350 项 0 失败(新增 10 项:勾选落库 / 跨家庭
拒绝 / 取消勾选移出 / 情绪不被清 / 无标记不动参与人);部署静态断言、
双 PHP 渲染、内容断言全过后提交。
2026-09-24 12:36:01 +08:00

1228 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$isEdit = $isEdit ?? false;
$n = $node; // null=新建;数组=编辑
/**
* 回填:上一次提交失败时,**用户刚填的值优先于库里的值**。
* flash_old() 把 $_POST 原样存进 session,layout.php 渲染完就 unset(用完即清),
* 所以这里不需要担心残留污染下一次新建。
*/
$oldFilled = !empty($_SESSION['_old']);
$oldStr = static function (string $k, $fb = ''): string {
return (string)old($k, (string)$fb);
};
$curStatus = $oldStr('status', (string)($n['status'] ?? ($category === 'plan' ? 'planned' : 'completed')));
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
'completed' => '已完成',
'cancelled' => '已取消',
];
// 计划节点不会有"进行中",执行节点默认就是已完成
$statusOptions = $category === 'plan'
? ['planned', 'completed', 'cancelled']
: ['completed', 'ongoing', 'planned', 'cancelled'];
// 起点 / 终点:库里的 JSON 先解出来,再让上一次提交的原始值覆盖它
$sLocRaw = json_decode_field($n['start_location'] ?? null);
$eLocRaw = json_decode_field($n['end_location'] ?? null);
$sLoc = [
'name' => $oldStr('start_location_name', $sLocRaw['name'] ?? ''),
'address' => $oldStr('start_location_address', $sLocRaw['address'] ?? ''),
'lat' => $oldStr('start_location_lat', $sLocRaw['lat'] ?? ''),
'lng' => $oldStr('start_location_lng', $sLocRaw['lng'] ?? ''),
];
$eLoc = [
'name' => $oldStr('end_location_name', $eLocRaw['name'] ?? ''),
'address' => $oldStr('end_location_address', $eLocRaw['address'] ?? ''),
'lat' => $oldStr('end_location_lat', $eLocRaw['lat'] ?? ''),
'lng' => $oldStr('end_location_lng', $eLocRaw['lng'] ?? ''),
];
// 时间:优先用上一次提交的三段式(_date/_hour/_minute),否则用库里存的
$startTimeVal = old_dt('start_time', $n['start_time'] ?? null);
$endTimeVal = old_dt('end_time', $n['end_time'] ?? null);
/* 参与人在表单里直接勾(不必先保存再回详情页加人)。
回填优先:上次提交失败时,用刚勾的而不是库里的。 */
$familyUsers = $familyUsers ?? [];
$nodeMemberIds = $nodeMemberIds ?? [];
$oldMembers = isset($_SESSION['_old']) && is_array($_SESSION['_old'])
? (array)($_SESSION['_old']['members'] ?? [])
: null;
$pickedMembers = static function () use ($oldMembers, $nodeMemberIds): array {
$src = $oldMembers ?? $nodeMemberIds;
$out = [];
foreach ((array)$src as $v) {
$v = (int)$v;
if ($v > 0) {
$out[] = $v;
}
}
return $out;
};
$picked = $pickedMembers();
/**
* 交通信息只留一个输入框:飞机只要航班号(航空公司是隐含的),火车只要车次。
* 座位号/登机口这类「一趟车多人同行」时根本没法记,所以不再收集。
*/
$tripField = [
'plane' => ['label' => '航班号', 'ph' => '例如:CA1234'],
'train' => ['label' => '车次', 'ph' => '例如:G1234'],
'bus' => ['label' => '班次', 'ph' => '例如:T108、8 路'],
'subway' => ['label' => '线路', 'ph' => '例如:地铁 10 号线'],
'ship' => ['label' => '航次', 'ph' => '例如:渤海轮渡 3 号'],
'taxi' => ['label' => '叫车备注', 'ph' => '例如:尾号 1234'],
'other' => ['label' => '车次 / 班次', 'ph' => '例如:G1234、CA1234'],
];
?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑' : '新建' ?><?= $category === 'plan' ? '计划' : '执行' ?>节点</h1>
<p class="muted">
活动:
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>"><?= e($activity['title']) ?></a>
<?php if ($isEdit && !empty($n['original_plan_node_id'])): ?>
· <span class="tag-default">由计划节点生成</span>
<?php endif; ?>
</p>
</div>
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">返回活动</a>
</div>
<form method="post" action="<?= url($isEdit ? 'nodes/update' : 'nodes/store') ?>"
enctype="multipart/form-data" class="form" id="node-form"
data-old="<?= $oldFilled ? '1' : '0' ?>"
data-draft-key="<?= e(($isEdit ? 'edit|' . (int)$n['id'] : 'create|' . (int)$activity['id'] . '|' . $category)) ?>">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="activity_id" value="<?= (int)$activity['id'] ?>">
<input type="hidden" name="node_category" value="<?= e($category) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<?php endif; ?>
<?php if (!$isEdit && $category === 'reality' && !empty($planNodes)): ?>
<div class="form-row">
<label>关联计划节点(可选)</label>
<select name="original_plan_node_id">
<option value="">— 不关联,此为临时新增 —</option>
<?php $selPlan = $oldStr('original_plan_node_id', ''); ?>
<?php foreach ($planNodes as $p): ?>
<option value="<?= (int)$p['id'] ?>"<?= $selPlan === (string)(int)$p['id'] ? ' selected' : '' ?>>
<?= e($p['title'] ?: '(无标题)') ?> · <?= date('m-d H:i', strtotime($p['start_time'])) ?>
</option>
<?php endforeach; ?>
</select>
<div class="muted">关联后可以在计划卡片上看到「已成行」。</div>
</div>
<?php endif; ?>
<div class="form-row">
<label>节点类型 *</label>
<select name="node_type_id" required>
<?php $selType = $oldStr('node_type_id', (string)($n['node_type_id'] ?? 0)); ?>
<?php foreach ($nodeTypes as $t): ?>
<option value="<?= (int)$t['id'] ?>"
<?= $selType === (string)(int)$t['id'] ? ' selected' : '' ?>>
<?= e($t['icon'] . ' ' . $t['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row two-col">
<div>
<label>开始时间 *</label>
<?php /* 24 小时制组合控件:原生 datetime-local 的「上午/下午」显示由浏览器
UI 语言决定,页面管不住(用户反馈过混淆),所以拆成日期 + 时/分下拉 */ ?>
<?php $dtName = 'start_time'; $dtValue = $startTimeVal; $dtRequired = true; $dtId = 'start-time-date'; ?>
<?php require __DIR__ . '/../_datetime.php'; ?>
</div>
<div>
<label>结束时间 *</label>
<?php $dtName = 'end_time'; $dtValue = $endTimeVal; $dtRequired = true; $dtId = 'end-time-date'; ?>
<?php require __DIR__ . '/../_datetime.php'; ?>
</div>
</div>
<div class="form-row">
<label>标题</label>
<input type="text" name="title" maxlength="200" placeholder="例如:出发去奥森"
value="<?= e($oldStr('title', $n['title'] ?? '')) ?>">
</div>
<fieldset class="fieldset">
<legend>出行方式</legend>
<div class="form-row">
<label for="travel-mode-select">选择方式</label>
<select name="travel_mode_id" id="travel-mode-select">
<option value="" data-mode-type="">— 不使用 —</option>
<?php $selMode = $oldStr('travel_mode_id', (string)($n['travel_mode_id'] ?? '')); ?>
<?php foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>"
<?= $selMode === (string)(int)$tm['id'] ? ' selected' : '' ?>>
<?= e(($modeLabels[$tm['mode_type']] ?? $tm['mode_type']) . (!empty($tm['plate_number']) ? ' · ' . $tm['plate_number'] : '')) ?>
</option>
<?php endforeach; ?>
</select>
<div class="muted">
没有合适的方式?在右上角菜单的「出行方式」里添加。
</div>
</div>
<?php /* 只有一个交通字段:飞机=航班号、火车=车次,标签随方式切换 */ ?>
<div class="form-row" id="field-trip-number" style="display:none;">
<label id="trip-number-label" for="trip-number-input">车次 / 航班号</label>
<input type="text" name="trip_number" id="trip-number-input" maxlength="50"
placeholder="例如:G1234、CA1234"
value="<?= e($oldStr('trip_number', $n['trip_number'] ?? '')) ?>">
<div class="muted" id="trip-number-hint"></div>
</div>
<div class="form-row" id="field-no-trip" style="display:none;">
<div class="muted" id="no-trip-hint"></div>
</div>
</fieldset>
<fieldset class="fieldset">
<legend>起点(可选)</legend>
<div class="form-row">
<label>起点名称(输入地址自动补全)</label>
<input type="text" name="start_location_name" id="start-location-input"
placeholder="输入关键词,如「北京南站」「奥森公园」"
autocomplete="off"
value="<?= e($sLoc['name'] ?? '') ?>">
<input type="hidden" name="start_location_address" id="start-location-address" value="<?= e($sLoc['address'] ?? '') ?>">
<input type="hidden" name="start_location_lat" id="start-location-lat" value="<?= e($sLoc['lat'] ?? '') ?>">
<input type="hidden" name="start_location_lng" id="start-location-lng" value="<?= e($sLoc['lng'] ?? '') ?>">
<?php /* 选中下拉项后由 JS 补上 href;服务端先按已存坐标渲染一次,编辑时不至于空白 */ ?>
<?php $sNav = amap_nav_url($sLoc); ?>
<div class="loc-nav" id="start-location-nav"<?= $sNav === '' ? ' style="display:none;"' : '' ?>>
<a class="loc-nav-link" id="start-location-nav-link" href="<?= e($sNav ?: '#') ?>"
target="_blank" rel="noopener">🧭 导航到这里</a>
<span class="muted">保存后节点详情页也会带这个链接</span>
</div>
<div class="loc-detail muted" id="start-location-detail"></div>
</div>
</fieldset>
<fieldset class="fieldset">
<legend>终点(可选)</legend>
<div class="form-row">
<label>终点名称(输入地址自动补全)</label>
<input type="text" name="end_location_name" id="end-location-input"
placeholder="输入关键词,如「国家大剧院」「三里屯」"
autocomplete="off"
value="<?= e($eLoc['name'] ?? '') ?>">
<input type="hidden" name="end_location_address" id="end-location-address" value="<?= e($eLoc['address'] ?? '') ?>">
<input type="hidden" name="end_location_lat" id="end-location-lat" value="<?= e($eLoc['lat'] ?? '') ?>">
<input type="hidden" name="end_location_lng" id="end-location-lng" value="<?= e($eLoc['lng'] ?? '') ?>">
<?php $eNav = amap_nav_url($eLoc); ?>
<div class="loc-nav" id="end-location-nav"<?= $eNav === '' ? ' style="display:none;"' : '' ?>>
<a class="loc-nav-link" id="end-location-nav-link" href="<?= e($eNav ?: '#') ?>"
target="_blank" rel="noopener">🧭 导航到这里</a>
<span class="muted">保存后节点详情页也会带这个链接</span>
</div>
<div class="loc-detail muted" id="end-location-detail"></div>
</div>
</fieldset>
<div class="form-row">
<label>详细描述</label>
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($oldStr('description', $n['description'] ?? '')) ?></textarea>
</div>
<?php /* 参与人:直接在表单里勾,不必先保存再回详情页加(批 13) */ ?>
<fieldset class="fieldset" id="member-field">
<legend>参与人(直接勾选)</legend>
<?php if ($familyUsers === []): ?>
<p class="muted">这个家庭还没有成员。</p>
<?php else: ?>
<input type="hidden" name="members_present" value="1">
<div class="member-pick">
<?php foreach ($familyUsers as $u): ?>
<?php $uid = (int)$u['id']; ?>
<label class="member-pick-item">
<input type="checkbox" name="members[]" value="<?= $uid ?>"
<?= in_array($uid, $picked, true) ? 'checked' : '' ?>>
<?php if (!empty($u['avatar'])): ?>
<img class="member-pick-avatar" src="<?= e((string)$u['avatar']) ?>" alt="">
<?php endif; ?>
<span class="member-pick-name"><?= e((string)$u['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<p class="form-hint">勾选后保存即生效;已选的人重新勾选不会清掉之前记的签到 / 心情。</p>
<?php endif; ?>
</fieldset>
<?php
/* 照片附件:随节点表单一起提交。
新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」;
编辑时把已有照片带出来,可勾选删除。
新选的图片可以先裁剪:不想裁就直接点「确定」,会按原图上传。 */
$existingAtts = $attachments ?? [];
$checkedDel = old_array('delete_attachments'); // 保存失败时恢复勾选状态
?>
<fieldset class="fieldset" id="photo-field">
<legend>附件(照片 / 视频 / 文档)</legend>
<?php if ($existingAtts !== []): ?>
<div class="form-row">
<label>已有附件(勾选后保存即删除)</label>
<div class="photo-grid photo-grid-sm" id="node-photo-existing">
<?php foreach ($existingAtts as $att): ?>
<?php
$attId = (int)$att['id'];
$attIsImg = \App\Attachments::isImageRow($att);
$attDesc = \App\Attachments::describe($att);
?>
<label class="photo-pick" title="<?= e($attDesc['name']) ?>">
<?php /* checkbox 排在最前:CSS 用的是 input:checked ~ img 这种兄弟选择器 */ ?>
<input type="checkbox" name="delete_attachments[]" value="<?= $attId ?>"
<?= in_array((string)$attId, $checkedDel, true) ? ' checked' : '' ?>>
<?php if ($attIsImg): ?>
<img src="<?= e(media_att($attId)) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="photo-pick-ext"><?= e(strtoupper(pathinfo((string)($att['file_name'] ?? ''), PATHINFO_EXTENSION) ?: 'FILE')) ?></span>
<?php endif; ?>
<span class="photo-pick-mark" aria-hidden="true">✓</span>
</label>
<?php endforeach; ?>
</div>
<div class="muted">勾上的会在保存时删掉(文件也会一起清理)。</div>
</div>
<?php endif; ?>
<div class="form-row">
<label>添加附件(照片 / 视频 / 文档)</label>
<?php /* 用 div + role=button 而不是 <label>:label 包着隐藏 input 时,
点击会在部分浏览器里触发两次文件对话框。这里统一由 JS 打开。 */ ?>
<div class="drop-zone" id="node-photo-drop" tabindex="0" role="button"
aria-label="选择或拖入附件">
<input type="file" id="node-photo-input" name="files[]" multiple
accept="image/*,.heic,.heif,.pdf,.mp4,.mov,.doc,.docx,.xls,.xlsx,.zip"
style="display:none;">
<div class="drop-zone-inner">
<div class="drop-zone-icon">📷</div>
<p class="drop-zone-title">点击选择,或把照片 / 视频 / 文件拖到这里</p>
<p class="muted drop-zone-hint">
照片、视频(MP4 / MOV)、文档(PDF / Word / Excel)、ZIP 都可以。
照片单张上限 <?= (int)($maxPhotoMb ?? 12) ?>MB,视频与文档单个上限 <?= (int)(\App\Uploads::MAX_FILE_BYTES / 1048576) ?>MB,
一次最多 <?= (int)\App\Uploads::MAX_FILES ?> 个。
</p>
</div>
</div>
<div class="form-hint" id="node-photo-status" role="status"></div>
<div class="photo-grid photo-grid-sm" id="node-photo-preview"></div>
<div class="crop-toolbar" id="node-crop-toolbar" style="display:none;">
<button type="button" class="btn btn-sm" id="node-photo-cropall">逐张裁剪</button>
<span class="muted">不想裁就逐张点「确定」,会按原图上传。</span>
</div>
<?php /* 裁剪结果走 base64(cropped[]),没裁的走原文件(files[])——与照片页同一套约定 */ ?>
<input type="hidden" name="cropped" id="node-cropped-json" value="">
</div>
</fieldset>
<?php if ($isEdit): ?>
<fieldset class="fieldset">
<legend>执行状态</legend>
<div class="form-row">
<label for="status-select">状态</label>
<select name="status" id="status-select">
<?php foreach ($statusOptions as $st): ?>
<option value="<?= e($st) ?>" <?= $curStatus === $st ? ' selected' : '' ?>>
<?= e($statusLabels[$st]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row">
<label>取消原因(状态选「已取消」时填写)</label>
<input type="text" name="cancel_reason" maxlength="500" placeholder="例如:下雨,临时改行程"
value="<?= e($oldStr('cancel_reason', $n['cancel_reason'] ?? '')) ?>">
</div>
</fieldset>
<?php endif; ?>
<div class="form-actions">
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<?php /* ── 裁剪弹窗:与「照片与附件」页共用同一份模板 ────────────────────── */ ?>
<?php require __DIR__ . '/../_crop_modal.php'; ?>
<script>
(function () {
// ===== 交通字段:只一个输入框,标签随出行方式变 =====
var TRIP = <?= json_encode($tripField, JSON_UNESCAPED_UNICODE) ?>;
// 这些方式不需要车次/航班号(自驾、步行、骑行)
var NO_TRIP = ['drive_own', 'drive_rental', 'walk', 'bike'];
var modeSelect = document.getElementById('travel-mode-select');
var tripRow = document.getElementById('field-trip-number');
var tripLabel = document.getElementById('trip-number-label');
var tripInput = document.getElementById('trip-number-input');
var tripHint = document.getElementById('trip-number-hint');
var noTripRow = document.getElementById('field-no-trip');
var noTripHint = document.getElementById('no-trip-hint');
function refreshTransport() {
if (!modeSelect || !tripRow) return;
var opt = modeSelect.options[modeSelect.selectedIndex];
var mode = opt ? (opt.getAttribute('data-mode-type') || '') : '';
if (mode === '') {
tripRow.style.display = 'none';
noTripRow.style.display = 'none';
return;
}
if (NO_TRIP.indexOf(mode) !== -1) {
tripRow.style.display = 'none';
noTripRow.style.display = '';
noTripHint.textContent = (mode === 'walk' || mode === 'bike')
? '步行 / 骑行不用填车次。'
: '自驾不用填车次,车牌已在出行方式里。';
return;
}
var cfg = TRIP[mode] || TRIP['other'];
noTripRow.style.display = 'none';
tripRow.style.display = '';
tripLabel.textContent = cfg.label;
tripInput.placeholder = cfg.ph;
tripHint.textContent = '只填这一个就够了' +
(mode === 'plane' ? '——航班号本身就包含了航空公司。'
: mode === 'train' ? '——车次就够,座位号对多人同行没意义。'
: '。');
}
if (modeSelect) {
modeSelect.addEventListener('change', refreshTransport);
refreshTransport();
}
// ===== 起点 / 终点的「🧭 导航到这里」 =====
// 规则必须与服务端 amap_nav_url() 保持一致(改一边就得改另一边):
// 有经纬度 → navigation;只填了名字 → search。
// ⚠️ navigation 的坐标顺序是「经度,纬度」(lng 在前),写反了会导到地球另一边。
var NAV_SRC = 'trip.yangyq.net';
function navUrl(name, address, lng, lat) {
name = String(name || '').trim();
address = String(address || '').trim();
lng = String(lng || '').trim();
lat = String(lat || '').trim();
if (lng !== '' && lat !== '' && isFinite(Number(lng)) && isFinite(Number(lat))) {
// 只编码名称,逗号留字面量 —— 高德要的是 lng,lat,name 三段式
return 'https://uri.amap.com/navigation?to=' + lng + ',' + lat
+ (name !== '' ? ',' + encodeURIComponent(name) : '')
+ '&mode=car&policy=1&src=' + NAV_SRC + '&coordinate=gaode&callnative=1';
}
var keyword = name !== '' ? name : address;
if (keyword === '') return '';
return 'https://uri.amap.com/search?keyword=' + encodeURIComponent(keyword) + '&src=' + NAV_SRC;
}
function refreshLocNav(prefix) {
var box = document.getElementById(prefix + '-location-nav');
var link = document.getElementById(prefix + '-location-nav-link');
if (!box || !link) return;
var nameEl = document.getElementById(prefix + '-location-input');
var addrEl = document.getElementById(prefix + '-location-address');
var latEl = document.getElementById(prefix + '-location-lat');
var lngEl = document.getElementById(prefix + '-location-lng');
var lng = lngEl ? lngEl.value : '';
var lat = latEl ? latEl.value : '';
var href = navUrl(nameEl ? nameEl.value : '', addrEl ? addrEl.value : '', lng, lat);
if (href === '') {
box.style.display = 'none';
link.setAttribute('href', '#');
return;
}
link.setAttribute('href', href);
box.style.display = '';
var hint = box.querySelector('.muted');
if (hint) {
hint.textContent = (lng !== '' && lat !== '')
? '点开就是到这里的驾车导航(手机端会尝试唤起高德 App)'
: '还没选中具体坐标,点开是高德搜索这个地名';
}
}
refreshLocNav('start');
refreshLocNav('end');
// 手动敲字(没从下拉里选)也要能刷出链接,所以这里给两侧输入框各挂一个 blur。
// 故意挂在 AMap 的加载回调之外:高德的脚本要是没加载出来(Key 失效 / 域名没绑),
// 用户仍然可以自己敲地名,这时的「搜索这个地名」链接不该跟着一起消失。
['start', 'end'].forEach(function (prefix) {
var el = document.getElementById(prefix + '-location-input');
if (!el) return;
el.addEventListener('blur', function () { refreshLocNav(prefix); });
el.addEventListener('input', function () {
// 手改名字后原先选中的坐标就作废了,否则会拿旧坐标去导航新地名
var latEl = document.getElementById(prefix + '-location-lat');
var lngEl = document.getElementById(prefix + '-location-lng');
if (latEl) latEl.value = '';
if (lngEl) lngEl.value = '';
refreshLocNav(prefix);
});
});
// ===== 高德地址自动补全(自定义下拉) =====
var maxWait = 5000; // 最多等 5 秒
var waited = 0;
function waitForAmap(callback) {
if (window.__AMAP_LOADED && typeof AMap !== 'undefined' && AMap.AutoComplete) {
callback();
return;
}
if (window.__AMAP_ERROR) {
console.error('[AMap] 脚本加载失败:', window.__AMAP_ERROR);
return;
}
if (waited >= maxWait) {
console.error('[AMap] 等待超时。可能原因:Key 无效 / 未绑定域名 / 无法访问 webapi.amap.com');
return;
}
waited += 200;
setTimeout(function () { waitForAmap(callback); }, 200);
}
waitForAmap(function () {
setupAutoComplete('start-location-input', 'start');
setupAutoComplete('end-location-input', 'end');
});
function setupAutoComplete(inputId, prefix) {
var input = document.getElementById(inputId);
if (!input) return;
var addrField = document.getElementById(prefix + '-location-address');
var latField = document.getElementById(prefix + '-location-lat');
var lngField = document.getElementById(prefix + '-location-lng');
var dropdown = document.createElement('div');
dropdown.className = 'amap-dropdown';
dropdown.style.display = 'none';
var wrapper = input.closest('.form-row') || input.parentElement;
wrapper.style.position = 'relative';
wrapper.appendChild(dropdown);
var autoComplete = new AMap.AutoComplete({});
var currentIndex = -1;
var currentResults = [];
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function renderDropdown(results) {
currentResults = results || [];
currentIndex = -1;
if (!currentResults.length) {
dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = currentResults.map(function (item, i) {
var sub = [item.district || '', item.address || ''].filter(Boolean).join(' ');
return '<div class="amap-dropdown-item" data-index="' + i + '">'
+ '<div class="amap-dropdown-name">' + escapeHtml(item.name || '') + '</div>'
+ (sub ? '<div class="amap-dropdown-sub">' + escapeHtml(sub) + '</div>' : '')
+ '</div>';
}).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.amap-dropdown-item').forEach(function (el) {
el.addEventListener('mousedown', function (ev) {
ev.preventDefault(); // 防止 input 失焦
selectItem(currentResults[parseInt(el.getAttribute('data-index'), 10)]);
});
});
}
function selectItem(item) {
if (!item) return;
input.value = item.name || '';
/* 高德联想返回的是「简化地名」(如「青岛北站」),详细地址要另外查:
① 有坐标 → 逆地理编码拿这个点的 formattedAddress(最贴合选中的位置);
② 没坐标 → 按「区县 + 名称」正查,顺带把坐标也补回来。
查不到就退回联想项自带的 district/address,总之不把它留空。 */
var fallback = item.address || item.district || '';
if (addrField) addrField.value = fallback;
if (item.location) {
if (latField) latField.value = item.location.lat;
if (lngField) lngField.value = item.location.lng;
reverseDetail(item.location, fallback);
} else {
var query = [item.district || '', item.name || ''].filter(Boolean).join(' ');
if (query) {
forwardDetail(query, fallback);
} else {
showDetail(fallback);
}
}
dropdown.style.display = 'none';
// 坐标刚变,导航链接要跟着变(有坐标才是真导航,没坐标只是搜索)
refreshLocNav(prefix);
}
/* 地理编码插件按需加载(layout.php 只预装了 AutoComplete) */
function withGeocoder(cb) {
try {
if (typeof AMap.Geocoder !== 'undefined') {
cb(new AMap.Geocoder({}));
return;
}
AMap.plugin('AMap.Geocoder', function () {
cb(typeof AMap.Geocoder !== 'undefined' ? new AMap.Geocoder({}) : null);
});
} catch (e) {
cb(null);
}
}
function showDetail(addr) {
var box = document.getElementById(prefix + '-location-detail');
if (!box) return;
box.textContent = addr ? '详细地址:' + addr : '';
}
/** 有坐标:反查这个点的完整地址 */
function reverseDetail(loc, fallback) {
withGeocoder(function (g) {
if (!g) { showDetail(fallback); return; }
try {
g.getAddress([Number(loc.lng), Number(loc.lat)], function (status, result) {
var addr = (status === 'complete' && result && result.regeocode)
? (result.regeocode.formattedAddress || '') : '';
if (addr && addrField) addrField.value = addr;
showDetail(addr || fallback);
});
} catch (e) {
showDetail(fallback);
}
});
}
/** 没坐标:按「区县 + 名称」正查,拿到详细地址与坐标 */
function forwardDetail(query, fallback) {
withGeocoder(function (g) {
if (!g) { showDetail(fallback); return; }
try {
g.getLocation(query, function (status, result) {
var addr = '';
if (status === 'complete' && result && result.geocodes && result.geocodes.length) {
var gc = result.geocodes[0];
addr = gc.formattedAddress || '';
if (gc.location) {
if (latField) latField.value = Number(gc.location.getLat());
if (lngField) lngField.value = Number(gc.location.getLng());
refreshLocNav(prefix);
}
}
if (addr && addrField) addrField.value = addr;
showDetail(addr || fallback);
});
} catch (e) {
showDetail(fallback);
}
});
}
// 编辑已有节点:把库里存下来的详细地址显示出来,让人知道记的是什么
showDetail(addrField ? addrField.value : '');
var searchTimer = null;
input.addEventListener('input', function () {
var keyword = input.value.trim();
clearTimeout(searchTimer);
if (keyword.length < 2) {
dropdown.style.display = 'none';
return;
}
searchTimer = setTimeout(function () {
autoComplete.search(keyword, function (status, result) {
if (status === 'complete' && result && result.tips) {
renderDropdown(result.tips);
} else {
dropdown.style.display = 'none';
}
});
}, 300);
});
input.addEventListener('blur', function () {
setTimeout(function () { dropdown.style.display = 'none'; }, 200);
});
input.addEventListener('focus', function () {
if (currentResults.length) dropdown.style.display = 'block';
});
input.addEventListener('keydown', function (ev) {
if (dropdown.style.display === 'none') return;
var items = dropdown.querySelectorAll('.amap-dropdown-item');
if (!items.length) return;
function highlight() {
items.forEach(function (el, i) { el.classList.toggle('active', i === currentIndex); });
}
if (ev.key === 'ArrowDown') {
ev.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
highlight();
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
currentIndex = (currentIndex - 1 + items.length) % items.length;
highlight();
} else if (ev.key === 'Enter') {
if (currentIndex >= 0 && currentResults[currentIndex]) {
ev.preventDefault();
selectItem(currentResults[currentIndex]);
}
} else if (ev.key === 'Escape') {
dropdown.style.display = 'none';
}
});
}
// ===== 节点表单里的照片附件(含裁剪)=====
//
// 提交形态与「照片与附件」页保持同一套约定:
// 裁过的图 → 转成 base64 放进 #node-cropped-json(cropped[]),走服务端 handleCropped;
// 没裁的 → 还留在 input.files 里(files[]),省掉一次无谓的编码。
// 「确定」时如果选框还是整图(用户没动过),就当作没裁,**保留原文件**——
// 不重新编码既不损画质,也不平白多出 37% 的 base64 流量。
//
// 必须在这一层挡住「总量超 post_max_size」:PHP 遇到超限请求会把 $_POST 与
// $_FILES **整个丢掉**,控制器只能走到 csrf_check() 失败、报「请求已过期」,
// 用户完全看不懂发生了什么。提前拦住才能给一句能行动的话。
(function () {
var form = document.getElementById('node-form');
var dropZone = document.getElementById('node-photo-drop');
var input = document.getElementById('node-photo-input');
var preview = document.getElementById('node-photo-preview');
var status = document.getElementById('node-photo-status');
var cropJson = document.getElementById('node-cropped-json');
var toolbar = document.getElementById('node-crop-toolbar');
var cropAll = document.getElementById('node-photo-cropall');
if (!form || !dropZone || !input || !preview) return;
var MAX_FILES = <?= (int)\App\Uploads::MAX_FILES ?>;
var MAX_ONE = <?= (int)($maxPhotoMb ?? 12) ?> * 1024 * 1024;
var FILE_MAX = <?= (int)\App\Uploads::MAX_FILE_BYTES ?>;
var POST_LIMIT = <?= (int)ini_bytes((string)ini_get('post_max_size')) ?>;
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
var VIDEO_EXT = ['mp4', 'mov', 'm4v'];
var PDF_EXT = ['pdf'];
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; // 用户点了「取消」就结束本次逐张裁剪
var cropChain = false; // true = 「逐张裁剪」的连裁流程(确定后自动下一张)
function extOf(name) {
var i = String(name || '').lastIndexOf('.');
return i < 0 ? '' : String(name).slice(i + 1).toLowerCase();
}
function isImageFile(f) {
if (f.type && f.type.indexOf('image/') === 0) return true;
return IMG_EXT.indexOf(extOf(f.name)) >= 0;
}
function iconFor(f) {
var ext = extOf(f.name);
if (VIDEO_EXT.indexOf(ext) >= 0) return '🎬';
if (PDF_EXT.indexOf(ext) >= 0) return '📄';
return '📎';
}
// 浏览器画布解不开的格式(典型是 HEIC),裁不了,只能原图直传
function canvasSafe(f) {
var t = String(f.type || '');
if (t === 'image/heic' || t === 'image/heif') return false;
if (!t && ['heic', 'heif'].indexOf(extOf(f.name)) >= 0) return false;
return true;
}
function fmtSize(n) {
n = Number(n) || 0;
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(2) + ' MB';
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function dataUrlBytes(dataUrl) {
var i = String(dataUrl).indexOf(',');
if (i < 0) return 0;
var b64 = String(dataUrl).slice(i + 1);
var pad = b64.slice(-2) === '==' ? 2 : (b64.slice(-1) === '=' ? 1 : 0);
return Math.floor(b64.length * 3 / 4) - pad;
}
function bytesOf(it) { return it.cropped ? dataUrlBytes(it.cropped) : it.file.size; }
function find(uid) {
for (var i = 0; i < picked.length; i++) if (picked[i].uid === uid) return picked[i];
return null;
}
// 能不能改写 input.files —— 决定「移除」按钮是否有意义。
// 只判断 DataTransfer 存在是不够的:必须真塞一个文件、再读回来验证赋值生效。
// 否则界面上显示「已移除」、请求里却还带着,用户永远查不出为什么多传了一张。
var canSync = (function () {
try {
var probe = new DataTransfer();
probe.items.add(new File([], 'probe.txt'));
input.files = probe.files;
var ok = !!(input.files && input.files.length === 1);
input.files = new DataTransfer().files;
return ok;
} catch (e) {
return false;
}
})();
/** 只有「没裁过的」走原文件通道;裁过的已经转成 base64,再带上原文件就重复了 */
function syncInput() {
if (!canSync) return;
var dt = new DataTransfer();
picked.forEach(function (it) {
if (!it.cropped) dt.items.add(it.file);
});
input.files = dt.files;
}
function totalBytes() {
return picked.reduce(function (sum, it) { return sum + bytesOf(it); }, 0);
}
function setStatus(text, isError) {
if (!status) return;
status.textContent = text || '';
status.className = 'form-hint' + (text && isError ? ' form-hint-error' : '');
}
function render() {
preview.innerHTML = '';
picked.forEach(function (it, idx) {
var card = document.createElement('div');
card.className = 'photo-card';
card.innerHTML =
(it.thumb
? '<img src="' + esc(it.thumb) + '" alt="">'
: '<div class="attachment-icon">' + iconFor(it) + '</div>')
+ (canSync
? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>'
: '')
+ (it.cropped ? '<span class="photo-badge">已裁剪</span>' : '')
+ (it.croppable && modal
? '<button type="button" class="photo-crop-btn" data-crop="' + idx
+ '" title="裁剪这张图">✂ 裁剪</button>'
: '')
+ '<div class="photo-cap">'
+ '<span class="photo-cap-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
+ '<span class="photo-cap-sub">' + esc(fmtSize(bytesOf(it))) + '</span>'
+ '</div>';
preview.appendChild(card);
});
syncInput();
syncCropJson();
if (toolbar) {
var any = picked.some(function (it) { return it.croppable; });
toolbar.style.display = any ? '' : 'none';
}
if (!picked.length) {
setStatus('', false);
return;
}
var total = totalBytes();
var over = POST_LIMIT > 0 && total > POST_LIMIT;
var croppedCount = picked.filter(function (it) { return it.cropped; }).length;
setStatus(
picked.length + ' 个待上传 · 合计约 ' + fmtSize(total)
+ (croppedCount > 0 ? '(' + croppedCount + ' 张已裁剪)' : '')
+ (POST_LIMIT > 0 ? '(服务器单次上限 ' + fmtSize(POST_LIMIT) + ')' : '')
+ (over ? ' —— 超了。请分几次保存,或先保存节点、再进「📷 照片」页上传。' : ''),
over
);
}
/** 把裁剪结果写进隐藏字段(原生表单提交也能带上 base64) */
function syncCropJson() {
if (!cropJson) return;
var arr = [];
picked.forEach(function (it) {
if (it.cropped) arr.push({ name: it.name, data: it.cropped });
});
cropJson.value = arr.length ? JSON.stringify(arr) : '';
}
function addFiles(list) {
var arr = Array.prototype.slice.call(list || []);
if (!arr.length) return;
var errors = [];
arr.forEach(function (f) {
if (picked.length >= MAX_FILES) {
errors.push(f.name + ':一次最多 ' + MAX_FILES + ' 个');
return;
}
var key = f.name + '|' + f.size + '|' + (f.lastModified || 0);
if (canSync && picked.some(function (it) { return it.key === key; })) {
return; // 同一个文件选两次,静默忽略
}
if (isImageFile(f) && MAX_ONE > 0 && f.size > MAX_ONE) {
errors.push(f.name + ':超过单张 ' + (MAX_ONE / 1024 / 1024) + 'MB');
return;
}
/* 大小分级:照片走 photo_max_mb;视频 / 文档走更宽的 FILE_MAX(后端同款逻辑) */
if (!isImageFile(f) && f.size > FILE_MAX) {
errors.push(f.name + ':文件超过 ' + (FILE_MAX / 1024 / 1024) + 'MB');
return;
}
var image = isImageFile(f);
var url = image ? URL.createObjectURL(f) : '';
var croppable = image && canvasSafe(f);
picked.push({
uid: ++seq,
file: f,
key: key,
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
});
});
render();
if (errors.length) { setStatus(errors.join(';'), true); return; }
/* 批 11:选图后**不再自动弹出**裁剪框 —— 每张缩略图旁有「✂ 裁剪」按钮,
想裁哪张点哪张;想批量处理就用上面的「逐张裁剪」。 */
}
function removeAt(idx) {
var it = picked[idx];
if (!it) return;
if (it.origUrl) { try { URL.revokeObjectURL(it.origUrl); } catch (e) {} }
if (cropUid === it.uid) closeCrop();
picked.splice(idx, 1);
render();
}
preview.addEventListener('click', function (ev) {
if (!ev.target.closest) return;
/* 「✂ 裁剪」:只打开这一张的裁剪框,确定/取消都不自动跳下一张 */
var cropBtn = ev.target.closest('button[data-crop]');
if (cropBtn) {
var ci = parseInt(cropBtn.getAttribute('data-crop'), 10);
if (picked[ci]) openCrop(picked[ci].uid, false);
return;
}
var btn = ev.target.closest('button[data-idx]');
if (!btn) return;
removeAt(parseInt(btn.getAttribute('data-idx'), 10));
});
// 用 div + role=button + 由 JS 打开文件框:label 包隐藏 input 时,
// 部分浏览器一次点击会弹两次文件对话框
dropZone.addEventListener('click', function () { input.click(); });
dropZone.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); input.click(); }
});
// 不清 input.value:能改写 files 时下一次选择本来就会替换掉旧的
input.addEventListener('change', function () { addFiles(input.files); });
['dragenter', 'dragover'].forEach(function (t) {
dropZone.addEventListener(t, function (ev) {
ev.preventDefault(); ev.stopPropagation();
dropZone.classList.add('dragging');
});
});
['dragleave', 'dragend'].forEach(function (t) {
dropZone.addEventListener(t, function (ev) {
ev.preventDefault(); ev.stopPropagation();
dropZone.classList.remove('dragging');
});
});
dropZone.addEventListener('drop', function (ev) {
ev.preventDefault(); ev.stopPropagation();
dropZone.classList.remove('dragging');
if (ev.dataTransfer && ev.dataTransfer.files) addFiles(ev.dataTransfer.files);
});
// 别让文件掉在页面别处被浏览器直接打开
window.addEventListener('dragover', function (ev) { ev.preventDefault(); });
window.addEventListener('drop', function (ev) { ev.preventDefault(); });
// ── 裁剪 ──────────────────────────────────────────────────
function advanceCrop() {
if (cropStopped) return;
for (var i = 0; i < picked.length; i++) {
if (picked[i].croppable && !picked[i].reviewed) {
openCrop(picked[i].uid, true);
return;
}
}
}
function openCrop(uid, chained) {
var it = find(uid);
if (!it || !it.croppable) return;
if (typeof window.Cropper === 'undefined') {
it.reviewed = true;
setStatus('裁剪组件没加载出来,这张会按原图上传。', false);
return;
}
cropUid = uid;
cropChain = !!chained;
var pending = picked.filter(function (x) { return x.croppable && !x.reviewed; }).length;
cropTitle.textContent = '裁剪:' + it.name
+ (chained && 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();
if (cropChain) advanceCrop(); // 只有「逐张裁剪」流程才自动弹下一张
}
if (cropAll) {
cropAll.addEventListener('click', function () {
cropStopped = false;
picked.forEach(function (it) { if (it.croppable) it.reviewed = false; });
advanceCrop();
});
}
// ── 提交前的总量拦截 ──────────────────────────────────────
form.addEventListener('submit', function (ev) {
var total = totalBytes();
if (POST_LIMIT > 0 && total > POST_LIMIT) {
ev.preventDefault();
setStatus('这次要传约 ' + fmtSize(total) + ',超过服务器单次请求上限 '
+ fmtSize(POST_LIMIT) + '。请分几次保存,或先保存节点、再进「📷 照片」页上传。', true);
dropZone.scrollIntoView({ block: 'center' });
return;
}
saveDraft(); // 提交瞬间把最新值再存一份,失败回填就靠它
});
// ── 填写草稿(sessionStorage)────────────────────────────
// 为什么需要:校验失败这类错误,服务端能把 $_POST 原样回填(old());
// 但「请求体超限」「会话过期」这类情况 PHP 连 $_POST 都拿不到,服务端无从回填。
// 只能靠浏览器自己留一份。
//
// 什么时候恢复:**页面上有错误提示条**时才恢复。这是刻意收窄的:
// · 保存成功 → 跳到活动详情页(没有表单)→ 草稿留着;
// · 下次再开一个全新节点表单 → 页面没有错误条 → 草稿被清掉、也不会回填,
// 所以不会出现「新建一个节点,表单里却有上上次的内容」。
var DRAFT_KEY = 'trip-node-draft|' + String(form.getAttribute('data-draft-key') || '');
var serverFilled = form.getAttribute('data-old') === '1';
var saveTimer = null;
function draftable(el) {
if (!el.name || el.disabled) return false;
if (el.name === '_csrf' || el.name === 'cropped') return false;
if (el.type === 'file' || el.type === 'submit' || el.type === 'button') return false;
return true;
}
function saveDraft() {
var data = {};
Array.prototype.forEach.call(form.elements, function (el) {
if (!draftable(el)) return;
if (el.type === 'checkbox') {
if (!data[el.name]) data[el.name] = [];
if (el.checked) data[el.name].push(el.value);
return;
}
data[el.name] = el.value;
});
try { sessionStorage.setItem(DRAFT_KEY, JSON.stringify(data)); } catch (e) { /* 存不下就算了 */ }
}
function restoreDraft(data) {
Array.prototype.forEach.call(form.elements, function (el) {
if (!draftable(el) || !(el.name in data)) return;
var v = data[el.name];
if (el.type === 'checkbox') {
el.checked = Array.isArray(v) && v.indexOf(el.value) >= 0;
return;
}
el.value = String(v == null ? '' : v);
});
// 时/分下拉恢复后要重刷「出行方式」那块的标签显隐
if (typeof refreshTransport === 'function') refreshTransport();
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 400);
}
form.addEventListener('input', scheduleSave);
form.addEventListener('change', scheduleSave);
(function initDraft() {
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(DRAFT_KEY) || 'null'); } catch (e) {}
if (!saved) return;
var hasAlert = !!document.querySelector('main .alert');
if (!hasAlert) {
// 不是从「保存失败」回来的 —— 这是全新一次填写,旧草稿不该再出现
try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
return;
}
if (serverFilled) {
// 服务端已经把用户刚填的值原样填回来了,草稿是冗余的
try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
return;
}
restoreDraft(saved);
setStatus('已恢复上次没保存成功的内容(照片需要重新选择)。', false);
})();
})();
})();
</script>