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` 为空时输出 ``:瀑布流里整张卡片已经是 `` 了, * `` 套 `` 是非法 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 = ''; } else { $av = '' . e(mb_substr($name, 0, 1, 'UTF-8')) . ''; } $cls = 'viewer-tag' . ($extraClass !== '' ? ' ' . $extraClass : ''); $inner = $av . '' . e($name) . ''; if ($href !== '') { return '' . $inner . ''; } return '' . $inner . ''; } /** * 读「日期 + 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; } } /** * 🔴 v43(用户 2026-10-11 第 6 条)—— 把一条日历/任务条目编成**详情浮层**能直接渲染的 JSON。 * * 用户原话: * 「首页上的日程、首页上的近期任务,以及日程里面的日程,点击它们的时候, * 都会弹出一个框显示更详细的信息。…如果是观影啊、旅程啊,点击详细信息就会 * 进入到观影或者旅程的那个相关的页面。如果是日程的话,点击详细信息就进到 * 日程的详细页面。当然还可以直接点击编辑,点击编辑不同的来源就进入不同的编辑页面, * 就在当前的页面弹窗…」 * * ⇒ 每种来源由这里给出**三样**: * `detail` 「详细信息」跳哪(该来源的详情页) * `edit` 「编辑」取哪份编辑表单片段(`?embed=1` 的那些路由) * `rows` 浮层正文那几行「标签:值」 * * ⚠️ 模板里只调这一个函数,不要在各模板里各拼一份 JSON —— * "三个入口三套写法"必然有一天漏改一个(用户第 6 条点名了三处入口)。 * ⚠️ 返回**已经 json_encode 好的字符串**,直接写进 `data-fb-entry='…'`; * 模板里用单引号属性包住(JSON 里的双引号不会撞)。 * 用 `JSON_HEX_APOS | JSON_HEX_QUOT` 把引号也转成实体,双保险。 * * @param array $ev 日历条目(Calendar::month 的形状:kind/ref_id/title/date/time/…) * @param string $detail 详情页地址(没有就不显「详细信息」) * @param string $edit 编辑表单片段地址(没有就不显「编辑」) * @param array $extra 额外要显示的行 [[标签, 值], …] * @return string 可直接放进 data-fb-entry 的 JSON 字符串(已转义) */ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array $extra = []): string { $date = (string)($ev['date'] ?? ''); $time = (string)($ev['time'] ?? ''); $et = (string)($ev['end_time'] ?? ''); if ($date !== '') { $when = $date; if (!empty($ev['all_day'])) { $when .= ' 全天'; } elseif ($time !== '') { $when .= ' ' . $time . ($et !== '' ? '–' . $et : ''); } } else { $when = ''; } $rows = []; if ($when !== '') { $rows[] = ['时间', $when]; } if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; } if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; } if ((string)($ev['status_word'] ?? '') !== '') { $rows[] = ['状态', (string)$ev['status_word']]; } if ((string)($ev['note'] ?? '') !== '') { $rows[] = ['备注', (string)$ev['note']]; } // ⚠️ 扩展行排在"备注"之后、作为补充(调用方按需给,比如任务给「所属看板」) foreach ($extra as $row) { if (is_array($row) && count($row) >= 2 && (string)$row[1] !== '') { $rows[] = [(string)$row[0], (string)$row[1]]; } } $payload = [ 'title' => trim((string)($ev['title'] ?? '')), 'rows' => $rows, 'detail' => $detail, 'edit' => $edit, ]; return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT); }