2026-09-30 00:49:53 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 全局辅助函数(由 bootstrap.php 引入,页面无需重复 require)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
/** HTML 转义:所有输出到页面的动态值都必须过一道 */
|
|
|
|
|
|
function e($value): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 17:41:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「多久之前」—— 通知列表用(v30 第 5 条)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 只做**粗粒度**(刚刚 / N 分钟前 / N 小时前 / 昨天 / N 天前 / 月-日),
|
|
|
|
|
|
* 不引 Carbon、也不做"1 个月前"这种会随月长变化的措辞 ——
|
|
|
|
|
|
* 通知的时间只是让人一眼看出"新不新",精确到秒反而不好读。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function fb_time_ago(?string $dt): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$s = trim((string)$dt);
|
|
|
|
|
|
if ($s === '') {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$t = strtotime($s);
|
|
|
|
|
|
if ($t === false) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$d = time() - $t;
|
|
|
|
|
|
if ($d < 0) {
|
|
|
|
|
|
return date('n 月 j 日', $t); // 时间在未来(服务器时钟偏差)→ 直接给日期
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($d < 60) {
|
|
|
|
|
|
return '刚刚';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($d < 3600) {
|
|
|
|
|
|
return intdiv($d, 60) . ' 分钟前';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($d < 86400) {
|
|
|
|
|
|
return intdiv($d, 3600) . ' 小时前';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($d < 172800) {
|
|
|
|
|
|
return '昨天';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($d < 86400 * 30) {
|
|
|
|
|
|
return intdiv($d, 86400) . ' 天前';
|
|
|
|
|
|
}
|
|
|
|
|
|
return date('n 月 j 日', $t);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
/** 静态资源地址,带版本号(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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 附件(头像等)地址 —— 全站唯一出口。
|
2026-09-30 09:44:55 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 现在**一律指向 media.php 代理**,不再直接把 uploads/ 或 OSS 的地址吐给浏览器:
|
|
|
|
|
|
* · 统一:本地/云端的形态差别被代理吃掉,前端只认一个 media.php?k=…
|
|
|
|
|
|
* · 安全:桶不必开公共读;uploads/ 可以直接封死不让 Web 访问,
|
|
|
|
|
|
* 附件是否可读由**登录态**决定,而不是"知道文件名就能看"。
|
|
|
|
|
|
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给,而不是靠存储桶配得对不对。
|
2026-09-30 08:13:07 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function storage_url(?string $key): string
|
|
|
|
|
|
{
|
2026-09-30 09:44:55 +08:00
|
|
|
|
return media_url($key);
|
2026-09-30 08:13:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 头像地址(无头像自动回退内置剪影) */
|
|
|
|
|
|
function avatar_url(?string $key): string
|
|
|
|
|
|
{
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$key = trim((string)$key);
|
|
|
|
|
|
if ($key === '') {
|
|
|
|
|
|
return asset('/assets/avatar-default.svg');
|
|
|
|
|
|
}
|
|
|
|
|
|
return media_url($key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 附件代理地址。
|
|
|
|
|
|
* @param array $extra 额外参数,如 ['dl' => 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ---------------- 人:怎么称呼、什么颜色 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 页面上「这个人叫什么」的**唯一出口**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户 2026-10-01 第 3 条:凡是出现人员的地方,默认显示「我」对他的昵称/称呼,
|
|
|
|
|
|
* 不要直接冒出姓名。所以这里先看 nicknames 里的 my_nickname,没有才退回姓名。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $p 至少要有 name;有 my_nickname 就优先用;`shown` 是某些查询
|
|
|
|
|
|
* 已经算好的显示名(如观影表单的已选人),也认。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function person_name(array $p): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$nick = trim((string)($p['my_nickname'] ?? ''));
|
|
|
|
|
|
if ($nick !== '') {
|
|
|
|
|
|
return $nick;
|
|
|
|
|
|
}
|
|
|
|
|
|
$shown = trim((string)($p['shown'] ?? ''));
|
|
|
|
|
|
if ($shown !== '') {
|
|
|
|
|
|
return $shown;
|
|
|
|
|
|
}
|
|
|
|
|
|
return (string)($p['name'] ?? '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 这个人的底色(浅色底)与字色(深色字)—— 都由他自己的主色推出来。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 主色来自头像(上传时算好存 persons.avatar_color),没头像或头像是灰片时
|
|
|
|
|
|
* 由姓名稳定地推一个。页面上一律走这两个函数,不要再自己写 '#eee'。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function person_bg(array $p): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return \App\AvatarColor::bg(\App\AvatarColor::for($p));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function person_fg(array $p): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return \App\AvatarColor::fg(\App\AvatarColor::for($p));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function person_bd(array $p): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return \App\AvatarColor::bd(\App\AvatarColor::for($p));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 一个人的彩色标签(头像 + 称呼 + 他自己的底色)—— 全站唯一画法。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ `$href` 为空时输出 `<span>`:瀑布流里整张卡片已经是 `<a>` 了,
|
|
|
|
|
|
* `<a>` 套 `<a>` 是非法 HTML,浏览器会把外层链接切开(卡片点不动)。
|
|
|
|
|
|
* 所以列表/瀑布一律传空,只有详情页那种独立标签才传链接。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function person_chip(array $p, string $href = '', string $extraClass = ''): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$name = person_name($p);
|
|
|
|
|
|
$style = 'background:' . person_bg($p) . ';color:' . person_fg($p)
|
|
|
|
|
|
. ';border-color:' . person_bd($p) . ';';
|
|
|
|
|
|
if (!empty($p['avatar'])) {
|
|
|
|
|
|
$av = '<img class="chip-av" src="' . e(media_url((string)$p['avatar'])) . '" alt="">';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$av = '<span class="chip-av chip-av-text">'
|
|
|
|
|
|
. e(mb_substr($name, 0, 1, 'UTF-8')) . '</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
$cls = 'viewer-tag' . ($extraClass !== '' ? ' ' . $extraClass : '');
|
|
|
|
|
|
$inner = $av . '<span class="chip-name">' . e($name) . '</span>';
|
|
|
|
|
|
if ($href !== '') {
|
|
|
|
|
|
return '<a class="' . e($cls) . '" style="' . e($style) . '" href="' . e($href) . '">'
|
|
|
|
|
|
. $inner . '</a>';
|
|
|
|
|
|
}
|
|
|
|
|
|
return '<span class="' . e($cls) . '" style="' . e($style) . '">' . $inner . '</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 09:44:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 读「日期 + 24 小时制时:分:秒」组合控件(templates/_datetime.php 产出)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么不用 <input type="datetime-local">:它的显示格式由**浏览器 UI 语言 /
|
|
|
|
|
|
* 系统时间格式**决定,<html lang> 压不过它 —— 中文环境会渲染成「下午 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);
|
2026-09-30 08:13:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写);$anchor 是页面内锚点(不带 #) */
|
|
|
|
|
|
function url(string $route = 'home', array $query = [], ?string $anchor = null): string
|
2026-09-30 00:49:53 +08:00
|
|
|
|
{
|
|
|
|
|
|
$q = array_merge(['r' => $route], $query);
|
2026-10-01 00:12:04 +08:00
|
|
|
|
return 'index.php?' . http_build_query($q) . ($anchor !== null && $anchor !== '' ? '#' . rawurlencode($anchor) : '');
|
2026-09-30 00:49:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
function redirect(string $route = 'home', array $query = [], ?string $anchor = null): void
|
2026-09-30 00:49:53 +08:00
|
|
|
|
{
|
2026-10-01 00:12:04 +08:00
|
|
|
|
header('Location: ' . url($route, $query, $anchor));
|
2026-09-30 00:49:53 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function is_post(): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 读配置。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ `?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)
|
2026-09-30 00:49:53 +08:00
|
|
|
|
{
|
|
|
|
|
|
$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']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 09:44:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 生日相关:年龄与距下次生日天数。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 输入可能是 DATETIME('1990-05-12 14:30:00')—— 一律先截到日期再算,
|
|
|
|
|
|
* 否则 'm-d' 之类会带上时间,闰年/跨年边界容易算歪。
|
|
|
|
|
|
* 真正的"下一次生日"请用 App\Birthday::nextOccurrences():
|
|
|
|
|
|
* 它会分别处理身份证/阳历/农历三个生日,这里只是给页面用的简版。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function age_from(?string $birthday): ?int
|
2026-09-30 00:49:53 +08:00
|
|
|
|
{
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$d = \App\Birthday::dateOnly($birthday);
|
|
|
|
|
|
if ($d === '') {
|
2026-09-30 00:49:53 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$b = new DateTimeImmutable($d);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
} catch (Exception $ex) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return (int)$b->diff(new DateTimeImmutable('today'))->y;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 09:44:55 +08:00
|
|
|
|
function days_until_birthday(?string $birthday): ?int
|
2026-09-30 00:49:53 +08:00
|
|
|
|
{
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$d = \App\Birthday::dateOnly($birthday);
|
|
|
|
|
|
if ($d === '') {
|
2026-09-30 00:49:53 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$b = new DateTimeImmutable($d);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|