Files
TripLog/templates/layout.php
T
laoyang 86e0481a19 节点表单:24 小时制时间控件、附件可裁剪、保存失败不丢已填内容
1) 时间选择不再出现「上午/下午」
   datetime-local 的显示格式由浏览器 UI 语言决定(中文环境渲染成
   「下午 03:30」),页面与元素的 lang 都压不住(截图实测)。
   拆成「原生 date 控件 + 时下拉(00-23) + 分下拉(00-59)」组合控件
   (templates/_datetime.php),24 小时制由控件本身决定,浏览器改不掉。
   服务端 read_dt_input() 把三段拼回 Y-m-d H:i:s 并校验,兼容旧单字段。
   节点起止时间与成员签到时间一并换掉。

2) 附件可裁剪,不想裁点「确定」就按原图上传
   裁剪弹窗抽成 templates/_crop_modal.php 与照片页共用;表单里选完图
   自动逐张进入裁剪,整图选框=没裁(保留原文件、不重新编码、省 base64
   流量),裁过的走 cropped 通道(隐藏域 JSON,原生表单提交也能带),
   没裁的留在 input.files,已裁卡片带角标,可随时重置重来。

3) 节点保存失败不再清空整页
   服务端:失败分支 flash_old($_POST),重开的表单按 old()/old_array()/
   old_dt() 全字段回填(含勾选删除状态),渲染完即清(layout.php)。
   前端:sessionStorage 草稿兜底服务端拿不到 $_POST 的情况
   (post_max_size 超限、会话过期),恢复条件收窄到「页面有错误提示条」,
   避免陈旧草稿污染新表单。

已验证:本地 php -l 全量 0 错、模板渲染 275 项、表单交互(真实浏览器)
73 项——含真实裁剪一刀、草稿恢复、三段式时间选择;远端 e2e 289 项
0 失败(存储设置测试期间归一为 local,不再写用户真实 OSS 桶,收尾还原);
部署后双 PHP 渲染逐字节一致、59 文件哈希校验一致。
2026-09-23 09:11:42 +08:00

164 lines
7.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
/* 上一次提交的原始字段(flash_old() 存的)只服务这一次渲染,用完立刻清掉。
不能不清:不清的话「上次失败时填的内容」会一直留在 session 里,
用户下次随便开一个别的表单就会被莫名回填。 */
unset($_SESSION['_old']);
?>
<?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>