Files
FamilyBook/app/Task.php
T
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00

1094 lines
47 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
/**
* 任务(v12,用户 2026-10-01 第 9 条):看板 + 两级子任务 + 评论@人 + 共享 + 循环。
*
* 看板三列直接落在 `status` 上(todo / doing / done),不另存"列"字段 ——
* "列"和"状态"本来就是一件事,存两份必然对不上(拖到 done 了状态还是 doing 之类)。
*
* ────────────────────────────────────────────────────────────────
* 「待办」与「进行中」到底差什么(v24 第 25 批 用户第 6 条要求说清楚)
*
* 用户原话:「任务的状态也不太对,待办和进行中,有什么区别?去网上查查,
* 别人都是怎么设置状态的。」
*
* 查证结果(这个答得干脆,业界早就收敛了):
* 主流做法就是 Kanban 的**三态**,而且**列名与状态名是同一件事**:
* · **To Do / 待办** —— 已记下来、**还没动手**。它是一份"排队"。
* · **In Progress / 进行中** —— **已经动手了**(哪怕只做了两分钟)。
* · **Done / 已完成** —— 做完并验收。
* 出处:Todoist 的 Kanban 指南、Wrike 的 Kanban 词条("What Are The Kanban
* To Do, Doing, Done Lists?")都把这三列定义为**工作流阶段**;
* ONES 的中文词条更直白:「待处理=已创建但尚未开始」「进行中=正在执行过程中,
* 让其他人知道**已有人在处理**,避免重复工作」。
*
* ⇒ 所以分界线是**"有没有开始"**,不是"重不重要"、也不是"谁在管":
* 待办 = 队列里排着(我还没碰它)
* 进行中 = 手上有活(**别人不该再认领**)
* 两条纪律(Kanban 的 WIP 限制):
* ① 「进行中」**不要堆太多** —— 那是"同时开了 N 摊",做不完;
* ② 首页只显示「进行中」(+ 已逾期的待办),因为"我手头在做什么"才是要看的。
* 这正是 v24 第 14 批 用户第 2 条那句「按状态取,不看板取」的来由。
*
* ⚠️ **不做**「待确认 / 待审核 / 已暂停」这类扩展状态:
* 那是团队协作的产物(要有人审、有人批)。家庭场景三个人做家务,
* 多一个状态就多一次"该选哪个"的犹豫。三态够用,且与业界口径一致。
* 真需要更细,用**看板**分("属于哪摊事")而不是加压状态 —— 两个维度本来就分开。
*
* 两级:只做父任务 / 子任务两层(parent_id)。**不做无限层级** ——
* 家庭任务("周末大扫除"下面挂"擦玻璃""洗车")两层足够,
* 无限层级要递归查询 + 缩进渲染,代价远大于收益。
* ⚠️ 子任务的 status 独立于父任务:父任务可以还在 doing 而某个子任务已经 done,
* 这正好是"还剩几件没做"的进度条来源。
*
* 循环:标为 done 时,如果 repeat_rule 非空,就**新生成一条**下个周期的任务,
* 原任务保留在 done 列里(历史可回看)。不原地改 due_at ——
* 那样"上次是什么时候做的"就永远消失了。
*/
declare(strict_types=1);
namespace App;
final class Task
{
public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成'];
/**
* 每个状态「到底是什么意思」——**给界面上的人看**(v24 第 25 批 用户第 6 条)。
*
* 用户原话:「待办和进行中,有什么区别?」——他不知道区别,就是这一处没写清楚。
* 所以这句话要出现在**他看到状态的地方**:卡片上的 title、状态说明、
* 以及任务页头那句总说明(见 templates/tasks/index.php)。
*
* ⚠️ 文案刻意用"我/手头"这种第一人称口语:这是家庭场景,
* 写成"尚未开始执行"那种公文腔他自己也不会去读。
*/
public const STATUS_HINT = [
'todo' => '记下来了,还没动手 —— 排队等着',
'doing' => '已经动手了(哪怕刚开始)—— 别人就别再认领了',
'done' => '做完了',
];
/** 状态的一句话解释(界面上用它做 title / 说明) */
public static function statusHint(string $k): string
{
return self::STATUS_HINT[$k] ?? '';
}
/**
* 界面上「点一下就能完成」的那颗复选框要提交的目标状态。
*
* 用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方,
* 点这个复选框,这个任务就完成了。」
* ⇒ 所以勾上 = `done`(**不是**"推进到下一个状态")。
* ⚠️ 与卡片上那颗状态徽标(点一下轮转 todo→doing→done)刻意不同:
* 复选框只有一个意思「完成」,徽标是"改状态"。两个控件、两种语义。
*/
public const DONE_STATUS = 'done';
public const REPEAT = [
'' => '不循环',
'day' => '每天',
'week' => '每周',
'month' => '每月',
'year' => '每年',
];
/** 附件的宿主类型 */
public const ATTACH = 'task';
// ---------------- 看板与分类(v24 用户第 9、10 条)----------------
/**
* 这个家庭的看板列表。
*
* ⚠️ 「看板」与「状态」是两件事:状态是这条任务做到哪一步,看板是它属于哪一块
* (家务 / 装修 / 旅行准备)。之前只有状态,想分开看只能给标题加前缀。
*/
public static function boards(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT b.*, (SELECT COUNT(*) FROM tasks t WHERE t.board_id = b.id AND t.parent_id IS NULL
AND t.status <> "done") AS open_count
FROM task_boards b WHERE b.family_id = ? ORDER BY b.position, b.id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function categories(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT c.*, (SELECT COUNT(*) FROM tasks t WHERE t.category_id = c.id) AS used_count
FROM task_categories c WHERE c.family_id = ? ORDER BY c.position, c.id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function boardName(int $familyId, ?int $boardId): string
{
if (!$boardId) {
return '默认看板';
}
$st = Database::get()->prepare('SELECT name FROM task_boards WHERE id = ? AND family_id = ?');
$st->execute([$boardId, $familyId]);
$v = $st->fetchColumn();
return $v === false ? '' : (string)$v;
}
/**
* 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。
* @return int 默认看板的 id
*/
public static function ensureDefaultBoard(int $familyId): int
{
$db = Database::get();
$st = $db->prepare('SELECT id FROM task_boards WHERE family_id = ? ORDER BY position, id LIMIT 1');
$st->execute([$familyId]);
$id = (int)$st->fetchColumn();
if ($id > 0) {
return $id;
}
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, '日常', 100]);
return (int)$db->lastInsertId();
}
/** @return int 新建看板的 id */
public static function createBoard(int $familyId, string $name): int
{
$db = Database::get();
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, self::shortName($name), self::nextBoardPosition($familyId)]);
return (int)$db->lastInsertId();
}
public static function renameBoard(int $familyId, int $id, string $name): void
{
Database::get()->prepare('UPDATE task_boards SET name = ? WHERE id = ? AND family_id = ?')
->execute([self::shortName($name), $id, $familyId]);
}
/**
* 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。
*/
public static function deleteBoard(int $familyId, int $id): void
{
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM task_boards WHERE family_id = ?');
$st->execute([$familyId]);
if ((int)$st->fetchColumn() <= 1) {
return; // 最后一个看板不许删:删了界面上就没处放任务
}
$def = self::ensureDefaultBoard($familyId);
$db->prepare('UPDATE tasks SET board_id = ? WHERE board_id = ? AND family_id = ?')
->execute([$def === $id ? null : $def, $id, $familyId]);
$db->prepare('DELETE FROM task_boards WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/** @return int 新建分类的 id */
public static function createCategory(int $familyId, string $name): int
{
$db = Database::get();
$db->prepare('INSERT INTO task_categories (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, self::shortName($name), self::nextCategoryPosition($familyId)]);
return (int)$db->lastInsertId();
}
public static function renameCategory(int $familyId, int $id, string $name): void
{
Database::get()->prepare('UPDATE task_categories SET name = ? WHERE id = ? AND family_id = ?')
->execute([self::shortName($name), $id, $familyId]);
}
/** 删分类:用过的任务变成「未分类」,任务本身不动 */
public static function deleteCategory(int $familyId, int $id): void
{
$db = Database::get();
$db->prepare('UPDATE tasks SET category_id = NULL WHERE category_id = ? AND family_id = ?')
->execute([$id, $familyId]);
$db->prepare('DELETE FROM task_categories WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/**
* 已完成的任务(v24 第 10 条:从看板上消失,另外一处按分类查看/搜索)。
*
* @param int $categoryId 0=全部分类
* @param string $keyword 标题里含这个串(空=不限)
*/
public static function doneList(int $familyId, int $personId, int $categoryId = 0, string $keyword = ''): array
{
$kw = trim($keyword);
$sql = 'SELECT t.*, c.name AS category_name,
a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick
FROM tasks t
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
WHERE t.family_id = ? AND t.status = "done" AND t.parent_id IS NULL
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)';
$args = [$personId, $personId, $familyId, $personId, $personId];
if ($categoryId > 0) {
$sql .= ' AND t.category_id = ?';
$args[] = $categoryId;
}
if ($kw !== '') {
$sql .= ' AND t.title LIKE ?';
$args[] = '%' . $kw . '%';
}
$sql .= ' ORDER BY t.done_at DESC, t.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
/**
* 看板 id 归一化:0 / 不存在的 id → 默认看板,并且**必须属于这个家庭**
* (不校验的话,改一下表单就能把任务挂到别人家的看板上)。
* @return int|null NULL 表示"用默认看板"落在库里也存 NULL,读的时候 COALESCE 回来
*/
private static function boardIdOf(int $familyId, int $id): ?int
{
if ($id <= 0) {
return self::ensureDefaultBoard($familyId);
}
$st = Database::get()->prepare('SELECT id FROM task_boards WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$v = $st->fetchColumn();
return $v === false ? null : (int)$v;
}
/** 分类 id 归一化:0 = 未分类(NULL);不属于这个家庭的一律不认 */
private static function categoryIdOf(int $familyId, int $id): ?int
{
if ($id <= 0) {
return null;
}
$st = Database::get()->prepare('SELECT id FROM task_categories WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$v = $st->fetchColumn();
return $v === false ? null : (int)$v;
}
private static function shortName(string $n): string
{
$n = trim(preg_replace('/\s+/u', ' ', $n ?? ''));
return mb_substr($n, 0, 40, 'UTF-8');
}
private static function nextBoardPosition(int $familyId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_boards WHERE family_id = ?');
$st->execute([$familyId]);
return (int)$st->fetchColumn();
}
private static function nextCategoryPosition(int $familyId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_categories WHERE family_id = ?');
$st->execute([$familyId]);
return (int)$st->fetchColumn();
}
// ---------------- 读 ----------------
/**
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
*
* ⚠️ v24(用户 2026-10-03 第 7 条):**看板的列 = 用户自己建的看板**,
* 不再是 todo / doing 两列。原话:「任务模块跟我想的不同,每个人自定义的看板,
* 不是显示在现在的看板上面,而是**直接代替现在的待办和进行中**,是看板,
* 而不是看板的分类。」
* 所以 `status` 仍然存着 todo/doing(拖动换栏 = 改 status),
* 但**界面上的列**是 task_boards 里的那些行 —— 一列一个看板。
* 这一层只负责"把任务按看板分堆",列名/顺序由 boards() 给。
*
* 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*
* @return array<int, array> 看板 id => 该列的任务(键 0 = 没有看板归属的兜底列)
*/
public static function boardColumns(int $familyId, int $personId): array
{
$boards = self::boards($familyId);
$defId = self::ensureDefaultBoard($familyId);
$cols = [];
foreach ($boards as $b) {
$cols[(int)$b['id']] = [];
}
// ⚠️ 兜底列(键 0):老任务的 board_id 是 NULL,或者看板被删过之后没搬到默认看板。
// 必须有个地方放它们,否则那些任务在看板上**直接消失**(比显示错更糟)。
$cols[0] = [];
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done,
(SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count,
(SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
LEFT JOIN task_categories c ON c.id = t.category_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status <> "done"
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.status, t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $personId, $personId]);
$rows = $st->fetchAll();
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as $r) {
$r['children'] = $children[(int)$r['id']] ?? [];
$bid = (int)($r['board_id'] ?? 0);
if (!isset($cols[$bid])) {
$bid = 0; // 看板已被删 / 不认识 → 落进兜底列
}
$cols[$bid][] = $r;
}
return ['cols' => $cols, 'boards' => $boards, 'defId' => $defId];
}
/**
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
*
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*
* @param int $boardId 0 = 全部看板
*/
public static function board(int $familyId, int $personId, int $boardId = 0): array
{
// ⚠️ v24 第 10 条:**已完成不出现在看板里**(用户原话「直接消失」)。
// 所以这里根本不查 status='done';它们改由 doneList() 单独呈现。
// 看板上仍然只有 todo / doing 两列 —— 第三列没了,才是"已完成消失"。
// 老任务的 board_id 是 NULL → 一律算在**默认看板**上(COALESCE 到默认看板 id)
$defId = self::ensureDefaultBoard($familyId);
$boardId = $boardId > 0 ? $boardId : $defId;
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done,
(SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count,
(SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
LEFT JOIN task_categories c ON c.id = t.category_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status <> "done"
AND COALESCE(t.board_id, ?) = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $defId, $boardId, $personId, $personId]);
$rows = $st->fetchAll();
// 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢)
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as &$r) {
$r['children'] = $children[(int)$r['id']] ?? [];
}
unset($r);
// ⚠️ 只有 todo / doing 两列:done 已经从看板上撤走了(v24 第 10 条)。
// 保留 'done' 这个键(恒空)只为兼容个别直接按 key 取的老调用。
$out = ['todo' => [], 'doing' => [], 'done' => []];
foreach ($rows as $r) {
$k = (string)$r['status'];
$out[$k][] = $r;
}
return $out;
}
/**
* 我能看到的、处于某个状态的**父任务**(**跨看板**)。
*
* v24 第 14 批 用户第 2 条原话:「正在进行的状态的任务,应该在首页中显示。
* 也就是说,首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关。」
*
* ⚠️ 与 board() 的分工:board() 是"某一块看板里有什么"(列 = 看板,按 board_id 过滤);
* 这个是"处于某个状态的所有事"(**不看 board_id**)——
* 一个人在"家务""装修"两个看板上各有一件进行中的事,首页就该看到两件。
* 之前首页直接调 board()(它只取默认看板),于是别的看板上的事**永远不会出现在首页**。
*
* @param string $status todo / doing / done
*/
public static function byStatus(int $familyId, int $personId, string $status): array
{
$status = self::status($status);
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
b.name AS board_name,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done
FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN task_boards b ON b.id = t.board_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY COALESCE(t.due_at, ?) ASC, t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $status, $personId, $personId, '9999-12-31 00:00:00']);
$rows = $st->fetchAll();
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as &$r) {
$r['children'] = $children[(int)$r['id']] ?? [];
}
unset($r);
return $rows;
}
/** @return array<int,array> 父任务 id => 子任务列表 */
public static function childrenFor(array $parentIds, int $viewerId = 0): array
{
$out = [];
$ids = array_values(array_filter(array_map('intval', $parentIds)));
if (!$ids) {
return $out;
}
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT c.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick
FROM tasks c
LEFT JOIN persons a ON a.id = c.assignee_id
LEFT JOIN nicknames na ON na.target_id = c.assignee_id AND na.viewer_id = ?
WHERE c.parent_id IN ($in) ORDER BY c.position, c.id"
);
$st->execute(array_merge([$viewerId], $ids));
foreach ($st->fetchAll() as $c) {
$out[(int)$c['parent_id']][] = $c;
}
return $out;
}
/**
* 一条任务(带子任务 / 评论 / 附件 / 共享)。看不到返回 null。
*/
public static function find(int $familyId, int $id, int $personId): ?array
{
$st = Database::get()->prepare(
// ⚠️ 父任务的标题列叫 `title`,**不是 `name`** —— 这里写过 `p.name`,
// php -l 一点问题都没有(SQL 是字符串),只在真的打开任务详情页时
// 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以
// "200 + Fatal error 正文"吐出来,看着像页面正常返回。
// 改 SQL 之后一定要用「真实库表结构」校验列名(见 sql-schema-column-check)。
'SELECT t.*, p.title AS parent_title,
a.name AS assignee_name, a.avatar AS assignee_avatar, a.avatar_color AS assignee_color,
na.nickname AS assignee_nick,
cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color,
nc.nickname AS creator_nick
FROM tasks t
LEFT JOIN tasks p ON p.id = t.parent_id
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN persons cr ON cr.id = t.creator_id
LEFT JOIN nicknames nc ON nc.target_id = t.creator_id AND nc.viewer_id = ?
WHERE t.id = ? AND t.family_id = ?'
);
$st->execute([$personId, $personId, $id, $familyId]);
$t = $st->fetch();
if (!$t) {
return null;
}
if (!self::visibleTo($t, $personId)) {
return null;
}
// 子任务的任务详情里要能看到父任务,父任务也要能看到全部兄弟
$t['children'] = self::childrenFor([(int)$t['id']], $personId)[(int)$t['id']] ?? [];
$t['comments'] = self::comments((int)$t['id'], $personId);
$t['attachs'] = \App\Attachment::list(self::ATTACH, (int)$t['id']);
$t['shares'] = self::shares((int)$t['id']);
return $t;
}
/** 我能看这条吗(建的 / 分给我的 / 共享给我的 / 父任务的子任务) */
public static function visibleTo(array $t, int $personId): bool
{
if ((int)($t['creator_id'] ?? 0) === $personId || (int)($t['assignee_id'] ?? 0) === $personId) {
return true;
}
if (self::isShared((int)$t['id'], $personId)) {
return true;
}
// 子任务:父任务能看,子任务也就能看(否则点开父任务会看到一半的子任务)
$pid = (int)($t['parent_id'] ?? 0);
if ($pid > 0) {
$st = Database::get()->prepare(
'SELECT creator_id, assignee_id FROM tasks WHERE id = ?'
);
$st->execute([$pid]);
$p = $st->fetch();
if ($p && ((int)$p['creator_id'] === $personId || (int)$p['assignee_id'] === $personId
|| self::isShared($pid, $personId))) {
return true;
}
}
return false;
}
public static function isShared(int $taskId, int $personId): bool
{
$st = Database::get()->prepare('SELECT 1 FROM task_shares WHERE task_id = ? AND person_id = ?');
$st->execute([$taskId, $personId]);
return $st->fetchColumn() !== false;
}
/** 我能改吗(建的人 / 负责人 / 共享为 write / 子任务随父) */
public static function canWrite(array $t, int $personId): bool
{
$id = (int)($t['id'] ?? 0);
if ($id <= 0) {
return false;
}
if ((int)($t['creator_id'] ?? 0) === $personId || (int)($t['assignee_id'] ?? 0) === $personId) {
return true;
}
if (self::sharePerm($id, $personId) === 'write') {
return true;
}
// 子任务的权限跟着父任务走:父任务能改,它的子任务也就能改
$pid = (int)($t['parent_id'] ?? 0);
if ($pid > 0) {
$st = Database::get()->prepare('SELECT id, parent_id, creator_id, assignee_id FROM tasks WHERE id = ?');
$st->execute([$pid]);
$p = $st->fetch();
if ($p) {
return self::canWrite($p, $personId);
}
}
return false;
}
/** 我对这条任务的共享权限:'' / 'read' / 'write' */
private static function sharePerm(int $taskId, int $personId): string
{
$st = Database::get()->prepare('SELECT perm FROM task_shares WHERE task_id = ? AND person_id = ?');
$st->execute([$taskId, $personId]);
$v = $st->fetchColumn();
return $v === false ? '' : (string)$v;
}
// ---------------- 写 ----------------
/** @return int 新任务 id */
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO tasks (family_id, board_id, category_id, parent_id, title, note, status, due_at,
position, creator_id, assignee_id, repeat_rule)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$familyId,
self::boardIdOf($familyId, (int)($d['board_id'] ?? 0)),
self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0)),
self::parentId($familyId, (int)($d['parent_id'] ?? 0)),
self::title($d['title'] ?? ''),
self::textOrNull($d['note'] ?? ''),
self::status($d['status'] ?? ''),
self::dtOrNull($d['due_at'] ?? ''),
self::nextPosition($familyId, self::status($d['status'] ?? '')),
$creatorId ?: null,
(int)($d['assignee_id'] ?? 0) ?: null,
self::repeat($d['repeat_rule'] ?? ''),
]);
return (int)$db->lastInsertId();
}
public static function update(int $familyId, int $id, array $d): void
{
$db = Database::get();
$old = self::row($familyId, $id);
if (!$old) {
return;
}
// ⚠️ 父任务不能把自己挂到自己(或自己的子孙)下面 —— 会成环,
// 看板递归时会无限展开。直接忽略这种改动。
$pid = self::parentId($familyId, (int)($d['parent_id'] ?? 0), $id);
$status = self::status($d['status'] ?? '');
$db->prepare(
'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?,
parent_id=?, board_id=?, category_id=? WHERE id=? AND family_id=?'
)->execute([
self::title($d['title'] ?? ''),
self::textOrNull($d['note'] ?? ''),
$status,
self::dtOrNull($d['due_at'] ?? ''),
(int)($d['assignee_id'] ?? 0) ?: null,
self::repeat($d['repeat_rule'] ?? ''),
$pid,
array_key_exists('board_id', $d) ? self::boardIdOf($familyId, (int)$d['board_id']) : $old['board_id'],
array_key_exists('category_id', $d)
? self::categoryIdOf($familyId, (int)$d['category_id'])
: $old['category_id'],
$id, $familyId,
]);
if ($status === 'done' && (string)$old['status'] !== 'done') {
self::markDone($familyId, $id);
}
}
/**
* 改状态(看板拖动 / 勾复选框都走这里)。
*
* ⚠️ 循环在这里发生:变成 done 且 repeat_rule 非空 → 生成下一条。
* 只在**从非 done 变成 done** 时生成(重复提交 / 刷新不会刷出一堆)。
*
* @return int 因循环而新生成的任务 id(没有则 0)
*/
public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int
{
$old = self::row($familyId, $id);
if (!$old) {
return 0;
}
$status = self::status($status);
$db = Database::get();
if ($position > 0) {
$db->prepare('UPDATE tasks SET status=?, position=? WHERE id=? AND family_id=?')
->execute([$status, $position, $id, $familyId]);
} else {
$db->prepare('UPDATE tasks SET status=? WHERE id=? AND family_id=?')
->execute([$status, $id, $familyId]);
}
if ($status === 'done' && (string)$old['status'] !== 'done') {
self::markDone($familyId, $id);
return self::spawnNext($familyId, $old);
}
return 0;
}
/**
* 看板里搬一张卡:改状态 + 插到指定位置。
*
* ⚠️ 位置是**整列重排**(10, 20, 30…)而不是"取相邻两个 position 的中点":
* 中点在两卡相邻时会挤不开(pos 相同 → 顺序由 id 兜底 → 拖了跟没拖一样),
* 而一列通常只有几十条,按序重排的代价可以忽略,换来的是永远不会排错。
*
* @param int $beforeId 放在这张卡**前面**(0 = 放到末尾)
*/
public static function moveTo(int $familyId, int $id, string $status, int $beforeId = 0): void
{
$old = self::row($familyId, $id);
if (!$old) {
return;
}
$status = self::status($status);
$db = Database::get();
$st = $db->prepare(
'SELECT id FROM tasks WHERE family_id = ? AND status = ? AND parent_id IS NULL AND id <> ?
ORDER BY position, id'
);
$st->execute([$familyId, $status, $id]);
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
$idx = count($ids);
if ($beforeId > 0) {
$k = array_search($beforeId, $ids, true);
if ($k !== false) {
$idx = (int)$k;
}
}
array_splice($ids, $idx, 0, [$id]);
$up = $db->prepare('UPDATE tasks SET status = ?, position = ? WHERE id = ? AND family_id = ?');
foreach ($ids as $i => $tid) {
$up->execute([$status, ($i + 1) * 10, $tid, $familyId]);
}
if ($status === 'done' && (string)$old['status'] !== 'done') {
self::markDone($familyId, $id);
self::spawnNext($familyId, $old);
}
}
/**
* 看板里把一张卡搬到**另一个看板**(v24 用户 2026-10-03 第 7 条)。
*
* ⚠️ 与 moveTo() 的分工(别再混起来):
* · v24 第 7 条之前,界面上的两列是「待办 / 进行中」→ 搬卡 = 改 status(moveTo);
* · 现在界面上的一列一个**看板** → 搬卡 = 改 board_id(这个方法)。
* 两个字段都还在、都有用:board 是"属于哪摊事",status 是"做到哪一步"。
* 看板上只显示未完成的(status<>done),所以搬卡不动 status ——
* 顺手把 status 重置成 todo 反而会把用户"进行中"的标记抹掉。
*
* @param int $beforeId 放在这张卡**前面**(0 = 放到末尾)
* @param int $personId 谁在搬(用来只在他看得见的那些卡里排序)
*/
public static function moveToBoard(int $familyId, int $id, int $boardId, int $beforeId = 0, int $personId = 0): void
{
$old = self::row($familyId, $id);
if (!$old) {
return;
}
// 看板 id 归一化:0 或不认识的一律落到默认看板(不校验就等着"搬到别人家的看板")
$target = self::boardIdOf($familyId, $boardId);
$db = Database::get();
if ($target === null) {
$target = self::ensureDefaultBoard($familyId);
}
// 同列重排:把这一列的 id 按目标顺序重排成 10/20/30…
// (与 moveTo 同一套做法:中点法在两卡相邻时会挤不开,整列重排永远不会排错)
// ⚠️ 排序范围按"这个人能看到的卡"算 —— 否则把别人私有的任务也算进来,
// position 会被那些看不见的卡占掉(表现为"拖了跟没拖一样")。
$st = $db->prepare(
'SELECT t.id FROM tasks t
WHERE t.family_id = ? AND t.status <> "done" AND t.parent_id IS NULL AND t.id <> ?
AND COALESCE(t.board_id, 0) = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR EXISTS (
SELECT 1 FROM task_shares sh WHERE sh.task_id = t.id AND sh.person_id = ?))
ORDER BY t.position, t.id'
);
$st->execute([$familyId, $id, $target, $personId, $personId, $personId]);
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
$idx = count($ids);
if ($beforeId > 0) {
$k = array_search($beforeId, $ids, true);
if ($k !== false) {
$idx = (int)$k;
}
}
array_splice($ids, $idx, 0, [$id]);
$up = $db->prepare('UPDATE tasks SET board_id = ?, position = ? WHERE id = ? AND family_id = ?');
foreach ($ids as $i => $tid) {
$up->execute([$target, ($i + 1) * 10, $tid, $familyId]);
}
}
public static function destroy(int $familyId, int $id): void
{
// 子任务随父任务级联(外键 ON DELETE CASCADE 已保证),
// 但**附件与评论里的附件**没有外键,得自己清。
$st = Database::get()->prepare('SELECT id FROM tasks WHERE family_id = ? AND (id = ? OR parent_id = ?)');
$st->execute([$familyId, $id, $id]);
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
foreach ($ids as $tid) {
\App\Attachment::removeAll(self::ATTACH, $tid);
$cs = Database::get()->prepare('SELECT id FROM task_comments WHERE task_id = ?');
$cs->execute([$tid]);
foreach ($cs->fetchAll() as $c) {
\App\Attachment::removeAll('task_comment', (int)$c['id']);
}
}
Database::get()->prepare('DELETE FROM tasks WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
// ---------------- 评论 ----------------
public static function comments(int $taskId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT m.*, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM task_comments m JOIN persons p ON p.id = m.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE m.task_id = ? ORDER BY m.created_at, m.id'
);
$st->execute([$viewerId, $taskId]);
return $st->fetchAll();
}
public static function addComment(int $taskId, int $personId, string $content): int
{
$content = trim($content);
if ($content === '') {
return 0;
}
$db = Database::get();
$db->prepare('INSERT INTO task_comments (task_id, person_id, content) VALUES (?,?,?)')
->execute([$taskId, $personId, mb_substr($content, 0, 4000, 'UTF-8')]);
return (int)$db->lastInsertId();
}
public static function deleteComment(int $id, int $personId): void
{
// 只能删自己的(管理员也不删别人的留言 —— 家庭场景下这是聊天记录)
Database::get()->prepare('DELETE FROM task_comments WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
/**
* 把评论里的 @某人 高亮出来。
*
* ⚠️ 先转义、再替换:**绝不能**反过来(先塞 <b> 再转义会把标签变成 &lt;b&gt;,
* 页面上一堆字面的尖括号)。匹配按"长的名字优先",
* 否则「@张三丰」里的「@张三」会先被短名字命中,剩下「丰」没被高亮。
*
* @param array $names 可匹配的名字(含昵称),按长度降序排好
*/
public static function commentHtml(string $content, array $names): string
{
$html = e($content);
$pairs = [];
foreach ($names as $nm) {
$nm = trim((string)$nm);
if ($nm !== '') {
$pairs[$nm] = '<b class="mention">@' . e($nm) . '</b>';
}
}
uksort($pairs, static fn($a, $b) => mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8'));
foreach ($pairs as $nm => $rep) {
$html = str_replace('@' . e($nm), $rep, $html);
}
return nl2br($html);
}
// ---------------- 共享 ----------------
public static function shares(int $taskId): array
{
$st = Database::get()->prepare(
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM task_shares s JOIN persons p ON p.id = s.person_id
LEFT JOIN nicknames n ON n.target_id = p.id
WHERE s.task_id = ? ORDER BY p.id'
);
$st->execute([$taskId]);
return $st->fetchAll();
}
public static function setShare(int $taskId, int $personId, string $perm): void
{
if ($personId <= 0) {
return;
}
Database::get()->prepare(
'INSERT INTO task_shares (task_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$taskId, $personId, $perm === 'read' ? 'read' : 'write']);
}
public static function dropShare(int $taskId, int $personId): void
{
Database::get()->prepare('DELETE FROM task_shares WHERE task_id = ? AND person_id = ?')
->execute([$taskId, $personId]);
}
// ---------------- 展示 ----------------
public static function statusWord(string $k): string
{
return self::STATUS[$k] ?? '待办';
}
/** 把任意输入归一化成合法状态(路由里写提示文案用:不能拿用户传的原文去查词) */
public static function normalizeStatus(string $v): string
{
return self::status($v);
}
public static function repeatWord(?string $k): string
{
$k = (string)$k;
return self::REPEAT[$k] ?? '不循环';
}
/** 截止日期的人话(今天/明天/逾期…),任务卡片与日历共用 */
public static function dueWord(?string $due): string
{
if ($due === null || $due === '') {
return '';
}
$ts = strtotime($due);
if ($ts === false) {
return '';
}
$hasTime = substr((string)$due, 11, 5) !== '00:00';
$d = date('Y-m-d', $ts);
$today = date('Y-m-d');
if ($d === $today) {
return ($hasTime ? '今天 ' . substr((string)$due, 11, 5) : '今天') . ' 截止';
}
if ($d === date('Y-m-d', strtotime('+1 day'))) {
return '明天截止';
}
if ($d < $today) {
return '已逾期(' . date('n月j日', $ts) . ')';
}
return date('n月j日', $ts) . ($hasTime ? ' ' . substr((string)$due, 11, 5) : '') . ' 截止';
}
public static function isOverdue(?string $due): bool
{
if ($due === null || $due === '') {
return false;
}
return substr((string)$due, 0, 10) < date('Y-m-d');
}
// ---------------- 内部 ----------------
private static function row(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM tasks WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/** 标完成:记 done_at */
private static function markDone(int $familyId, int $id): void
{
Database::get()->prepare(
"UPDATE tasks SET done_at = NOW() WHERE id = ? AND family_id = ? AND done_at IS NULL"
)->execute([$id, $familyId]);
}
/**
* 循环:生成下一条。**只在有截止日期时**生成 ——
* 没有基准日期的循环任务算不出"下一次是什么时候",硬造一条只会变成一条空的待办。
*
* @return int 新任务 id(没生成则 0)
*/
private static function spawnNext(int $familyId, array $old): int
{
$rule = (string)($old['repeat_rule'] ?? '');
$due = (string)($old['due_at'] ?? '');
if ($rule === '' || $due === '') {
return 0;
}
$next = self::advance($due, $rule);
if ($next === '') {
return 0;
}
$db = Database::get();
$db->prepare(
'INSERT INTO tasks (family_id, parent_id, title, note, status, due_at, position,
creator_id, assignee_id, repeat_rule)
VALUES (?,?,?,?,?,?,?,?,?,?)'
)->execute([
$familyId,
$old['parent_id'] !== null ? (int)$old['parent_id'] : null,
(string)$old['title'],
$old['note'],
'todo',
$next,
$old['position'],
$old['creator_id'],
$old['assignee_id'],
$rule,
]);
return (int)$db->lastInsertId();
}
/** 按循环规则把日期往后推一个周期(以**原截止日**为基准,不是以今天) */
private static function advance(string $due, string $rule): string
{
$ts = strtotime($due);
if ($ts === false) {
return '';
}
$map = ['day' => '+1 day', 'week' => '+1 week', 'month' => '+1 month', 'year' => '+1 year'];
if (!isset($map[$rule])) {
return '';
}
// 推到"不早于今天"为止:一个每周任务拖了三周才勾,应该出现下周三,
// 而不是连着补三条过期的一周前。
$t = $ts;
for ($i = 0; $i < 400; $i++) {
$t = strtotime($map[$rule], $t);
if ($t === false) {
return '';
}
if (date('Y-m-d', $t) >= date('Y-m-d')) {
break;
}
}
return date('Y-m-d H:i:s', $t);
}
/**
* 父任务 id 的合法性:存在、同家庭、不是自己、也不是自己的子孙(否则成环)。
*
* @param int $selfId 被编辑的那条自己(0=新建,不存在成环问题)
*/
private static function parentId(int $familyId, int $pid, int $selfId = 0): ?int
{
if ($pid <= 0) {
return null;
}
if ($selfId > 0 && $pid === $selfId) {
return null;
}
$db = Database::get();
$st = $db->prepare('SELECT parent_id FROM tasks WHERE id = ? AND family_id = ?');
$st->execute([$pid, $familyId]);
$r = $st->fetch();
if (!$r) {
return null; // 不存在 / 别家的
}
// 从候选父任务往上走,撞到自己就说明"想把自己挂到自己的后代下面"
$cur = (int)($r['parent_id'] ?? 0);
for ($guard = 0; $guard < 10 && $cur > 0; $guard++) {
if ($selfId > 0 && $cur === $selfId) {
return null;
}
$up = $db->prepare('SELECT parent_id FROM tasks WHERE id = ?');
$up->execute([$cur]);
$u = $up->fetch();
if (!$u) {
break;
}
$cur = (int)($u['parent_id'] ?? 0);
}
return $pid;
}
private static function nextPosition(int $familyId, string $status): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(MAX(position),0)+10 FROM tasks WHERE family_id = ? AND status = ? AND parent_id IS NULL'
);
$st->execute([$familyId, $status]);
return (int)$st->fetchColumn();
}
private static function title(string $v): string
{
return mb_substr(trim($v), 0, 200, 'UTF-8');
}
private static function status(string $v): string
{
return array_key_exists($v, self::STATUS) ? $v : 'todo';
}
private static function repeat(string $v): ?string
{
return array_key_exists($v, self::REPEAT) && $v !== '' ? $v : null;
}
private static function dtOrNull(string $v): ?string
{
$v = trim($v);
if ($v === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {
$v .= ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $v)) {
$v .= ':00';
}
return preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v) ? $v : null;
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
}