Files
TripLog/templates/nodes/form.php
T
laoyang 86e0481a19 节点表单: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 文件哈希校验一致。
2026-09-23 09:11:42 +08:00

1074 lines
50 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);
/**
* 交通信息只留一个输入框:飞机只要航班号(航空公司是隐含的),火车只要车次。
* 座位号/登机口这类「一趟车多人同行」时根本没法记,所以不再收集。
*/
$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>
</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>
</fieldset>
<div class="form-row">
<label>详细描述</label>
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($oldStr('description', $n['description'] ?? '')) ?></textarea>
</div>
<?php
/* 照片附件:随节点表单一起提交。
新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」;
编辑时把已有照片带出来,可勾选删除。
新选的图片可以先裁剪:不想裁就直接点「确定」,会按原图上传。 */
$existingAtts = $attachments ?? [];
$checkedDel = old_array('delete_attachments'); // 保存失败时恢复勾选状态
?>
<fieldset class="fieldset" id="photo-field">
<legend>照片附件(可多张)</legend>
<?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/*,application/pdf,.heic,.heif" 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">
可一次选多张;登机牌、车票、门票随手拍下来即可。
单张上限 <?= (int)($maxPhotoMb ?? 12) ?>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 || '';
if (addrField) addrField.value = item.address || item.district || '';
if (item.location) {
if (latField) latField.value = item.location.lat;
if (lngField) lngField.value = item.location.lng;
}
dropdown.style.display = 'none';
// 坐标刚变,导航链接要跟着变(有坐标才是真导航,没坐标只是搜索)
refreshLocNav(prefix);
}
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 POST_LIMIT = <?= (int)ini_bytes((string)ini_get('post_max_size')) ?>;
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
var picked = []; // {uid, file, key, name, origName, type, origUrl, thumb, image, croppable, cropped, reviewed}
var seq = 0;
// ---- 裁剪弹窗(id 与 templates/_crop_modal.php 一一对应)----
var modal = document.getElementById('crop-modal');
var cropImg = document.getElementById('crop-image');
var cropTitle = document.getElementById('crop-title');
var cropper = null;
var cropUid = null;
var cropStopped = false; // 用户点了「取消」就结束本次逐张裁剪
function extOf(name) {
var i = String(name || '').lastIndexOf('.');
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;
}
// 浏览器画布解不开的格式(典型是 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">' + esc((extOf(it.name).toUpperCase()) || 'FILE') + '</div>')
+ (canSync
? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>'
: '')
+ (it.cropped ? '<span class="photo-badge">已裁剪</span>' : '')
+ '<div class="photo-cap">'
+ '<span class="photo-cap-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
+ '<span class="photo-cap-sub">' + esc(fmtSize(bytesOf(it))) + '</span>'
+ '</div>';
preview.appendChild(card);
});
syncInput();
syncCropJson();
if (toolbar) {
var any = picked.some(function (it) { return it.croppable; });
toolbar.style.display = any ? '' : 'none';
}
if (!picked.length) {
setStatus('', false);
return;
}
var total = totalBytes();
var over = POST_LIMIT > 0 && total > POST_LIMIT;
var croppedCount = picked.filter(function (it) { return it.cropped; }).length;
setStatus(
picked.length + ' 个待上传 · 合计约 ' + fmtSize(total)
+ (croppedCount > 0 ? '(' + croppedCount + ' 张已裁剪)' : '')
+ (POST_LIMIT > 0 ? '(服务器单次上限 ' + fmtSize(POST_LIMIT) + ')' : '')
+ (over ? ' —— 超了。请分几次保存,或先保存节点、再进「📷 照片」页上传。' : ''),
over
);
}
/** 把裁剪结果写进隐藏字段(原生表单提交也能带上 base64) */
function syncCropJson() {
if (!cropJson) return;
var arr = [];
picked.forEach(function (it) {
if (it.cropped) arr.push({ name: it.name, data: it.cropped });
});
cropJson.value = arr.length ? JSON.stringify(arr) : '';
}
function addFiles(list) {
var arr = Array.prototype.slice.call(list || []);
if (!arr.length) return;
var errors = [];
var addedCroppable = false;
arr.forEach(function (f) {
if (picked.length >= MAX_FILES) {
errors.push(f.name + ':一次最多 ' + MAX_FILES + ' 个');
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;
}
var image = isImageFile(f);
var url = image ? URL.createObjectURL(f) : '';
var croppable = image && canvasSafe(f);
if (croppable) addedCroppable = true;
picked.push({
uid: ++seq,
file: f,
key: key,
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; }
// 新选进来的图片里有能裁的 → 自动开始逐张裁剪;不想裁就一路点「确定」或直接「取消」
if (addedCroppable && modal && typeof window.Cropper !== 'undefined') {
cropStopped = false;
advanceCrop();
}
}
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) {
var btn = ev.target.closest ? ev.target.closest('button[data-idx]') : null;
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);
return;
}
}
}
function openCrop(uid) {
var it = find(uid);
if (!it || !it.croppable) return;
if (typeof window.Cropper === 'undefined') {
it.reviewed = true;
setStatus('裁剪组件没加载出来,这张会按原图上传。', false);
return;
}
cropUid = uid;
var pending = picked.filter(function (x) { return x.croppable && !x.reviewed; }).length;
cropTitle.textContent = '裁剪:' + it.name + (pending > 1 ? '(还有 ' + (pending - 1) + ' 张待处理)' : '');
// 每次都换一个新的 img 节点,避免 Cropper 复用旧实例时状态残留
var fresh = document.createElement('img');
fresh.id = 'crop-image';
fresh.alt = '';
cropImg.parentNode.replaceChild(fresh, cropImg);
cropImg = fresh;
cropImg.onerror = function () {
var cur = find(cropUid);
if (cur) { cur.croppable = false; cur.reviewed = true; }
closeCrop();
render();
setStatus('浏览器解不开这张图(可能是 HEIC 等格式),会按原图上传。', false);
};
cropImg.onload = function () {
if (typeof window.Cropper === 'undefined') return;
cropper = new window.Cropper(cropImg, {
viewMode: 1,
dragMode: 'move',
autoCropArea: 1,
responsive: true,
background: false,
aspectRatio: NaN // 自由比例
});
// 挂到 window 上:无头浏览器交互测试要驱动 Cropper(拖拽在无头下不可靠),
// 线上也是现成的调试入口,不引入额外状态。
window.__nodeCropper = cropper;
};
cropImg.src = it.cropped ? it.cropped : (it.thumb || it.origUrl);
modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeCrop() {
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (cropper) { cropper.destroy(); cropper = null; }
cropUid = null;
}
if (modal) {
modal.addEventListener('click', function (ev) {
if (ev.target.getAttribute && ev.target.getAttribute('data-close') === '1') {
stopCropPass();
}
});
document.getElementById('crop-cancel').addEventListener('click', stopCropPass);
document.getElementById('crop-reset').addEventListener('click', function () {
if (cropper) cropper.reset();
});
document.getElementById('crop-apply').addEventListener('click', applyCrop);
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && modal.classList.contains('open')) stopCropPass();
});
}
/** 「取消」= 这张跳过,并且本次逐张裁剪到此为止(用户明确表示不想裁) */
function stopCropPass() {
var cur = cropUid !== null ? find(cropUid) : null;
if (cur) cur.reviewed = true;
cropStopped = true;
closeCrop();
render();
}
function applyCrop() {
if (!cropper || cropUid === null) { closeCrop(); return; }
var it = find(cropUid);
if (!it) { closeCrop(); return; }
// 选框还是整图 = 用户没真的裁。保留原文件,不重新编码。
var d = null, img = null;
try { d = cropper.getData(true); img = cropper.getImageData(); } catch (e) {}
var full = !!(d && img
&& d.x <= 0 && d.y <= 0
&& d.width >= Math.round(img.naturalWidth) - 1
&& d.height >= Math.round(img.naturalHeight) - 1);
if (full) {
it.cropped = null;
it.thumb = it.origUrl;
it.name = it.origName;
} else {
// 限制长边,避免 base64 把请求撑爆(登机牌 1800px 已经足够看清)
var canvas = cropper.getCroppedCanvas({
maxWidth: 1800,
maxHeight: 1800,
imageSmoothingQuality: 'high',
fillColor: '#ffffff'
});
if (!canvas) { alert('裁剪失败,请重试。'); return; }
var mime = /^image\/(png|webp)$/.test(it.type) ? it.type : 'image/jpeg';
var dataUrl;
try {
dataUrl = canvas.toDataURL(mime, 0.9);
} catch (e) {
alert('导出裁剪结果失败:' + e.message);
return;
}
it.cropped = dataUrl;
it.thumb = dataUrl;
it.name = it.origName.replace(/\.[^.]+$/, '') + (mime === 'image/png' ? '.png'
: mime === 'image/webp' ? '.webp' : '.jpg');
}
it.reviewed = true;
closeCrop();
render();
advanceCrop(); // 还有没处理过的图就继续弹下一张
}
if (cropAll) {
cropAll.addEventListener('click', function () {
cropStopped = false;
picked.forEach(function (it) { if (it.croppable) it.reviewed = false; });
advanceCrop();
});
}
// ── 提交前的总量拦截 ──────────────────────────────────────
form.addEventListener('submit', function (ev) {
var total = totalBytes();
if (POST_LIMIT > 0 && total > POST_LIMIT) {
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>