节点表单:时间框整块可点、起点终点一键导航、可直接传多张附件
三件事都是「界面上明明该有、但用起来不通」的地方:
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user