节点表单:时间框整块可点、起点终点一键导航、可直接传多张附件
三件事都是「界面上明明该有、但用起来不通」的地方:
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:
@@ -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;
|
||||
|
||||
@@ -14,6 +14,7 @@ require __DIR__ . '/src/Settings.php';
|
||||
require __DIR__ . '/src/Oss.php';
|
||||
require __DIR__ . '/src/Storage.php';
|
||||
require __DIR__ . '/src/Attachments.php';
|
||||
require __DIR__ . '/src/Uploads.php';
|
||||
require __DIR__ . '/src/Controllers/AuthController.php';
|
||||
require __DIR__ . '/src/Controllers/AttachmentController.php';
|
||||
require __DIR__ . '/src/Controllers/ActivityController.php';
|
||||
|
||||
@@ -6,15 +6,12 @@ namespace App\Controllers;
|
||||
use App\Access;
|
||||
use App\Attachments;
|
||||
use App\Auth;
|
||||
use App\Mime;
|
||||
use App\Settings;
|
||||
use App\Storage;
|
||||
use App\Uploads;
|
||||
|
||||
class AttachmentController
|
||||
{
|
||||
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
||||
private const MAX_FILES = 30;
|
||||
|
||||
public function node(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
@@ -37,113 +34,39 @@ class AttachmentController
|
||||
* files[] —— 普通 multipart 文件(照片原图、PDF、视频……)
|
||||
* cropped[] —— 前端裁好的图片,JSON 字符串 {"name":..,"data":"data:image/...;base64,.."}
|
||||
* 分开传是为了省流量:没裁的照片原图直传,裁过的才走 base64。
|
||||
*
|
||||
* 真正的处理逻辑在 App\Uploads —— 节点表单里的附件走同一份代码,
|
||||
* 免得两边的 MIME 白名单/大小上限/孤儿文件回滚各写一套、慢慢走偏。
|
||||
*/
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
|
||||
// 请求体超过 post_max_size 时 PHP 会把 $_POST 和 $_FILES 整个丢掉,
|
||||
// 紧接着的 csrf_check() 必然失败、报「请求已过期」,用户完全看不懂。
|
||||
// 提前认出来,给一句能行动的话。
|
||||
if (Uploads::requestTooLarge()) {
|
||||
$nodeId = (int)($_POST['node_id'] ?? $_GET['node_id'] ?? 0);
|
||||
flash('error', '一次上传的内容超过服务器上限(' . ini_get('post_max_size')
|
||||
. '),请分批上传。');
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
}
|
||||
|
||||
csrf_check();
|
||||
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$node = Access::node($nodeId);
|
||||
|
||||
$maxBytes = 20 * 1024 * 1024;
|
||||
$photoMax = (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
|
||||
$uploaderId = (int)Auth::id();
|
||||
$target = Attachments::TARGET_NODE;
|
||||
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
$count = 0;
|
||||
$files = Uploads::handleFiles($_FILES['files'] ?? [], $target, $nodeId, $uploaderId, 0);
|
||||
$crop = Uploads::handleCropped((string)($_POST['cropped'] ?? ''), $target, $nodeId,
|
||||
$uploaderId, $files['count']);
|
||||
|
||||
// ── 1) 普通文件 ──
|
||||
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
|
||||
foreach ($this->restructureFiles($_FILES['files']) as $file) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
||||
continue;
|
||||
}
|
||||
$limit = $photoMax > 0 ? max($photoMax, $maxBytes) : $maxBytes;
|
||||
if ($file['size'] > $limit) {
|
||||
$errors[] = $file['name'] . ':超过大小上限';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $file['name'] . ':不支持的文件类型';
|
||||
continue;
|
||||
}
|
||||
if ($info['is_image'] && $photoMax > 0 && $file['size'] > $photoMax) {
|
||||
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($file['tmp_name']);
|
||||
if ($raw === false) {
|
||||
$errors[] = $file['name'] . ':读取临时文件失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = $this->store($nodeId, $raw, $mime, $file['name'], $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $file['name'] . ':' . $err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2) 前端裁剪后的图片 ──
|
||||
$cropped = $_POST['cropped'] ?? '';
|
||||
if (is_string($cropped) && $cropped !== '') {
|
||||
$items = json_decode($cropped, true);
|
||||
if (!is_array($items)) {
|
||||
$errors[] = '裁剪数据解析失败';
|
||||
$items = [];
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
$name = (string)($item['name'] ?? 'photo.png');
|
||||
$data = (string)($item['data'] ?? '');
|
||||
if (!preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#is', $data, $m)) {
|
||||
$errors[] = $name . ':裁剪结果格式不正确';
|
||||
continue;
|
||||
}
|
||||
$raw = base64_decode($m[2], true);
|
||||
if ($raw === false || $raw === '') {
|
||||
$errors[] = $name . ':图片解码失败';
|
||||
continue;
|
||||
}
|
||||
if (strlen($raw) > $maxBytes) {
|
||||
$errors[] = $name . ':超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectBuffer($raw);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !Mime::isImageMime($mime) || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $name . ':裁剪结果不是受支持的图片';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = $this->store($nodeId, $raw, $mime, $this->asImageName($name, $mime), $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $name . ':' . $err;
|
||||
}
|
||||
}
|
||||
}
|
||||
$uploaded = $files['uploaded'] + $crop['uploaded'];
|
||||
$errors = array_merge($files['errors'], $crop['errors']);
|
||||
$count = $crop['count'];
|
||||
|
||||
if ($count === 0 && $uploaded === 0) {
|
||||
flash('error', '没有选择任何文件');
|
||||
@@ -181,66 +104,6 @@ class AttachmentController
|
||||
redirect('attachments/node', ['id' => (int)$node['id']], 'photos');
|
||||
}
|
||||
|
||||
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
|
||||
private function store(int $nodeId, string $raw, string $mime, string $fileName, array $info): ?string
|
||||
{
|
||||
$ext = Mime::extFor($mime);
|
||||
if ($ext === null) {
|
||||
return '无法确定扩展名';
|
||||
}
|
||||
|
||||
try {
|
||||
$key = Storage::keyFor($ext, 'attachments');
|
||||
$stored = Storage::put($key, $raw, $mime);
|
||||
} catch (\Throwable $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
Attachments::create(
|
||||
Attachments::TARGET_NODE,
|
||||
$nodeId,
|
||||
(int)Auth::id(),
|
||||
$stored,
|
||||
$fileName,
|
||||
$mime,
|
||||
(bool)$info['is_image'],
|
||||
$info['width'],
|
||||
$info['height']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 入库失败就把刚写的对象删掉,别留孤儿文件
|
||||
Storage::delete($stored['storage'], $stored['key']);
|
||||
return '入库失败:' . $e->getMessage();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 裁剪结果统一成 .jpg/.png 这类名字,避免扩展名与真实格式不符 */
|
||||
private function asImageName(string $name, string $mime): string
|
||||
{
|
||||
$base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo';
|
||||
return $base . '.' . (Mime::extFor($mime) ?? 'jpg');
|
||||
}
|
||||
|
||||
private function restructureFiles(array $files): array
|
||||
{
|
||||
$result = [];
|
||||
$count = count($files['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (empty($files['name'][$i])) continue;
|
||||
$result[] = [
|
||||
'name' => $files['name'][$i],
|
||||
'type' => $files['type'][$i],
|
||||
'tmp_name' => $files['tmp_name'][$i],
|
||||
'error' => $files['error'][$i],
|
||||
'size' => $files['size'][$i],
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Comments;
|
||||
use App\Database;
|
||||
use App\Expenses;
|
||||
use App\Members;
|
||||
use App\Uploads;
|
||||
|
||||
class NodeController
|
||||
{
|
||||
@@ -44,6 +45,14 @@ class NodeController
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
// 表单里带了照片时,整个请求体可能超过 post_max_size —— 这时 PHP 会
|
||||
// **静默丢掉 $_POST 和 $_FILES**,紧接着 csrf_check() 报「请求已过期」,
|
||||
// 用户看到的是「点了保存但没反应」。提前认出来,说清怎么办。
|
||||
// (这一步放在 csrf_check() 之前:它只读长度、不改任何状态。)
|
||||
if (Uploads::requestTooLarge()) {
|
||||
$this->flashTooLarge();
|
||||
}
|
||||
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
@@ -84,12 +93,19 @@ class NodeController
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
flash('error', '节点已保存');
|
||||
// 节点必须先有 id 才能挂附件,所以附件在插入之后处理
|
||||
$extra = $this->handleNodeUploads((int)$db->lastInsertId());
|
||||
|
||||
flash('error', '节点已保存' . $extra);
|
||||
redirect('activities/show', ['id' => (int)$activity['id']]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
if (Uploads::requestTooLarge()) {
|
||||
$this->flashTooLarge();
|
||||
}
|
||||
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
@@ -123,7 +139,17 @@ class NodeController
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
flash('error', '保存成功');
|
||||
// 表单里勾了「删除」的已有照片
|
||||
$removed = $this->deleteCheckedAttachments((int)$node['id']);
|
||||
|
||||
// 再处理这次新选的照片
|
||||
$extra = $this->handleNodeUploads((int)$node['id']);
|
||||
|
||||
$msg = '保存成功';
|
||||
if ($removed > 0) {
|
||||
$msg .= ",删除了 {$removed} 个附件";
|
||||
}
|
||||
flash('error', $msg . $extra);
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
@@ -338,6 +364,10 @@ class NodeController
|
||||
'planNodes' => $planNodes,
|
||||
'node' => $node,
|
||||
'isEdit' => $isEdit,
|
||||
// 编辑时把已有照片带进表单:能看、能勾选删除。新建时节点还没 id,
|
||||
// 附件挂不上去,所以只能是空数组(模板里会提示先保存)。
|
||||
'attachments' => $isEdit ? Attachments::forTarget(Attachments::TARGET_NODE, (int)$node['id']) : [],
|
||||
'maxPhotoMb' => (int)\App\Settings::get('photo_max_mb', '12'),
|
||||
'pageTitle' => ($isEdit ? '编辑' : '新建') . ($category === 'plan' ? '计划' : '执行') . '节点',
|
||||
];
|
||||
}
|
||||
@@ -496,4 +526,88 @@ class NodeController
|
||||
$v = trim($_POST[$key] ?? '');
|
||||
return $v === '' ? null : $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体超限的统一提示。
|
||||
*
|
||||
* 这时 $_POST 已经被 PHP 丢掉了,拿不到 activity_id,只能尽量退回一个合理页面。
|
||||
*/
|
||||
private function flashTooLarge(): void
|
||||
{
|
||||
flash('error', '这次提交的内容太大(超过服务器上限 ' . ini_get('post_max_size')
|
||||
. '),节点没有保存。请把照片分成几批上传,或先保存节点、再进「📷 照片」页上传。');
|
||||
|
||||
$actId = (int)($_POST['activity_id'] ?? 0);
|
||||
if ($actId > 0) {
|
||||
$cat = (string)($_POST['node_category'] ?? 'plan');
|
||||
redirect('nodes/create', [
|
||||
'activity_id' => $actId,
|
||||
'category' => in_array($cat, ['plan', 'reality'], true) ? $cat : 'plan',
|
||||
]);
|
||||
}
|
||||
redirect('activities/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理节点表单里一起提交的附件(新选的 files[] 与前端裁过的 cropped[])。
|
||||
*
|
||||
* @return string 追加到 flash 文案后的后缀;没有附件时是空串
|
||||
*/
|
||||
private function handleNodeUploads(int $nodeId): string
|
||||
{
|
||||
$uploaderId = (int)Auth::id();
|
||||
$target = Attachments::TARGET_NODE;
|
||||
|
||||
$files = Uploads::handleFiles($_FILES['files'] ?? [], $target, $nodeId, $uploaderId, 0);
|
||||
$crop = Uploads::handleCropped(
|
||||
(string)($_POST['cropped'] ?? ''),
|
||||
$target,
|
||||
$nodeId,
|
||||
$uploaderId,
|
||||
$files['count']
|
||||
);
|
||||
|
||||
$uploaded = $files['uploaded'] + $crop['uploaded'];
|
||||
$errors = array_merge($files['errors'], $crop['errors']);
|
||||
|
||||
if ($uploaded === 0 && $errors === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$msg = $uploaded > 0
|
||||
? ",另存了 {$uploaded} 张照片/附件"
|
||||
: ',但照片没传上去';
|
||||
if ($errors !== []) {
|
||||
$msg .= '(' . implode(';', array_slice($errors, 0, 3)) . ')';
|
||||
}
|
||||
return $msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除表单里勾选的已有附件。
|
||||
*
|
||||
* ⚠️ 必须逐条校验归属:attachments 是「target_type + target_id」的软关联,
|
||||
* 表单里的 id 是用户可以随便改的,不校验就等于给了跨家庭删文件的入口。
|
||||
*/
|
||||
private function deleteCheckedAttachments(int $nodeId): int
|
||||
{
|
||||
$ids = $_POST['delete_attachments'] ?? [];
|
||||
if (!is_array($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
foreach ($ids as $id) {
|
||||
$id = (int)$id;
|
||||
$att = $id > 0 ? Attachments::find($id) : null;
|
||||
if (!$att
|
||||
|| $att['target_type'] !== Attachments::TARGET_NODE
|
||||
|| (int)$att['target_id'] !== $nodeId) {
|
||||
continue;
|
||||
}
|
||||
Attachments::delete($id);
|
||||
$removed++;
|
||||
}
|
||||
return $removed;
|
||||
}
|
||||
}
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 把「一个请求里带上来的文件」写成附件。
|
||||
*
|
||||
* 抽成独立类是因为有两个入口都要用它:
|
||||
* · AttachmentController::upload —— 节点照片页的独立上传
|
||||
* · NodeController::store/update —— 节点表单里跟节点一起提交的附件
|
||||
* 两边各写一份的话,MIME 白名单、大小上限、孤儿文件回滚这些迟早会走偏。
|
||||
*
|
||||
* 两类入参(沿用原设计,别再合并):
|
||||
* files[] —— 普通 multipart 文件(原图、PDF……)省流量,不预压
|
||||
* cropped[] —— 前端裁过/压过的图片,JSON 字符串 [{"name":..,"data":"data:image/..;base64,.."}]
|
||||
*/
|
||||
final class Uploads
|
||||
{
|
||||
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
||||
public const MAX_FILES = 30;
|
||||
|
||||
/** 单文件硬上限 */
|
||||
public const MAX_BYTES = 20971520; // 20MB
|
||||
|
||||
/**
|
||||
* 把 php.ini 里的 "8M" / "50M" 这类写法换算成字节数。
|
||||
*
|
||||
* 直接转发给全局的 ini_bytes()(helpers.php 里那个,模板也在用)——
|
||||
* 项目里只能有一份实现,否则两处迟早对 '-1'(不限)这类边界值理解不一致。
|
||||
*/
|
||||
public static function iniBytes(string $value): int
|
||||
{
|
||||
return \ini_bytes($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「整个请求体超过 post_max_size」时,PHP 会**静默丢掉 $_POST 和 $_FILES**,
|
||||
* 表现为「点了保存但什么都没发生 / CSRF 校验失败」——最难排查的一类问题。
|
||||
* 这里按 CONTENT_LENGTH 提前认出来,好给用户一句能懂的话。
|
||||
*/
|
||||
public static function requestTooLarge(): bool
|
||||
{
|
||||
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||
if ($len <= 0) {
|
||||
return false;
|
||||
}
|
||||
$max = self::iniBytes((string)ini_get('post_max_size'));
|
||||
return $max > 0 && $len > $max;
|
||||
}
|
||||
|
||||
/** 站点设置的「单张照片上限」(字节) */
|
||||
public static function maxPhotoBytes(): int
|
||||
{
|
||||
return (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 $_FILES 里的某个多选字段(形状来自 name="xxx[]")。
|
||||
*
|
||||
* @param int $countStart 已经计入上限的文件数(files 与 cropped 共用同一个上限)
|
||||
* @return array{uploaded:int, errors:string[], count:int}
|
||||
*/
|
||||
public static function handleFiles(
|
||||
array $filesField,
|
||||
string $targetType,
|
||||
int $targetId,
|
||||
int $uploaderId,
|
||||
int $countStart = 0
|
||||
): array {
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
$count = $countStart;
|
||||
|
||||
if (empty($filesField) || !is_array($filesField['name'] ?? null)) {
|
||||
return ['uploaded' => 0, 'errors' => [], 'count' => $count];
|
||||
}
|
||||
|
||||
$photoMax = self::maxPhotoBytes();
|
||||
|
||||
foreach (self::restructure($filesField) as $file) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
||||
continue;
|
||||
}
|
||||
$limit = $photoMax > 0 ? max($photoMax, self::MAX_BYTES) : self::MAX_BYTES;
|
||||
if ($file['size'] > $limit) {
|
||||
$errors[] = $file['name'] . ':超过大小上限';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $file['name'] . ':不支持的文件类型';
|
||||
continue;
|
||||
}
|
||||
if ($info['is_image'] && $photoMax > 0 && $file['size'] > $photoMax) {
|
||||
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($file['tmp_name']);
|
||||
if ($raw === false) {
|
||||
$errors[] = $file['name'] . ':读取临时文件失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime, $file['name'], $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $file['name'] . ':' . $err;
|
||||
}
|
||||
}
|
||||
|
||||
return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理前端裁剪/压缩后的图片(base64 data URL)。
|
||||
*
|
||||
* @return array{uploaded:int, errors:string[], count:int}
|
||||
*/
|
||||
public static function handleCropped(
|
||||
string $json,
|
||||
string $targetType,
|
||||
int $targetId,
|
||||
int $uploaderId,
|
||||
int $countStart = 0
|
||||
): array {
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
$count = $countStart;
|
||||
|
||||
if (trim($json) === '') {
|
||||
return ['uploaded' => 0, 'errors' => [], 'count' => $count];
|
||||
}
|
||||
|
||||
$items = json_decode($json, true);
|
||||
if (!is_array($items)) {
|
||||
return ['uploaded' => 0, 'errors' => ['裁剪数据解析失败'], 'count' => $count];
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
$name = (string)($item['name'] ?? 'photo.png');
|
||||
$data = (string)($item['data'] ?? '');
|
||||
if (!preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#is', $data, $m)) {
|
||||
$errors[] = $name . ':裁剪结果格式不正确';
|
||||
continue;
|
||||
}
|
||||
$raw = base64_decode($m[2], true);
|
||||
if ($raw === false || $raw === '') {
|
||||
$errors[] = $name . ':图片解码失败';
|
||||
continue;
|
||||
}
|
||||
if (strlen($raw) > self::MAX_BYTES) {
|
||||
$errors[] = $name . ':超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectBuffer($raw);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !Mime::isImageMime($mime) || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $name . ':裁剪结果不是受支持的图片';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime,
|
||||
self::imageName($name, $mime), $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $name . ':' . $err;
|
||||
}
|
||||
}
|
||||
|
||||
return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count];
|
||||
}
|
||||
|
||||
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
|
||||
public static function storeOne(
|
||||
string $targetType,
|
||||
int $targetId,
|
||||
int $uploaderId,
|
||||
string $raw,
|
||||
string $mime,
|
||||
string $fileName,
|
||||
array $info
|
||||
): ?string {
|
||||
$ext = Mime::extFor($mime);
|
||||
if ($ext === null) {
|
||||
return '无法确定扩展名';
|
||||
}
|
||||
|
||||
try {
|
||||
$key = Storage::keyFor($ext, 'attachments');
|
||||
$stored = Storage::put($key, $raw, $mime);
|
||||
} catch (\Throwable $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
Attachments::create(
|
||||
$targetType,
|
||||
$targetId,
|
||||
$uploaderId,
|
||||
$stored,
|
||||
$fileName,
|
||||
$mime,
|
||||
(bool)$info['is_image'],
|
||||
$info['width'],
|
||||
$info['height']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 入库失败就把刚写的对象删掉,别留孤儿文件
|
||||
Storage::delete($stored['storage'], $stored['key']);
|
||||
return '入库失败:' . $e->getMessage();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 裁剪结果统一成 .jpg/.png 这类名字,避免扩展名与真实格式不符 */
|
||||
public static function imageName(string $name, string $mime): string
|
||||
{
|
||||
$base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo';
|
||||
return $base . '.' . (Mime::extFor($mime) ?? 'jpg');
|
||||
}
|
||||
|
||||
/** 把 $_FILES 的列式数组转成一列「一个文件一项」 */
|
||||
public static function restructure(array $files): array
|
||||
{
|
||||
$result = [];
|
||||
$count = count($files['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (empty($files['name'][$i])) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'name' => $files['name'][$i],
|
||||
'type' => $files['type'][$i],
|
||||
'tmp_name' => $files['tmp_name'][$i],
|
||||
'error' => $files['error'][$i],
|
||||
'size' => $files['size'][$i],
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,43 @@ function media_avatar(?string $url): string
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 高德导航链接:点一下直接导航到这个位置(手机端会尝试唤起高德 App)。
|
||||
*
|
||||
* 格式依据高德 URI API:https://uri.amap.com/navigation?to=lon,lat,name&mode=car
|
||||
* ⚠️ 坐标顺序是「经度,纬度」(lon 在前),写反了会导航到地球另一边。
|
||||
* ⚠️ 只给 to 不给 from:from 留空时移动端会自动用「当前位置」当起点,
|
||||
* 这正是「导航到这个位置」的语义。
|
||||
*
|
||||
* 没坐标时退化成关键词搜索 —— 用户在表单里只敲了名字、没从下拉里选中的情况很常见,
|
||||
* 这种节点也给一个能用的链接,总比什么都不显示强。
|
||||
*
|
||||
* @param array $loc 形如 ['name'=>..,'address'=>..,'lat'=>..,'lng'=>..]
|
||||
* @return string 无可用信息时返回空串(模板据此决定要不要渲染链接)
|
||||
*/
|
||||
function amap_nav_url(array $loc, string $mode = 'car'): string
|
||||
{
|
||||
$name = trim((string)($loc['name'] ?? ''));
|
||||
$lng = trim((string)($loc['lng'] ?? ''));
|
||||
$lat = trim((string)($loc['lat'] ?? ''));
|
||||
|
||||
if (is_numeric($lng) && is_numeric($lat)) {
|
||||
// 只对名称做 URL 编码(逗号保持字面量,否则高德解析不出三段式);
|
||||
// 名称里若含 & 或 = 也能被正确转义。
|
||||
$to = $lng . ',' . $lat . ($name !== '' ? ',' . rawurlencode($name) : '');
|
||||
return 'https://uri.amap.com/navigation?to=' . $to
|
||||
. '&mode=' . rawurlencode($mode)
|
||||
. '&policy=1&src=trip.yangyq.net&coordinate=gaode&callnative=1';
|
||||
}
|
||||
|
||||
$keyword = $name !== '' ? $name : trim((string)($loc['address'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
return '';
|
||||
}
|
||||
return 'https://uri.amap.com/search?keyword=' . rawurlencode($keyword)
|
||||
. '&src=trip.yangyq.net';
|
||||
}
|
||||
|
||||
/** 人类可读的文件大小 */
|
||||
function human_size(?int $bytes): string
|
||||
{
|
||||
|
||||
@@ -128,6 +128,29 @@ document.addEventListener('keydown', function (ev) {
|
||||
menu.open = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 时间框:点输入框任意一处都弹出选择器 =====
|
||||
// 原生行为只在点右侧那个小日历图标时才弹(而且各浏览器还不一致),用户会以为框只能读。
|
||||
// showPicker() 是标准做法:在 click 里调它,相当于把整块输入区都当成了那个图标。
|
||||
//
|
||||
// 为什么能跟浏览器自带的弹窗共存:若浏览器这次点击已经弹过了,再调一次是空操作
|
||||
// (规范里「show the picker」对已经在显示的选择器直接返回),不会闪两下。
|
||||
// 不支持的浏览器(老 Safari 等)静默跳过 —— 退化成原生行为,不会比现在更差。
|
||||
(function () {
|
||||
var pickers = document.querySelectorAll('input[type="datetime-local"], input[type="date"]');
|
||||
function hook(el) {
|
||||
el.addEventListener('click', function () {
|
||||
if (typeof el.showPicker !== 'function') return;
|
||||
try {
|
||||
el.showPicker();
|
||||
} catch (err) {
|
||||
// NotAllowedError:不是用户手势触发的(或被跨域 iframe 挡住)。
|
||||
// 忽略即可 —— 原生那套点击行为还在,用户仍有办法选时间。
|
||||
}
|
||||
});
|
||||
}
|
||||
for (var i = 0; i < pickers.length; i++) hook(pickers[i]);
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
|
||||
+340
-1
@@ -49,7 +49,8 @@ $tripField = [
|
||||
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">返回活动</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= url($isEdit ? 'nodes/update' : 'nodes/store') ?>" class="form">
|
||||
<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) ?>">
|
||||
@@ -145,6 +146,13 @@ $tripField = [
|
||||
<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>
|
||||
|
||||
@@ -159,6 +167,12 @@ $tripField = [
|
||||
<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>
|
||||
|
||||
@@ -167,6 +181,63 @@ $tripField = [
|
||||
<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>
|
||||
@@ -245,6 +316,79 @@ $tripField = [
|
||||
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;
|
||||
@@ -328,6 +472,8 @@ $tripField = [
|
||||
if (lngField) lngField.value = item.location.lng;
|
||||
}
|
||||
dropdown.style.display = 'none';
|
||||
// 坐标刚变,导航链接要跟着变(有坐标才是真导航,没坐标只是搜索)
|
||||
refreshLocNav(prefix);
|
||||
}
|
||||
|
||||
var searchTimer = null;
|
||||
@@ -382,5 +528,198 @@ $tripField = [
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 节点表单里的照片附件 =====
|
||||
// 与「照片与附件」页不同:这里不做裁剪,选好就跟着表单一起提交。
|
||||
//
|
||||
// 必须在这一层挡住「总量超 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 能不能改写 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>
|
||||
|
||||
@@ -104,6 +104,12 @@ if (!empty($node['duration_minutes'])) {
|
||||
<?php if (!empty($sLoc['address'])): ?>
|
||||
<span class="muted"><?= e($sLoc['address']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php /* 存下来的坐标就是为了一键导航:手机端点开会直接唤起高德 App */ ?>
|
||||
<?php $sNav = amap_nav_url($sLoc); ?>
|
||||
<?php if ($sNav !== ''): ?>
|
||||
<a class="loc-nav-link loc-nav-inline" href="<?= e($sNav) ?>"
|
||||
target="_blank" rel="noopener">🧭 导航</a>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -115,6 +121,11 @@ if (!empty($node['duration_minutes'])) {
|
||||
<?php if (!empty($eLoc['address'])): ?>
|
||||
<span class="muted"><?= e($eLoc['address']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php $eNav = amap_nav_url($eLoc); ?>
|
||||
<?php if ($eNav !== ''): ?>
|
||||
<a class="loc-nav-link loc-nav-inline" href="<?= e($eNav) ?>"
|
||||
target="_blank" rel="noopener">🧭 导航</a>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
Reference in New Issue
Block a user