Files
FamilyBook/app/helpers.php
T
laoyang bee1d077d8 严重修复:图片压缩参数被应用到了视频上,导致视频播不了 (#56)
用户报(2026-11-05):「你好像把 .mp4 后面也带上了问号,写成了 1600
这样的参数…你刚才没有进行图片压缩的时候,我自己播放好像还是好的。
是你更新了这个功能之后,出了这个问题。」

用户完全正确,这是我的错。

根因:media.php 只看 $imgW,没判是不是图片:
  Storage::read($familyId, $key, $imgW > 0 ? $OssSizes[$imgW] : );
⇒ 视频 URL 带 ?w=1600 后,给 .mp4 对象传了
  x-oss-process=image/resize,l_1600
⇒ OSS 图片处理服务对非图片返回错误内容
⇒ <video> 拿不到有效数据 ⇒ 播不了

三道防线:

1. _attachments.php(直接触发点)
   原来对所有附件都传 w=1600。改为 $attKind === image 才传。
   视频只传 v(版本号,破缓存用)。

2. media.php(核心防线)
   $imgW 与 $isImageKey 绑定,只有图片才允许压缩。
   $isImageKey 判定提前到 Storage::read 之前,
   并扩到扩展名兜底(jpg/png/webp...),
   视频/文档扩展名强制 false。

3. media_url()(前置防线)
   生成 URL 时按扩展名过滤:非图片自动去掉 w。
   这样即使调用方传错,URL 里也不会出现无效参数。

验证(模拟事故场景,URL 带 ?w=1600 请求 .mp4):
  HTTP 206,Content-Type: video/mp4
  无 hvc1,含 avc1
  ffprobe: h264/yuv420p,2.19MB  ✅ 正常

  media_url 生成视频 URL:...mp4&v=2295119(无 w)
  media_url 生成图片 URL:...jpg&w=1600&v=609017(有 w)
2026-11-05 13:14:51 +08:00

596 lines
25 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);
/* 🔴🔴 #56 严重修复(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号,
写成了 1600 这样的参数…是你更新了这个功能之后,出了这个问题」):
**`w`(压缩档位)只允许给图片。** 给视频传
`x-oss-process=image/resize,l_1600` 会让 OSS 图片处理服务
对非图片返回错误内容 ⇒ `<video>` 播不了。
这里用扩展名判断(只有 key、没有 MIME):
· 图片扩展名 → 保留 w
· 其它(mp4/pdf…) → **去掉 w**
⚠️ `v` 版本号**所有类型都保留**:视频转码后也要靠它破缓存。
它只是查询串,不参与 OSS 签名。 */
if (isset($q['w'])) {
$isImg = (bool)preg_match('#\.(jpe?g|png|gif|webp|bmp|avif|heic)$#i', $key);
if (!$isImg) {
unset($q['w']);
}
}
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;
}