$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 小时制时/分」三个控件拼成的时间。 * * ⚠️ 为什么不用 :它的**显示格式由浏览器 UI 语言 / * 系统时间格式决定**,页面和元素的 lang 属性完全无效。实测(chromium 1243): * --lang=zh-CN → 「2026/09/23 下午 03:30」(12 小时制) * --lang=en-GB → 「23/09/2026 15:30」(24 小时制) * 连 + 元素 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 留空时移动端会自动用「当前位置」当起点, * 这正是「导航到这个位置」的语义。 * * src 参数取 `config.php` 的 `amap.nav_src`(仅统计用,默认占位 `trip.example.com`)—— * 部署时按自己的域名改这一处即可,服务端与前端自动一致。 * * 没坐标时退化成关键词搜索 —— 用户在表单里只敲了名字、没从下拉里选中的情况很常见, * 这种节点也给一个能用的链接,总比什么都不显示强。 * * @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'] ?? '')); // 高德 URI API 要求带 src(「调用来源」,只用于统计,任意英数字串都行)。 // 取值统一放 config.php 的 amap.nav_src,与前端 templates/nodes/form.php 里的 // NAV_SRC 同源 —— 想换成自己的域名做统计时,只改配置一处即可。 $src = (string)(app_config()['amap']['nav_src'] ?? 'trip.example.com'); 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=' . rawurlencode($src) . '&coordinate=gaode&callnative=1'; } $keyword = $name !== '' ? $name : trim((string)($loc['address'] ?? '')); if ($keyword === '') { return ''; } return 'https://uri.amap.com/search?keyword=' . rawurlencode($keyword) . '&src=' . rawurlencode($src); } /** 人类可读的文件大小 */ 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'; }