'待办', 'doing' => '进行中', 'done' => '已完成']; /** * 每个状态「到底是什么意思」——**给开发看**的语义说明。 * * ⚠️ 历史(v24 第 25 批 用户第 6 条):用户问过「待办和进行中,有什么区别?」, * 当时把这句话渲染在看板顶部当"状态说明"。 * 🔴 v44(用户 2026-10-12 第 4 条):「任务页面,任务下面有一行字… * 这个提示不要了,去掉。」⇒ **界面不再渲染它**(见 templates/tasks/index.php)。 * 常量与 `statusHint()` 保留:它仍是这三个状态语义的**唯一书面定义**, * 后端/文案需要时还能取;删掉反而要后人重新考古一遍。 */ 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; } // ==================================================================== // 看板颜色(v24 第 26 批 用户第 3 条) // // 用户原话:「任务在设置看板的时候,可以选择颜色,这样在首页列表中显示的时候, // 就可以更加直观的看到他属于哪个看板。」 // // 设计要点(两条,都是为了"一眼能认出来"): // ① 用户没选过颜色时**按看板名派生**一个稳定色 —— 同名永远同色。 // 若改成"按 id 派生",看板一改名颜色就跳,用户反而记不住; // 若统一给一个默认色,所有看板长得一样,就完全失去这条需求的意义。 // ② 派生只从**固定调色板**里挑(跟名册头像色块同一套观感), // 不做 HSL 随机 —— 随机色会造出刺眼/低对比的颜色,白字压不住。 // ==================================================================== /** 看板可选色的调色板(10 个,覆盖暖冷中性;都够深,能压白字) */ public const BOARD_COLORS = [ '#C0533F', '#B87333', '#8A7B3F', '#5E9E4E', '#3F8F7A', '#3E7A9E', '#5B6DC0', '#8B5CF6', '#B0508A', '#7A6A5E', ]; /** * 取一个看板该用什么颜色。 * * @param string|null $color 库里存的(可能为 NULL / 空 / 非法值) * @param string $name 看板名(没存颜色时按它派生) * @return string '#RRGGBB' */ public static function boardColor(?string $color, string $name = ''): string { $c = strtolower(trim((string)$color)); if (preg_match('/^#[0-9a-f]{6}$/', $c)) { return $c; } // 按名字派生:同一个名字永远同一个色(改 id 不影响,用户能记住) $h = 0; foreach (preg_split('//u', $name, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $ch) { // 用码点而不是字节:中文名在不同编码下字节不同,码点是稳的 $h = ($h * 31 + (int)(mb_ord($ch, 'UTF-8') ?: 0)) % 1000000007; } return self::BOARD_COLORS[abs($h) % count(self::BOARD_COLORS)]; } /** 看板 id → 颜色(board_name 一起取,省一次查询;查不到给灰) */ public static function boardColorById(int $familyId, ?int $boardId): string { if (!$boardId) { return self::boardColor(null, '默认看板'); } $st = Database::get()->prepare('SELECT name, color FROM task_boards WHERE id = ? AND family_id = ?'); $st->execute([$boardId, $familyId]); $r = $st->fetch(); if (!$r) { return self::boardColor(null, ''); } return self::boardColor($r['color'] ?? null, (string)$r['name']); } /** * 改看板名 + 颜色(设置页那一行表单)。 * * ⚠️ 颜色与名字**同一个动作**:用户改色时不该被迫把名字也重打一遍 * (表单里两个都在、一起提交,这里是它们共同的落点)。 * ⚠️ 颜色合法性在这里收口(只认 `#RRGGBB`),空串 = 清除自定义、回到按名派生。 */ public static function updateBoard(int $familyId, int $id, string $name, string $color): void { $db = Database::get(); $c = strtolower(trim($color)); $c = preg_match('/^#[0-9a-f]{6}$/', $c) ? $c : null; $db->prepare('UPDATE task_boards SET name = ?, color = ? WHERE id = ? AND family_id = ?') ->execute([self::shortName($name), $c, $id, $familyId]); } /** * 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。 * @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(); } /* ⚠️ 原来的 `renameBoard()` 已在 v24 第 26 批**并入 `updateBoard()`**(用户第 3 条 要给看板加颜色,两个字段同一个动作),这里不再保留 —— 留一个只改名字的方法, 下一个改颜色的人会顺手调错它("改了色但名字被重置"这类事故就是这么来的)。 */ /** * 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。 */ 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 看板 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, b.color AS board_color, (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 父任务 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>, * 页面上一堆字面的尖括号)。匹配按"长的名字优先", * 否则「@张三丰」里的「@张三」会先被短名字命中,剩下「丰」没被高亮。 * * @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] = '@' . e($nm) . ''; } } 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; } }