1. 独立页(登录/安装/错误)套上 HTML 骨架 —— 修「首页为什么没有样式」: 新增 templates/layout-bare.php,View::render 对独立页也走完整 <head> + CSS。 2. 以个人为中心:Kinship::label() 现算称呼、Auth::homeTitle() 出「X的家书」; 同一批人在不同登录者眼里自动变成公公婆婆 / 岳父岳母(不存一次性称谓)。 3. 附件存储可配置:Storage 门面(local / 阿里云 OSS),库里只存相对键, 云端失败自动回退本地;设置页可选驱动并填 OSS 参数(照 MovieLog 的方案)。 4. 三种角色:admin / member / guest,访客只读、写操作一律 403; accounts.role 枚举扩容,老库靠 Schema::ensure() 自动升级。 5. 家谱:只存相邻两代的边(parent / spouse),其余称呼全部推导 —— 兄弟姐妹不存边(共同父母 + birth_rank 排行推长子/次女、哥哥/妹妹), 认不清的人用虚拟人物 is_placeholder 占位,关系天然双向。 顺带修掉本地 e2e 挖出的 8 个线上级 bug(性别写死、长幼方向反、关系方向静默写反、 birth_rank 不从表单取回、隐式可空参数致响应头失效、无文件字段被误判为上传失败、 layout 变量撞车覆盖 $me、世代 0 显示成「下 0 代」)。 验证:kinship 单测 37/0 · HTTP e2e 81/0 · 老库升级专测 14/0 · 反向验证 11/11(把每个修复改回旧写法,对应断言都会变红)。
167 lines
4.4 KiB
PHP
167 lines
4.4 KiB
PHP
<?php
|
|
/**
|
|
* 全局辅助函数(由 bootstrap.php 引入,页面无需重复 require)
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** HTML 转义:所有输出到页面的动态值都必须过一道 */
|
|
function e($value): string
|
|
{
|
|
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
/** 静态资源地址,带版本号(mtime + size)——没版本号的话浏览器会一直用旧缓存 */
|
|
function asset(string $rel): string
|
|
{
|
|
$p = dirname(__DIR__) . $rel;
|
|
if (!is_file($p)) {
|
|
return $rel;
|
|
}
|
|
return $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
|
|
}
|
|
|
|
/**
|
|
* 附件(头像等)地址 —— 全站唯一出口。
|
|
* 走这里是纪律:数据库只存相对键,云端/本地的差异由 Storage 自己吸收。
|
|
*/
|
|
function storage_url(?string $key): string
|
|
{
|
|
$fid = \App\Auth::familyId();
|
|
return \App\Storage\Storage::url((int)$fid, $key);
|
|
}
|
|
|
|
/** 头像地址(无头像自动回退内置剪影) */
|
|
function avatar_url(?string $key): string
|
|
{
|
|
$fid = (int)\App\Auth::familyId();
|
|
return \App\Upload::avatarUrl($fid, $key);
|
|
}
|
|
|
|
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */
|
|
function url(string $route = 'home', array $query = []): string
|
|
{
|
|
$q = array_merge(['r' => $route], $query);
|
|
return 'index.php?' . http_build_query($q);
|
|
}
|
|
|
|
function redirect(string $route = 'home', array $query = []): void
|
|
{
|
|
header('Location: ' . url($route, $query));
|
|
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 '<input type="hidden" name="_csrf" value="' . e(csrf_token()) . '">';
|
|
}
|
|
|
|
/**
|
|
* 校验 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']);
|
|
}
|
|
|
|
/** 生日相关:返回年龄与距下次生日天数 */
|
|
function age_from(string $birthday): ?int
|
|
{
|
|
if ($birthday === '' || $birthday === null) {
|
|
return null;
|
|
}
|
|
try {
|
|
$b = new DateTimeImmutable($birthday);
|
|
} catch (Exception $ex) {
|
|
return null;
|
|
}
|
|
return (int)$b->diff(new DateTimeImmutable('today'))->y;
|
|
}
|
|
|
|
function days_until_birthday(string $birthday): ?int
|
|
{
|
|
if (!$birthday) {
|
|
return null;
|
|
}
|
|
try {
|
|
$b = new DateTimeImmutable($birthday);
|
|
$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;
|
|
}
|
|
}
|