Files
TripLog/templates/nodes/form.php
T
laoyang 44ffee6c92 节点表单:时间框整块可点、起点终点一键导航、可直接传多张附件
三件事都是「界面上明明该有、但用起来不通」的地方:

1) 时间输入框只能点右侧那个小日历图标才弹选择器
   原生行为如此(而且各浏览器还不一致),用户会以为输入框是只读的。
   在全局脚本里给 datetime-local / date 挂 click → showPicker(),
   等于把整块输入区都当成那个图标;选择器已在显示时再调是空操作,
   不支持的浏览器静默跳过。顺带给可点光标。
   写在 layout.php 里,所以签到时间等其它时间框一并生效。

2) 起点/终点的精确位置此前只存在页面上,没有落库
   高德选点回调里拿到的 lng/lat 现在随节点一起存库,节点详情页和
   表单页都生成高德导航链接,点一下就能直接导航过去(手机端会尝试
   唤起高德 App)。链接只给 to 不给 from,让移动端自动用当前位置当起点。
   ⚠️ 高德 URI API 的坐标顺序是「经度,纬度」,写反了不报错、只会安静地
   导到地球另一边,所以单独写了单元测试守住顺序。
   手工改地名时会清掉旧坐标,退化成关键字搜索,避免「名字改了位置没改」。

3) 节点处不能上传图片附件
   节点表单改成 multipart,加拖放区 + 多选 + 本地预览 + 逐个移除,
   编辑态可勾选删除已有附件(逐条校验归属,防止改 id 删到别人家的文件)。
   上传逻辑从 AttachmentController 抽到 src/Uploads.php,两处共用一份:
   MIME 白名单、大小上限、孤儿回滚各抄一份迟早走偏。
   请求体超过 post_max_size 时 PHP 会静默丢掉 $_POST 与 $_FILES,紧随的
   CSRF 校验必然失败、报「请求已过期」——这是最难排查的一类问题。
   现在按 CONTENT_LENGTH 提前识别并给出人话提示,且该守卫必须排在
   csrf_check() 之前(只读长度、不改状态,所以顺序上是安全的)。

已验证:本地 php -l 全量、模板渲染 255 项、amap_nav_url 单测 18 项、
真实浏览器交互 47 项(时间框三处点击都弹、多选/移除后 input.files 真被
改写、超限被前端拦下、导航链接随选点刷新、勾选删除变红、三视口无溢出)、
打包 57 文件、远端 e2e 267 项,全部通过;部署后双 PHP 渲染逐字节一致。
2026-09-23 07:53:20 +08:00

726 lines
33 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=新建;数组=编辑
$curStatus = $n['status'] ?? ($category === 'plan' ? 'planned' : 'completed');
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
'completed' => '已完成',
'cancelled' => '已取消',
];
// 计划节点不会有"进行中",执行节点默认就是已完成
$statusOptions = $category === 'plan'
? ['planned', 'completed', 'cancelled']
: ['completed', 'ongoing', 'planned', 'cancelled'];
$dtLocal = static function ($value): string {
if (empty($value)) return '';
$ts = strtotime((string)$value);
return $ts ? date('Y-m-d\TH:i', $ts) : '';
};
$sLoc = json_decode_field($n['start_location'] ?? null);
$eLoc = json_decode_field($n['end_location'] ?? 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">
<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 foreach ($planNodes as $p): ?>
<option value="<?= (int)$p['id'] ?>">
<?= 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 foreach ($nodeTypes as $t): ?>
<option value="<?= (int)$t['id'] ?>"
<?= (int)($n['node_type_id'] ?? 0) === (int)$t['id'] ? ' selected' : '' ?>>
<?= e($t['icon'] . ' ' . $t['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row two-col">
<div>
<label>开始时间 *</label>
<input type="datetime-local" name="start_time" required value="<?= e($dtLocal($n['start_time'] ?? null)) ?>">
</div>
<div>
<label>结束时间 *</label>
<input type="datetime-local" name="end_time" required value="<?= e($dtLocal($n['end_time'] ?? null)) ?>">
</div>
</div>
<div class="form-row">
<label>标题</label>
<input type="text" name="title" maxlength="200" placeholder="例如:出发去奥森"
value="<?= e($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 foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>"
<?= (int)($n['travel_mode_id'] ?? 0) === (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($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($n['description'] ?? '') ?></textarea>
</div>
<?php
/* 照片附件:随节点表单一起提交。
新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」;
编辑时把已有照片带出来,可勾选删除。 */
$existingAtts = $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 ?>">
<?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>
</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($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>
<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';
}
});
}
// ===== 节点表单里的照片附件 =====
// 与「照片与附件」页不同:这里不做裁剪,选好就跟着表单一起提交。
//
// 必须在这一层挡住「总量超 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');
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 = []; // [{file, key, url}]
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 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;');
}
// 能不能改写 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;
}
})();
function syncInput() {
if (!canSync) return;
var dt = new DataTransfer();
picked.forEach(function (it) { dt.items.add(it.file); });
input.files = dt.files;
}
function totalBytes() {
return picked.reduce(function (sum, it) { return sum + it.file.size; }, 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.url
? '<img src="' + it.url + '" alt="">'
: '<div class="attachment-icon">' + esc(extOf(it.file.name).toUpperCase() || 'FILE') + '</div>')
+ (canSync
? '<button type="button" class="photo-del" data-idx="' + idx + '" title="移除">×</button>'
: '')
+ '<div class="photo-cap">'
+ '<span class="photo-cap-name" title="' + esc(it.file.name) + '">' + esc(it.file.name) + '</span>'
+ '<span class="photo-cap-sub">' + esc(fmtSize(it.file.size)) + '</span>'
+ '</div>';
preview.appendChild(card);
});
if (!picked.length) {
setStatus('', false);
return;
}
var total = totalBytes();
var over = POST_LIMIT > 0 && total > POST_LIMIT;
setStatus(
picked.length + ' 个待上传 · 合计约 ' + fmtSize(total)
+ (POST_LIMIT > 0 ? '(服务器单次上限 ' + fmtSize(POST_LIMIT) + ')' : '')
+ (over ? ' —— 超了。请分几次保存,或先保存节点、再进「📷 照片」页上传。' : ''),
over
);
}
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;
}
picked.push({
file: f,
key: key,
url: isImageFile(f) ? URL.createObjectURL(f) : ''
});
});
syncInput();
render();
if (errors.length) setStatus(errors.join(';'), true);
}
function removeAt(idx) {
var it = picked[idx];
if (!it) return;
if (it.url) { try { URL.revokeObjectURL(it.url); } catch (e) {} }
picked.splice(idx, 1);
syncInput();
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(); });
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' });
}
});
})();
})();
</script>