节点表单:时间框整块可点、起点终点一键导航、可直接传多张附件

三件事都是「界面上明明该有、但用起来不通」的地方:

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 渲染逐字节一致。
This commit is contained in:
2026-09-23 07:53:20 +08:00
parent 43171597e2
commit 44ffee6c92
9 changed files with 869 additions and 163 deletions
+56
View File
@@ -438,6 +438,12 @@ input[type="file"] { font-size: 13px; padding: 6px 0; border: 0; background: non
textarea { resize: vertical; min-height: 84px; }
select { cursor: pointer; }
/* 时间框:点框内任何一处都能弹选择器(由 layout.php 里的 showPicker 负责,
原生只有点右侧那个小图标才弹)。光标给个「整块可点」的暗示,别让人以为只有图标能点。 */
input[type="datetime-local"], input[type="date"] { cursor: pointer; }
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="date"]::-webkit-calendar-picker-indicator { cursor: pointer; padding: 2px; }
.form-actions {
display: flex; align-items: center; gap: 10px;
margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border);
@@ -503,6 +509,22 @@ select { cursor: pointer; }
.amap-dropdown-name { font-size: 13.5px; }
.amap-dropdown-sub { font-size: 12px; color: var(--text-mute); }
/* 起点 / 终点的「导航到这里」。
有没有坐标决定它是什么链接,所以由服务端与 JS 共同决定显隐;
必须排在 .form-row .muted 之后(同权重,靠后者生效)。 */
.loc-nav { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.loc-nav-link {
display: inline-flex; align-items: center; gap: 4px;
font-size: 13px; font-weight: 560; line-height: 1.4;
padding: 4px 11px; border-radius: 999px;
background: var(--accent-soft); color: var(--accent);
border: 1px solid transparent;
}
.loc-nav-link:hover { border-color: var(--accent); text-decoration: none; }
.loc-nav .muted { margin-top: 0; font-size: 12px; }
/* 详情页里跟在地址后面的那个链接(不是整块,只是行内追加一个胶囊) */
.loc-nav-inline { margin-left: 8px; vertical-align: middle; font-size: 12.5px; padding: 3px 9px; }
/* emoji 选择 */
.emoji-picker { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.emoji-btn {
@@ -692,6 +714,39 @@ select { cursor: pointer; }
gap: 12px;
}
.photo-grid-sm { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
/* 已有附件的「勾选即删除」格子:整格可点,勾上后变灰 + 红勾。
原生 checkbox 藏起来但保留可聚焦,键盘也能用(勾选框上会有 focus 环)。 */
.photo-pick {
position: relative; display: block; cursor: pointer; margin: 0;
border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; background: var(--surface);
transition: border-color .15s, box-shadow .15s;
}
.photo-pick:hover { border-color: var(--accent); }
.photo-pick img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; background: var(--bg-soft); }
.photo-pick-ext {
display: flex; align-items: center; justify-content: center;
aspect-ratio: 4 / 3; font-size: 12px; font-weight: 600;
color: var(--text-mute); background: var(--bg-soft);
}
.photo-pick input { position: absolute; width: 0; height: 0; opacity: 0; }
.photo-pick-mark {
position: absolute; top: 6px; right: 6px;
width: 22px; height: 22px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 13px; line-height: 1; color: transparent;
background: rgba(20, 24, 34, .45);
border: 1px solid rgba(255, 255, 255, .85);
transition: background .15s, color .15s, border-color .15s;
}
.photo-pick input:checked ~ img,
.photo-pick input:checked ~ .photo-pick-ext { opacity: .35; filter: grayscale(1); }
.photo-pick input:checked ~ .photo-pick-mark { background: var(--danger); border-color: var(--danger); color: #fff; }
.photo-pick input:focus-visible ~ .photo-pick-mark { box-shadow: 0 0 0 3px var(--accent-ring); }
/* 表单里刚选中的待上传文件(复用 .photo-card 的样式,只是要一个能挂 × 按钮的位置) */
.photo-card > .photo-del { position: absolute; top: 6px; right: 6px; }
.photo-card {
position: relative; display: block;
border: 1px solid var(--border); border-radius: var(--radius);
@@ -939,6 +994,7 @@ select { cursor: pointer; }
/* 表单里的小字说明 */
.form-hint { font-size: 12.5px; color: var(--text-mute); margin-top: 5px; line-height: 1.65; }
.form-hint-error { color: var(--danger); font-weight: 560; }
.form-hint code, .settings-hint code, .alert code {
background: var(--bg-soft); padding: 1px 5px; border-radius: 4px;
font-size: 12px; word-break: break-all;