Files
FamilyBook/app/helpers.php
T
laoyang 2c44318a51 压缩只留 1600 档;视频缓存失效加版本号 (#56 #57)
一、尺寸档位(用户:「400压缩的太多了。800也不够,只保留一个1600」)
   media.php 白名单从三档收到一档:
     1600 => image/resize,l_1600/quality,q_88
   400/800 删除。质量从 q_85 提到 q_88(只留一档就该偏清楚那侧)。
   照片墙跟随改 1600,缩略图与大图共用一份缓存。
   传旧 ?w=400 的链接会落到「非法值 ⇒ 原图」分支,不报错、只是不压缩。

二、视频仍显示 HEVC(用户:「他说是H.265,还是不让播呢?」)

   实测服务端 15 个视频**全部 h264**,无一个 HEVC:
     ffprobe 逐个查过,codec=h264,pix_fmt=yuv420p/yuvj420p
     media.php 返回 Content-Type: video/mp4,body 里无 hvc1/hev1
     ETag 与实际内容完全一致

   问题在浏览器缓存:
     Cache-Control: private, max-age=604800(7 天)
     浏览器对 <video> 有自己的媒体缓存策略,不完全遵守 ETag
     Range 分段缓存会把每段当独立资源
     src 不变时直接复用已有媒体对象

   修法:让 URL 变。media_url() 加第三个参数 $ver,
   用附件 size 当版本号 → media.php?k=...&v=2295119

   为什么用 size 而不是 mtime/md5:
     mtime —— 对象在 OSS 上,本地拿不到可靠时间
     md5   —— 要读整个文件,几百 MB 视频不可接受
     size  —— 转码前后必然不同(1.22MB→2.19MB),且本就在库里,零开销

   照片墙已接入。其余调用点可逐步铺开。
2026-11-05 13:11:44 +08:00

576 lines
24 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] 表示按下附件下载
*/
/**
* 附件访问地址(走 media.php 代理)。
*
* @param array $extra 额外参数:`w`(压缩档位 1600)、`dl`(下载)等
* @param int $ver 内容版本(通常是附件 size)。
* **变了就让 URL 变**,从而绕过一切缓存。
*
* 🔴 #57(用户:「他说是H.265,还是不让播呢?」):
* 转码后服务端确实是 h264 了(15 个视频全部实测过),但浏览器还在放旧的。
*
* 原因是**媒体缓存**:`Cache-Control: max-age=604800`(7 天),
* 而浏览器对 `<video>` 有自己的一套:
* · 不完全遵守 ETag
* · `Range` 分段缓存会把每段当独立资源
* · `src` 不变时直接复用已有媒体对象
*
* ⇒ 最可靠的做法是**让 URL 变**。URL 一变任何缓存层都不会命中。
*
* ⚠️ 版本号用 `size` 而不是 `mtime`/`md5`:
* · `mtime`:对象在 OSS 上,本地拿不到可靠时间
* · `md5`:要读整个文件,几百 MB 的视频不可接受
* · `size` 够用(转码前后体积必然不同:1.22MB → 2.19MB),
* 而且它本来就在数据库里,零开销。
*/
function media_url(?string $key, array $extra = [], int $ver = 0): string
{
$key = trim((string)$key);
if ($key === '') {
return asset('/assets/avatar-default.svg');
}
// 已经是绝对地址(历史数据/外部链接)→ 不代理,原样用
if (preg_match('#^https?://#i', $key)) {
return $key;
}
$q = array_merge(['k' => ltrim($key, '/')], $extra);
if ($ver > 0) {
$q['v'] = $ver;
}
return 'media.php?' . http_build_query($q);
}
// ---------------- 人:怎么称呼、什么颜色 ----------------
/**
* 页面上「这个人叫什么」的**唯一出口**。
*
* 用户 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;
}
}
/**
* 🔴 v43(用户 2026-10-11 第 6 条)—— 把一条日历/任务条目编成**详情浮层**能直接渲染的 JSON。
*
* 用户原话:
* 「首页上的日程、首页上的近期任务,以及日程里面的日程,点击它们的时候,
* 都会弹出一个框显示更详细的信息。…如果是观影啊、旅程啊,点击详细信息就会
* 进入到观影或者旅程的那个相关的页面。如果是日程的话,点击详细信息就进到
* 日程的详细页面。当然还可以直接点击编辑,点击编辑不同的来源就进入不同的编辑页面,
* 就在当前的页面弹窗…」
*
* ⇒ 每种来源由这里给出**三样**:
* `detail` 「详细信息」跳哪(该来源的详情页)
* `edit` 「编辑」取哪份编辑表单片段(`?embed=1` 的那些路由)
* `rows` 浮层正文那几行「标签:值」
*
* 🔴 v49(用户 2026-10-14 第 2 条)再补三样,见下方 `$payload` 上方的说明:
* `source` / `del` / `info`(+ `repeat` / `occ`)—— 让浮层能按来源分形态。
*
* ⚠️ 模板里只调这一个函数,不要在各模板里各拼一份 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']]; }
/* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。
当然,如果没有地点,这一行就不要显示了。」
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
没有地点时上面那条 `if` 自然不产出 ⇒ 整行不显示,正是用户要的。 */
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]];
}
}
/* 🔴 v49(用户 2026-10-14 第 2 条)——按来源分两种形态。
用户原话:「点击详细的时候,如果这是一个日程,就是正常的日程里面的一个事件,
不是来自于其他的模块…那么就应该直接在当前的页面,让这个模态框变大,
显示出来更多的信息。…如果当前显示的这个日程是来自于其他的模块,比如观影、
旅程…点击详情的时候就进入到这些模块展示它…这种情况下,下面就没有编辑了,
也没有删除,就只有一个详情。」
⇒ 多带四个字段:
`source` 'event'(自定义日程)/ 'derived'(观影·旅程·任务·纪念日·农历·节假日)
`del` 删除用的 POST 地址(**只有 source='event' 且可写**时才给)
`info` 详情片段地址(`calendar/event?embed=detail&id=…`;source='event' 才有)
`repeat` / `occ` 重复日程的删除范围要用("这一次 / 以后 / 全部"靠它定位)
⚠️ 判据是 `kind === 'custom'`(**不是**"有没有 edit"):只有 custom 是
calendar_events 里真属于某本日历的自定义日程;其余全是**从别的模块派生**的,
不该在这里改/删(用户明确要求)。
⚠️ `del`/`edit` 为空 ⇒ 浮层自动不显那两颗按钮
⇒ "只读日历里的自定义日程"天然变成"只有详情",前端不用再判一次。 */
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evOwn = ($evKind === 'custom' && $evRef > 0);
$evW = !empty($ev['_can_write']); // 由调用方按日历权限给出(见模板)
/* 🔴🔴 v93(用户 2026-10-25):「编辑重复日历时,也会有这三个选项
(修改当前 / 修改从现在到以后 / 修改全部)」
—— 浮层里的编辑表单要能给出这三档,就必须知道**打开的是哪一次**:
`calendar/event` 路由从 `?d=YYYY-MM-DD` 读它(`index.php` 里的 `$occ`),
`templates/calendar/event.php` 再据此输出那三档单选。
而浮层的 `edit` 地址原来是 `url('calendar/event', ['id'=>…, 'embed'=>1])` ——
**没带 d** ⇒ 表单里只显示"改全部",另外两档连出现都不出现,
用户会以为"重复日程不能只改这一次"(实测就是这样)。
⚠️ 只在 `source='event'`(自定义日程)且 occ 是合法日期时追加:
观影 / 旅程 / 任务 / 纪念日这些派生条目没有"第几次"的概念。
⚠️ 是否加 `&` 按 `$edit` 里有没有 `?` 判 —— `url()` 生成的一定带,
但别赌(换一处调用方就会静默拼出 `?id=1&embed=1d=2026-10-25` 这种废地址)。 */
$editUrl = $edit;
$occDay = (string)($ev['occ'] ?? '');
if ($evOwn && $editUrl !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay)) {
$editUrl .= (strpos($editUrl, '?') === false ? '?' : '&') . 'd=' . rawurlencode($occDay);
}
$payload = [
'title' => trim((string)($ev['title'] ?? '')),
'rows' => $rows,
'detail' => $detail,
'edit' => $editUrl,
// 新增(v49)
'source' => $evOwn ? 'event' : 'derived',
'ref' => $evOwn ? $evRef : 0, // 日程 id(前端删/改要按它定位,别从 URL 里抠)
'del' => ($evOwn && $evW) ? url('calendar/event_del') : '',
'info' => $evOwn ? url('calendar/event', ['id' => $evRef, 'embed' => 'detail']) : '',
'repeat' => (string)($ev['repeat'] ?? '') !== '',
'occ' => $occDay,
/* 🔴 v78(用户 2026-10-22):**就地面板**的地址。
首页「近期任务」点开时不再只给一行行只读信息,而是直接给出这张
面板(状态 / 看板 / 子任务 / 附件 / 评论 都能就地改),用户不必离开首页。
⚠️ 只有**任务**会给这个字段(`_card_tasks.php` 里传进来);
日程/观影/旅程仍然走原来的"详情 + 编辑/删除"三颗按钮 ——
它们没有"在首页上简单处理一下"的诉求,硬塞一个面板只会让浮层变复杂。 */
'panel' => (string)($ev['panel'] ?? ''),
];
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT);
}
/**
* 这次请求是不是「首页任务面板」发来的(v78)。
*
* 面板里的每一个动作都打**既有路由**,只多带一个 `ajax_panel=1`;
* 服务端据此把"flash + 302 跳转"换成"一段新面板 + 一句人话"。
*
* ⚠️ 判据只认 POST 里那个字段,**不要**顺手用 `X-Requested-With` 代替:
* `tasks/status` 早就用那个头区分"看板上的就地改(只要状态)"和"表单提交",
* 两件事混在一个判据里,改一处必然破坏另一处。
*/
function task_panel_wanted(): bool
{
return (string)($_POST['ajax_panel'] ?? '') === '1';
}
/**
* 把「这一条任务的最新面板」当 JSON 回给前端(v78)。
*
* 为什么**每次都重画整块面板**,而不是让前端自己把那一行改掉:
* · 一个动作的影响面比看起来大 —— 勾一条子任务会改"2/3"这个计数;
* 传一个附件会改附件张数与缩略图墙;挪看板会改上面那排 chip 的选中态。
* 逐处同步(本项目在 v55 的状态下拉上已经手写过一次)是"漏一处就自相矛盾"。
* · 面板很小(一次 Task::find + 一个片段),重画一次比维护 N 处 DOM 同步便宜。
*
* ⚠️ `html` 在失败(ok=false)时**也照样给**:失败的典型原因是"权限不够/内容为空",
* 这正是最需要让用户看到"现在的真实状态"的时候,回一个空壳反而把旧状态留在屏幕上。
*
* @param int $taskId 要重画哪一条(子任务新增时传**父任务**,否则会把用户弹到子任务上)
* @param string $msg 给用户看的一句话(落在浮层底栏左侧)
* @param bool $ok 这次动作成不成功(只影响提示语气与 HTTP 码,不影响 html)
*/
function task_panel_json(int $taskId, string $msg = '', bool $ok = true): void
{
$familyId = (int)\App\Auth::familyId();
$me = (int)\App\Auth::personId();
$t = \App\Task::find($familyId, $taskId, $me);
if (!$t) {
header('Content-Type: application/json; charset=utf-8', true, 404);
echo json_encode(['ok' => false, 'msg' => '找不到这项任务。'], JSON_UNESCAPED_UNICODE);
exit;
}
$people = \App\Person::roster($familyId, $me, 'member', '');
/* @人 高亮的候选名:与 tasks/show.php 同一份口径(真名 + 我的昵称都算),
否则同一句话在详情页高亮、在首页面板不高亮。 */
$names = [];
foreach ($people as $p) {
$names[] = person_name($p);
$nm = trim((string)($p['name'] ?? ''));
if ($nm !== '') {
$names[] = $nm;
}
}
$names = array_values(array_unique(array_filter($names)));
$html = \App\View::renderPartial('tasks/_panel', [
't' => $t,
'people' => $people,
'names' => $names,
'boards' => \App\Task::boards($familyId),
'categories' => \App\Task::categories($familyId),
'canWrite' => \App\Auth::canWrite() && \App\Task::canWrite($t, $me),
'meId' => $me,
]);
header('Content-Type: application/json; charset=utf-8', true, $ok ? 200 : 422);
echo json_encode([
'ok' => $ok,
'msg' => $msg,
'id' => $taskId,
'status' => (string)($t['status'] ?? ''),
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
'html' => $html,
], JSON_UNESCAPED_UNICODE);
exit;
}