Files
FamilyBook/app/helpers.php
T
laoyang 2a10df2557 v30 后半(5 条):导入框选中反馈+单选 / 首页日程改色块 / 近期任务主题色 / 时间冲突错开 / 通知消息模块
用户 2026-10-08 第二批 5 条。

1. 导入日程选中文件后没有任何反应 + 不允许多选
   `_stage_drop.php` 现在有两种模式:暂存型(附件,拖进来即刻上传)与导入型
   (`$sdStage=false`,写回 input.files、随表单提交、PHP 读完即弃)。
   暂存型框靠 queueDrop 画缩略图,而**导入型框选中文件后什么都不显示** ——
   框里那句「点击或拖动进行上传」从头到尾不变,用户看不出"到底选上没有"。
   改:app.js 新增 bindFileEcho(),只给**非暂存框**补 change 监听,
   选中后框里的话换成文件名 + 图标变 ✓ + `.upbox.is-picked` 稳定态;
   取消选择则还原原始文案(不是空字符串)。单选由 `$sdMultiple=false` 保证。

2. 首页「今天的日程」用色块表示,和日程保持一致
   原来那套是"白底卡片 + 左边一条 3px 色线",色只占一条线,看不出是哪个日历本。
   改:与日历页 `.tg-block` 同款实心色块(底色=日历本色,白字,圆角,无阴影),
   left/width/top/height 全部由内联给出(冲突分列 + 真实时长)。
   ⚠️ `.ag-ev` 的 CSS 里去掉 `right` —— 绝对定位下 left+right+width 三者同时存在是
      过约束,内联 width 会失效(冲突就不会错开了)。

3. 首页「近期任务」名称与圆点走任务模块主题色
   原来用 `var(--brand)` —— 那是**当前页面**(首页)的主题色,不是任务模块的;
   用户改过模块配色时两者不一致(首页砖红、任务别的色),正是他看到的"不一致"。
   改:两处都走 `--tk-c`(= Module::color('tasks')),与右上角「全部任务」按钮同源。
   逾期仍用红色(状态语义,不能被主题色盖掉)。

4. 日程时间冲突错开(算法借鉴 SyncCalendar)
   用户指定去读 SyncCalendar 的算法。落地自
   `entry/src/main/ets/common/TimelineUtil.ets` 的 build()/groupsOf():
   ① 划分冲突组(与组内最晚结束仍重叠就并入);② 组内贪心分列(区间图着色),
   列数 = **每组各自的最大同时重叠数**(sync 原注释点名的反例:传递性重叠但
   B/C 不重叠 → 只该 2 列)。落成 `App\Calendar::conflictLanes()`(PHP,判据一字未改)。
   ⚠️ 两处比原算法更细,都有单测钉住:
     · 列数按**组**算 —— 否则一个孤立日程会被别处的三方冲突连累成 1/3 宽;
     · "缺结束时间"不能一律按 30 分钟参与冲突(会与后面所有日程假冲突),
       改为优先拿**同列下一条的起点**当它的视觉结束 → 因此必须**先分列再算高度**。
   首页与日历页共用同一套(用户第 2 条要求"和日程保持一致")。

5. 通知消息模块(Schema v30 → v31)
   不在导航显示,入口在头像下拉里、**排在个人设置之前**,名字「通知消息」;
   有人在观影/旅程加我时发通知;通知出现在首页、**往年今天之前**。
   新增:app/Notification.php + notifications 表(唯一键
   (person_id,target_type,target_id) —— 参与人是整组替换,没有它改三次标题就收三条)+
   ?r=notifications{,/del,/clear} + templates/notifications/index.php + 头像下拉未读角标 +
   app/helpers.php 的 fb_time_ago()。
   ⚠️ 三条设计判断:不进 Module::LIST(进去就会被顶栏渲染、被"模块显示与排序"当开关);
      只在 person_id !== actor_id 时发(不给自己发,"通知"≠流水账);
      移出参与人**撤销未读**、**保留已读**(未读是没送达的提醒,已读是看过的历史)。

验证(全部 0 失败):e2e 1478/0(+14 条新断言)、升级 153/0、vCard 40 项、城市 32/0、
几何 428/0 + 移动 680/0 + 交互 92/0、b24/b27/b28/b29 真机全绿;
冲突分列算法单测 12/0(含 SyncCalendar 点名的反例)、
新探针 _b30_ui2 16/0 + _b30_notify 16/0 + _b30_import 26/0、
反向验证 18/0(五条各自改回旧行为 → 断言全红)、
部署脚本新增 9 条守卫 + 守卫证伪 11/0。线上 11 文件 SHA256 一致,Schema v31 已升级。
2026-10-08 17:41:30 +08:00

350 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
/**
* 全局辅助函数(由 bootstrap.php 引入,页面无需重复 require)
*/
declare(strict_types=1);
/** HTML 转义:所有输出到页面的动态值都必须过一道 */
function e($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
/**
* 「多久之前」—— 通知列表用(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);
}
/** 静态资源地址,带版本号(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);
}
/**
* 附件(头像等)地址 —— 全站唯一出口。
*
* ⚠️ 现在**一律指向 media.php 代理**,不再直接把 uploads/ 或 OSS 的地址吐给浏览器:
* · 统一:本地/云端的形态差别被代理吃掉,前端只认一个 media.php?k=…
* · 安全:桶不必开公共读;uploads/ 可以直接封死不让 Web 访问,
* 附件是否可读由**登录态**决定,而不是"知道文件名就能看"。
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给,而不是靠存储桶配得对不对。
*/
function storage_url(?string $key): string
{
return media_url($key);
}
/** 头像地址(无头像自动回退内置剪影) */
function avatar_url(?string $key): string
{
$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-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>';
}
/**
* 读「日期 + 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);
}
/** 站内链接(统一走 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 '<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']);
}
/**
* 生日相关:年龄与距下次生日天数。
*
* ⚠️ 输入可能是 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;
}
}