三件事都是「界面上明明该有、但用起来不通」的地方:
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 渲染逐字节一致。
158 lines
7.0 KiB
PHP
158 lines
7.0 KiB
PHP
<?php
|
||
$__isLoggedIn = \App\Auth::check();
|
||
$__user = $__isLoggedIn ? \App\Auth::user() : null;
|
||
$__isAdmin = $__isLoggedIn && \App\Auth::isAdmin();
|
||
$__name = (string)($__user['name'] ?? ($_SESSION['user_name'] ?? ''));
|
||
// 用 media_avatar() 解析后再判断,不能拿原始值判断:
|
||
// 外站地址(https://…)会被 media_avatar() 拒掉返回空串,若还按非空处理,
|
||
// 就会渲染出 <img src=""> —— 一个破图占位。
|
||
$__avatar = media_avatar((string)($__user['avatar'] ?? ''));
|
||
$__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·';
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<title><?= e($pageTitle ?? '家庭出行记录') ?></title>
|
||
<?php /* 静态资源一律走 asset():不带版本号的话,浏览器会一直用缓存里的旧 CSS */ ?>
|
||
<link rel="stylesheet" href="<?= e(asset('assets/style.css')) ?>">
|
||
<link rel="stylesheet" href="<?= e(asset('assets/vendor/cropper/cropper.min.css')) ?>">
|
||
<script src="<?= e(asset('assets/vendor/cropper/cropper.min.js')) ?>"></script>
|
||
<script>
|
||
window._AMapSecurityConfig = {
|
||
securityJsCode: '<?= e(app_config()['amap']['security_js'] ?? '') ?>',
|
||
};
|
||
window.__AMAP_LOADED = false;
|
||
window.__AMAP_ERROR = null;
|
||
</script>
|
||
<script src="https://webapi.amap.com/maps?v=2.0&key=<?= e(app_config()['amap']['key'] ?? '') ?>&plugin=AMap.AutoComplete"
|
||
onload="window.__AMAP_LOADED = true;"
|
||
onerror="window.__AMAP_ERROR = 'script load failed';">
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<?php if ($__isLoggedIn): ?>
|
||
<header class="topbar">
|
||
<a href="<?= url('activities/index') ?>" class="brand">
|
||
<span class="brand-mark">🧭</span>
|
||
<span class="brand-text">家庭出行记录</span>
|
||
</a>
|
||
|
||
<div class="topbar-right">
|
||
<a href="<?= url('activities/create') ?>" class="btn-primary btn-sm topbar-new">+ 新建活动</a>
|
||
|
||
<?php /* 原生 details 做下拉:不依赖 JS,键盘可用,点外部失焦由 JS 兜底 */ ?>
|
||
<details class="menu" id="user-menu">
|
||
<summary class="menu-trigger" aria-label="打开菜单">
|
||
<?php if ($__avatar !== ''): ?>
|
||
<img class="avatar-xs" src="<?= e($__avatar) ?>" alt="">
|
||
<?php else: ?>
|
||
<span class="avatar-xs avatar-initial"><?= e($__initial) ?></span>
|
||
<?php endif; ?>
|
||
<span class="menu-name"><?= e($__name) ?></span>
|
||
<span class="menu-caret" aria-hidden="true">▾</span>
|
||
</summary>
|
||
|
||
<div class="menu-panel">
|
||
<div class="menu-head">
|
||
<div class="menu-head-name"><?= e($__name) ?></div>
|
||
<div class="menu-head-role"><?= $__isAdmin ? '管理员' : '家庭成员' ?></div>
|
||
</div>
|
||
|
||
<a class="menu-item" href="<?= url('activities/index') ?>">
|
||
<span class="menu-icon">📋</span> 活动列表
|
||
</a>
|
||
<a class="menu-item" href="<?= url('activities/create') ?>">
|
||
<span class="menu-icon">➕</span> 新建活动
|
||
</a>
|
||
<?php if (\App\Auth::id()): ?>
|
||
<a class="menu-item" href="<?= url('users/edit', ['id' => \App\Auth::id()]) ?>">
|
||
<span class="menu-icon">🙋</span> 我的资料
|
||
</a>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($__isAdmin): ?>
|
||
<div class="menu-sep">管理</div>
|
||
<a class="menu-item" href="<?= url('travel_modes/index') ?>">
|
||
<span class="menu-icon">🚄</span> 出行方式
|
||
</a>
|
||
<a class="menu-item" href="<?= url('vehicles/index') ?>">
|
||
<span class="menu-icon">🚗</span> 车辆
|
||
</a>
|
||
<a class="menu-item" href="<?= url('node_types/index') ?>">
|
||
<span class="menu-icon">🏷️</span> 节点类型
|
||
</a>
|
||
<a class="menu-item" href="<?= url('role_tags/index') ?>">
|
||
<span class="menu-icon">🎭</span> 角色标签
|
||
</a>
|
||
<a class="menu-item" href="<?= url('users/index') ?>">
|
||
<span class="menu-icon">👥</span> 用户管理
|
||
</a>
|
||
<a class="menu-item" href="<?= url('settings/index') ?>">
|
||
<span class="menu-icon">🗄️</span> 存储设置
|
||
</a>
|
||
<?php endif; ?>
|
||
|
||
<div class="menu-sep"></div>
|
||
<a class="menu-item menu-item-danger" href="<?= url('auth/logout') ?>">
|
||
<span class="menu-icon">🚪</span> 退出登录
|
||
</a>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</header>
|
||
<?php endif; ?>
|
||
|
||
<main class="container<?= $__isLoggedIn ? '' : ' container-guest' ?>">
|
||
<?php if ($err = flash('error')): ?>
|
||
<div class="alert"><?= e($err) ?></div>
|
||
<?php endif; ?>
|
||
<?php require $content; ?>
|
||
</main>
|
||
|
||
<?php if ($__isLoggedIn): ?>
|
||
<footer class="footer">家庭出行记录</footer>
|
||
<script>
|
||
// 原生 details 不会因为点击面板外部而收起,这里补一句(不依赖它也能用)
|
||
document.addEventListener('click', function (ev) {
|
||
var menu = document.getElementById('user-menu');
|
||
if (menu && menu.open && !menu.contains(ev.target)) {
|
||
menu.open = false;
|
||
}
|
||
});
|
||
// Esc 收起
|
||
document.addEventListener('keydown', function (ev) {
|
||
var menu = document.getElementById('user-menu');
|
||
if (ev.key === 'Escape' && menu && menu.open) {
|
||
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>
|
||
</html>
|