Files
TripLog/src/helpers.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

313 lines
11 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
declare(strict_types=1);
function e($value): string
{
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
}
/**
* 读取 config.php(每个请求只读一次)
* 页面里的第三方 Key 一律从这里取,避免同一个值散落在多个文件里
*/
function app_config(): array
{
static $config = null;
if ($config === null) {
$config = require dirname(__DIR__) . '/config.php';
}
return $config;
}
function url(string $route, array $params = []): string
{
$params = array_merge(['r' => $route], $params);
return '/index.php?' . http_build_query($params);
}
/**
* 静态资源地址 + 内容版本号(cache busting)。
*
* 为什么必须有:CSS/JS 的响应没有 Cache-Control/Expires,浏览器会退回到自己的
* 「启发式缓存」——在它自行估算的新鲜期内**根本不发请求**。文件换了它也不知道,
* 因为地址一直没变。表现就是「服务端明明更新了,用户看到的还是旧样式」。
* 拼上随文件变化的版本号后,文件一改地址就变,浏览器只能重新拉。
*
* 版本号用 mtime + 文件大小:只用 mtime 时,同一秒内改两次会漏;
* 补上大小能挡住绝大多数「内容变了但 mtime 没变」的情况。
* 取不到文件信息就不加版本号,退化成原来的行为 —— 不影响可用性。
*/
function asset(string $path): string
{
$path = '/' . ltrim($path, '/');
$file = dirname(__DIR__) . $path;
$mtime = @filemtime($file);
if ($mtime === false) {
return $path;
}
return $path . '?v=' . $mtime . '-' . (int)@filesize($file);
}
function redirect(string $route, array $params = [], string $anchor = ''): void
{
$location = url($route, $params);
if ($anchor !== '') {
$location .= '#' . ltrim($anchor, '#');
}
header('Location: ' . $location);
exit;
}
function view(string $template, array $data = []): void
{
extract($data, EXTR_SKIP);
$basePath = dirname(__DIR__);
$content = $basePath . '/templates/' . $template . '.php';
if (!file_exists($content)) {
throw new \RuntimeException("模板不存在: $template");
}
require $basePath . '/templates/layout.php';
}
function flash(string $key, ?string $value = null): ?string
{
if ($value !== null) {
$_SESSION['_flash'][$key] = $value;
return null;
}
$v = $_SESSION['_flash'][$key] ?? null;
unset($_SESSION['_flash'][$key]);
return $v;
}
function csrf_token(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
}
/**
* 把这次提交的原始字段存进 session,供紧接着的那次渲染回填。
*
* 为什么需要:控制器校验失败时只能 redirect(POST/Redirect/GET),一重定向表单就被重新渲染成
* 空的了——用户填了半天的内容全没了。把 $_POST 原样留着,模板用 old() 取回来即可。
*
* 生命周期是「一次渲染」:layout.php 渲染完正文就 unset(见那里的注释),
* 所以它不会跟着用户跑到别的页面,也不会把上一次的残留混进下一次新建。
*/
function flash_old(array $data): void
{
$_SESSION['_old'] = $data;
}
/** 回填值:读取上一次提交的同名字段;没有就给 $default。数组字段(如 xxx[])不参与回填 */
function old(string $key, ?string $default = null): ?string
{
$v = $_SESSION['_old'][$key] ?? null;
if ($v === null || is_array($v)) {
return $default;
}
return (string)$v;
}
/** 回填「多选」字段(如 delete_attachments[])——用于恢复勾选状态 */
function old_array(string $key): array
{
$v = $_SESSION['_old'][$key] ?? null;
if (!is_array($v)) {
return [];
}
return array_map(static fn($x) => (string)$x, $v);
}
/**
* 回填「日期 + 时:分」组合控件(见 templates/_datetime.php)。
*
* 三个子字段要合并回一个 "Y-m-d H:i:s" 才好喂给模板里的拆分逻辑;
* 只要日期存在就认为用户在填(时分缺省按 00 补)。
*/
function old_dt(string $name, ?string $fallback): string
{
$date = old($name . '_date');
if ($date === null || trim($date) === '') {
return (string)($fallback ?? '');
}
$h = old($name . '_hour', '00');
$m = old($name . '_minute', '00');
return trim($date) . ' ' . str_pad((string)$h, 2, '0', STR_PAD_LEFT)
. ':' . str_pad((string)$m, 2, '0', STR_PAD_LEFT) . ':00';
}
/** 把各种写法的时间串统一成 MySQL 认的 "Y-m-d H:i:s";认不出来返回 null */
function normalize_dt_string(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
// datetime-local 传上来是 2026-09-12T10:30
$value = str_replace('T', ' ', $value);
$ts = strtotime($value);
if ($ts === false) {
return null;
}
return date('Y-m-d H:i:s', $ts);
}
/**
* 读取「日期 + 24 小时制时/分」三个控件拼成的时间。
*
* ⚠️ 为什么不用 <input type="datetime-local">:它的**显示格式由浏览器 UI 语言 /
* 系统时间格式决定**,页面和元素的 lang 属性完全无效。实测(chromium 1243):
* --lang=zh-CN → 「2026/09/23 下午 03:30」(12 小时制)
* --lang=en-GB → 「23/09/2026 15:30」(24 小时制)
* 连 <html lang="zh-CN"> + 元素 lang="en-GB" 都压不过它,所以只能换成
* 「原生日期选择器 + 时/分两个下拉」,24 小时制是控件本身决定的,不可能被浏览器改掉。
*
* 兼容旧的单个字段名(值形如 2026-09-23T15:30)——旧书签、接口测试可能还在这么传。
*/
function read_dt_input(string $name): ?string
{
$date = trim((string)($_POST[$name . '_date'] ?? ''));
$hRaw = trim((string)($_POST[$name . '_hour'] ?? ''));
$mRaw = trim((string)($_POST[$name . '_minute'] ?? ''));
if ($date === '' && $hRaw === '' && $mRaw === '') {
return normalize_dt_string((string)($_POST[$name] ?? ''));
}
if ($date === '') {
return null; // 只选了时分、没选日期 → 视为没填
}
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $mt)) {
return null;
}
if (!checkdate((int)$mt[2], (int)$mt[3], (int)$mt[1])) {
return null;
}
$h = $hRaw === '' ? 0 : (int)$hRaw;
$m = $mRaw === '' ? 0 : (int)$mRaw;
if ($h < 0 || $h > 23 || $m < 0 || $m > 59) {
return null;
}
return sprintf('%s %02d:%02d:00', $date, $h, $m);
}
/**
* 校验 CSRF。两个通道都收:
* 表单 → 隐藏字段 _csrf
* XHR → 请求头 X-CSRF-Token(自定义头跨站加不上,同样安全,且不必把 token 塞进 body)
*/
function csrf_check(): void
{
$token = $_POST['_csrf'] ?? '';
if ($token === '') {
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
}
if (!hash_equals($_SESSION['_csrf'] ?? '', (string)$token)) {
http_response_code(419);
exit('CSRF token 校验失败');
}
}
/** 把 php.ini 里的 "8M" / "512K" 这类值换算成字节 */
function ini_bytes(string $value): int
{
$value = trim($value);
if ($value === '' || $value === '-1') {
return 0; // 不限
}
$unit = strtolower(substr($value, -1));
$num = (int)$value;
switch ($unit) {
case 'g': return $num * 1024 * 1024 * 1024;
case 'm': return $num * 1024 * 1024;
case 'k': return $num * 1024;
default: return $num;
}
}
function json_decode_field($json, $default = [])
{
if (empty($json)) return $default;
if (is_array($json)) return $json;
$decoded = json_decode($json, true);
return $decoded ?? $default;
}
/**
* 附件的查看地址 —— 走 media/show 代理,页面里**永远不出现存储后端的真实地址**
* (OSS 桶是私有的,直链既取不到、也不该暴露给浏览器)。
*/
function media_att(int $attachmentId, bool $download = false): string
{
return url('media/show', $download ? ['id' => $attachmentId, 'dl' => 1] : ['id' => $attachmentId]);
}
/** 活动封面地址(同样走代理) */
function media_cover(int $activityId): string
{
return url('media/cover', ['id' => $activityId]);
}
/**
* 头像地址。头像是本站本地文件,但仍做一次前缀校验,
* 避免历史数据里塞了外部地址把图片挂到第三方去。
*/
function media_avatar(?string $url): string
{
$url = trim((string)$url);
if ($url === '' || strpos($url, '/uploads/avatars/') !== 0 || strpos($url, '..') !== false) {
return '';
}
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
{
$bytes = (int)$bytes;
if ($bytes <= 0) return '';
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . ' KB';
return round($bytes / 1024 / 1024, 2) . ' MB';
}