'待办', 'doing' => '进行中', 'done' => '已完成']; public const REPEAT = [ '' => '不循环', 'day' => '每天', 'week' => '每周', 'month' => '每月', 'year' => '每年', ]; /** 附件的宿主类型 */ public const ATTACH = 'task'; // ---------------- 读 ---------------- /** * 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。 * * 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。 * ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。 */ public static function board(int $familyId, int $personId): array { $st = Database::get()->prepare( 'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color, na.nickname AS assignee_nick, (SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id) AS child_total, (SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id AND c.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 = ? WHERE t.family_id = ? AND t.parent_id IS NULL 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, $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); $out = ['todo' => [], 'doing' => [], 'done' => []]; foreach ($rows as $r) { $k = (string)$r['status']; $out[$k][] = $r; } return $out; } /** @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, parent_id, title, note, status, due_at, position, creator_id, assignee_id, repeat_rule) VALUES (?,?,?,?,?,?,?,?,?,?)' )->execute([ $familyId, 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=? 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, $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); } } 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 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; } }