Files
FamilyBook/app/Task.php
T
laoyang 7138314dc7 v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
2026-10-05 10:58:17 +08:00

1032 lines
44 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 之类)。
*
* 两级:只做父任务 / 子任务两层(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' => '已完成'];
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;
}
}