三件事都是「界面上明明该有、但用起来不通」的地方:
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 渲染逐字节一致。
114 lines
3.9 KiB
PHP
114 lines
3.9 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controllers;
|
||
|
||
use App\Access;
|
||
use App\Attachments;
|
||
use App\Auth;
|
||
use App\Settings;
|
||
use App\Storage;
|
||
use App\Uploads;
|
||
|
||
class AttachmentController
|
||
{
|
||
public function node(): void
|
||
{
|
||
$this->requireLogin();
|
||
$nodeId = (int)($_GET['id'] ?? 0);
|
||
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
|
||
|
||
view('nodes/attachments', [
|
||
'node' => $node,
|
||
'attachments' => Attachments::forTarget(Attachments::TARGET_NODE, $nodeId),
|
||
'maxMb' => (int)Settings::get('photo_max_mb', '12'),
|
||
'driverLabel' => Storage::label(),
|
||
'pageTitle' => '节点照片:' . ($node['title'] ?: '无标题'),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 上传附件。
|
||
*
|
||
* 一个请求里同时收两类东西:
|
||
* 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);
|
||
|
||
$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']);
|
||
$count = $crop['count'];
|
||
|
||
if ($count === 0 && $uploaded === 0) {
|
||
flash('error', '没有选择任何文件');
|
||
redirect('attachments/node', ['id' => $nodeId]);
|
||
}
|
||
|
||
$msg = "成功上传 {$uploaded} 个文件(存到" . Storage::label() . ')';
|
||
if (!empty($errors)) {
|
||
$msg .= ';失败 ' . count($errors) . ' 个:' . implode(';', array_slice($errors, 0, 5));
|
||
}
|
||
if (Storage::ossMisconfigured()) {
|
||
$msg .= '|注意:已选择 OSS 但配置不完整,本次实际存到了本站服务器';
|
||
}
|
||
|
||
flash('error', $msg);
|
||
redirect('attachments/node', ['id' => $nodeId], 'photos');
|
||
}
|
||
|
||
public function destroy(): void
|
||
{
|
||
$this->requireLogin();
|
||
csrf_check();
|
||
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$att = Attachments::find($id);
|
||
if (!$att || $att['target_type'] !== Attachments::TARGET_NODE) {
|
||
flash('error', '附件不存在');
|
||
redirect('activities/index');
|
||
}
|
||
|
||
$node = Access::node((int)$att['target_id']); // 顺带做归属校验
|
||
Attachments::delete($id);
|
||
|
||
flash('error', '照片已删除');
|
||
redirect('attachments/node', ['id' => (int)$node['id']], 'photos');
|
||
}
|
||
|
||
private function requireLogin(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
redirect('auth/login');
|
||
}
|
||
}
|
||
}
|