1] 表示按下附件下载
*/
function media_url(?string $key, array $extra = []): string
{
$key = trim((string)$key);
if ($key === '') {
return asset('/assets/avatar-default.svg');
}
// 已经是绝对地址(历史数据/外部链接)→ 不代理,原样用
if (preg_match('#^https?://#i', $key)) {
return $key;
}
return 'media.php?' . http_build_query(array_merge(['k' => ltrim($key, '/')], $extra));
}
/**
* 读「日期 + 24 小时制时:分:秒」组合控件(templates/_datetime.php 产出)。
*
* 为什么不用 :它的显示格式由**浏览器 UI 语言 /
* 系统时间格式**决定, 压不过它 —— 中文环境会渲染成「下午 03:30」,
* 和"精确到时分秒"的要求对不上,用户还会把上午下午填反。
* 拆成「原生日期选择器 + 时/分/秒三个下拉」后,24 小时制由控件本身保证。
*
* 只选了时间没选日期 → 视为没填(与 TripLog 的行为一致)。
*/
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'] ?? ''));
$sRaw = trim((string)($_POST[$name . '_second'] ?? ''));
// 兼容一次性传整串的老表单(如 CSV 导入、内部调用)
if ($date === '' && $hRaw === '' && $mRaw === '') {
return \App\Birthday::toDateTime((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;
$i = $mRaw === '' ? 0 : (int)$mRaw;
$s = $sRaw === '' ? 0 : (int)$sRaw;
if ($h < 0 || $h > 23 || $i < 0 || $i > 59 || $s < 0 || $s > 59) {
return null;
}
return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s);
}
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写);$anchor 是页面内锚点(不带 #) */
function url(string $route = 'home', array $query = [], ?string $anchor = null): string
{
$q = array_merge(['r' => $route], $query);
return 'index.php?' . http_build_query($q) . ($anchor !== null && $anchor !== '' ? '#' . rawurlencode($anchor) : '');
}
function redirect(string $route = 'home', array $query = [], ?string $anchor = null): void
{
header('Location: ' . url($route, $query, $anchor));
exit;
}
function is_post(): bool
{
return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
}
/**
* 读配置。
*
* ⚠️ `?string $key = null` 里的 `?` 不能省:PHP 8.4 起,
* 「有类型 + 默认 null」而没写 `?` 的形式(隐式可空)会报 Deprecated。
* 这个警告一旦在页面顶部打出来,就等于响应体已经输出 ——
* 后面所有 header() 全部失效(典型症状:安装完/登录后**不再跳转**,
* 返回 200 而不是 302,但功能其实是好的,极难排查)。
* 线上跑 PHP 7.4/8.1 时不报,所以本地用新 PHP 才抓得到。
*/
function fb_config(?string $key = null, $default = null)
{
$c = $GLOBALS['config'];
if ($key === null) {
return $c;
}
return array_key_exists($key, $c) ? $c[$key] : $default;
}
// ---------------- CSRF ----------------
function csrf_token(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
}
function csrf_field(): string
{
return '';
}
/**
* 校验 CSRF。失败直接结束请求 —— 不做这种假返回:
* 「返回 false 让调用层自己处理」的写法,迟早有人忘了判断。
*/
function csrf_check(): void
{
$ok = isset($_POST['_csrf'], $_SESSION['_csrf'])
&& hash_equals($_SESSION['_csrf'], (string)$_POST['_csrf']);
if (!$ok) {
http_response_code(419);
echo '页面已过期,请刷新后重试(CSRF 校验失败)';
exit;
}
}
// ---------------- 一次性提示 ----------------
function flash(string $type, string $message): void
{
$_SESSION['_flash'][] = ['type' => $type, 'message' => $message];
}
function take_flash(): array
{
$f = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $f;
}
/** 表单回填(校验失败时保留用户输入) */
function old(string $key, $default = '')
{
return $_SESSION['_old'][$key] ?? $default;
}
function set_old(array $data): void
{
$_SESSION['_old'] = $data;
}
function clear_old(): void
{
unset($_SESSION['_old']);
}
/**
* 生日相关:年龄与距下次生日天数。
*
* ⚠️ 输入可能是 DATETIME('1990-05-12 14:30:00')—— 一律先截到日期再算,
* 否则 'm-d' 之类会带上时间,闰年/跨年边界容易算歪。
* 真正的"下一次生日"请用 App\Birthday::nextOccurrences():
* 它会分别处理身份证/阳历/农历三个生日,这里只是给页面用的简版。
*/
function age_from(?string $birthday): ?int
{
$d = \App\Birthday::dateOnly($birthday);
if ($d === '') {
return null;
}
try {
$b = new DateTimeImmutable($d);
} catch (Exception $ex) {
return null;
}
return (int)$b->diff(new DateTimeImmutable('today'))->y;
}
function days_until_birthday(?string $birthday): ?int
{
$d = \App\Birthday::dateOnly($birthday);
if ($d === '') {
return null;
}
try {
$b = new DateTimeImmutable($d);
$now = new DateTimeImmutable('today');
$next = new DateTimeImmutable($now->format('Y') . '-' . $b->format('m-d'));
if ($next < $now) {
$next = $next->modify('+1 year');
}
return (int)$now->diff($next)->days;
} catch (Exception $ex) {
return null;
}
}