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

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

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
+23 -160
View File
@@ -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()) {
+116 -2
View File
@@ -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
View File
@@ -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;
}
}
+37
View File
@@ -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
{