1307 lines
61 KiB
PHP
1307 lines
61 KiB
PHP
<?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; ?>
|
||
|
||
<?php /* 快捷新建成员(批 29):只填名字建一个 guest 成员并自动勾上,不离开表单。
|
||
想补手机号/头像/角色,去「成员管理」完善。 */ ?>
|
||
<?php if (\App\Auth::isAdmin()): ?>
|
||
<div class="member-quickadd" id="member-quickadd"
|
||
data-url="<?= e(url('users/quickCreate')) ?>" data-csrf="<?= e(csrf_token()) ?>">
|
||
<input type="text" id="new-member-name" maxlength="50"
|
||
placeholder="没找到?输入名字直接新建成员" autocomplete="off">
|
||
<button type="button" class="btn" id="new-member-add">+ 新建并勾选</button>
|
||
<span class="member-quickadd-msg" id="new-member-msg" aria-live="polite"></span>
|
||
</div>
|
||
<?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 在前),写反了会导到地球另一边。
|
||
// src 与服务端同源,取自 config.php 的 amap.nav_src(只用于统计),不要在别处写死。
|
||
var NAV_SRC = <?= json_encode((string)(app_config()['amap']['nav_src'] ?? 'trip.example.com')) ?>;
|
||
|
||
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
function dataUrlBytes(dataUrl) {
|
||
var i = String(dataUrl).indexOf(',');
|
||
if (i < 0) return 0;
|
||
var b64 = String(dataUrl).slice(i + 1);
|
||
var pad = b64.slice(-2) === '==' ? 2 : (b64.slice(-1) === '=' ? 1 : 0);
|
||
return Math.floor(b64.length * 3 / 4) - pad;
|
||
}
|
||
function bytesOf(it) { return it.cropped ? dataUrlBytes(it.cropped) : it.file.size; }
|
||
|
||
function find(uid) {
|
||
for (var i = 0; i < picked.length; i++) if (picked[i].uid === uid) return picked[i];
|
||
return null;
|
||
}
|
||
|
||
// 能不能改写 input.files —— 决定「移除」按钮是否有意义。
|
||
// 只判断 DataTransfer 存在是不够的:必须真塞一个文件、再读回来验证赋值生效。
|
||
// 否则界面上显示「已移除」、请求里却还带着,用户永远查不出为什么多传了一张。
|
||
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);
|
||
})();
|
||
|
||
// ===== 快捷新建成员(批 29):AJAX 建 guest 成员 → 插入已勾选的参与人 =====
|
||
(function initQuickAdd() {
|
||
var box = document.getElementById('member-quickadd');
|
||
if (!box) return;
|
||
var input = document.getElementById('new-member-name');
|
||
var btn = document.getElementById('new-member-add');
|
||
var msg = document.getElementById('new-member-msg');
|
||
var list = document.querySelector('#member-field .member-pick');
|
||
if (!input || !btn || !msg || !list) return;
|
||
|
||
function say(text, ok) {
|
||
msg.textContent = text;
|
||
msg.classList.toggle('is-ok', !!ok);
|
||
msg.classList.toggle('is-err', !ok);
|
||
}
|
||
|
||
function addOption(name, id) {
|
||
var label = document.createElement('label');
|
||
label.className = 'member-pick-item';
|
||
var cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.name = 'members[]';
|
||
cb.value = String(id);
|
||
cb.checked = true;
|
||
label.appendChild(cb);
|
||
var span = document.createElement('span');
|
||
span.className = 'member-pick-name';
|
||
span.textContent = name;
|
||
label.appendChild(span);
|
||
list.appendChild(label);
|
||
}
|
||
|
||
function submit() {
|
||
var name = input.value.replace(/\s+/g, ' ').trim();
|
||
if (!name) { say('先输入名字', false); input.focus(); return; }
|
||
btn.disabled = true;
|
||
say('创建中…', true);
|
||
var fd = new FormData();
|
||
fd.append('name', name);
|
||
fd.append('_csrf', box.dataset.csrf || '');
|
||
fetch(box.dataset.url, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(function (r) {
|
||
return r.json().then(function (j) { return { status: r.status, j: j }; });
|
||
})
|
||
.then(function (res) {
|
||
if (res.j && res.j.ok) {
|
||
addOption(res.j.name, res.j.id);
|
||
input.value = '';
|
||
say('已创建「' + res.j.name + '」并勾选', true);
|
||
} else {
|
||
say((res.j && res.j.error) || ('创建失败(' + res.status + ')'), false);
|
||
}
|
||
})
|
||
.catch(function () { say('网络错误,请重试', false); })
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
input.focus();
|
||
});
|
||
}
|
||
|
||
btn.addEventListener('click', submit);
|
||
input.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter') { ev.preventDefault(); submit(); }
|
||
});
|
||
})();
|
||
})();
|
||
})();
|
||
</script>
|