diff --git a/assets/style.css b/assets/style.css index c03beb0..ae40274 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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; diff --git a/index.php b/index.php index 32ef856..9c30dc0 100644 --- a/index.php +++ b/index.php @@ -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'; diff --git a/src/Controllers/AttachmentController.php b/src/Controllers/AttachmentController.php index e5d50f7..b0b934b 100644 --- a/src/Controllers/AttachmentController.php +++ b/src/Controllers/AttachmentController.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()) { diff --git a/src/Controllers/NodeController.php b/src/Controllers/NodeController.php index 78d3d02..0df6c22 100644 --- a/src/Controllers/NodeController.php +++ b/src/Controllers/NodeController.php @@ -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; + } } diff --git a/src/Uploads.php b/src/Uploads.php new file mode 100644 index 0000000..eb77565 --- /dev/null +++ b/src/Uploads.php @@ -0,0 +1,262 @@ + 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; + } +} diff --git a/src/helpers.php b/src/helpers.php index 04b3637..7128b7b 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -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 { diff --git a/templates/layout.php b/templates/layout.php index 1eed277..74d4edf 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -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]); +})(); diff --git a/templates/nodes/form.php b/templates/nodes/form.php index 2b85447..8571b19 100644 --- a/templates/nodes/form.php +++ b/templates/nodes/form.php @@ -49,7 +49,8 @@ $tripField = [ 返回活动 -
+ @@ -145,6 +146,13 @@ $tripField = [ + + + @@ -159,6 +167,12 @@ $tripField = [ + + @@ -167,6 +181,63 @@ $tripField = [ + +
+ 照片附件(可多张) + + +
+ +
+ + + + +
+
勾上的会在保存时删掉(文件也会一起清理)。
+
+ + +
+ + :label 包着隐藏 input 时, + 点击会在部分浏览器里触发两次文件对话框。这里统一由 JS 打开。 */ ?> +
+ +
+
📷
+

点击选择,或把照片拖到这里

+

+ 可一次选多张;登机牌、车票、门票随手拍下来即可。 + 单张上限 MB,一次最多 个。 +

+
+
+
+
+
+
+
执行状态 @@ -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 = ; + var MAX_ONE = * 1024 * 1024; + var POST_LIMIT = ; + + 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, '''); + } + + // 能不能改写 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 + ? '' + : '
' + esc(extOf(it.file.name).toUpperCase() || 'FILE') + '
') + + (canSync + ? '' + : '') + + '
' + + '' + esc(it.file.name) + '' + + '' + esc(fmtSize(it.file.size)) + '' + + '
'; + 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' }); + } + }); + })(); })(); diff --git a/templates/nodes/show.php b/templates/nodes/show.php index 6588cc9..f9d7e93 100644 --- a/templates/nodes/show.php +++ b/templates/nodes/show.php @@ -104,6 +104,12 @@ if (!empty($node['duration_minutes'])) { + + + + 🧭 导航 + @@ -115,6 +121,11 @@ if (!empty($node['duration_minutes'])) { + + + 🧭 导航 +