2026-10-02 09:28:07 +08:00
|
|
|
|
<?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';
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ---------------- 看板与分类(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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ---------------- 读 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
|
|
|
|
|
|
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
|
|
|
|
|
|
*/
|
2026-10-03 08:18:05 +08:00
|
|
|
|
public static function board(int $familyId, int $personId, int $boardId = 0): array
|
2026-10-02 09:28:07 +08:00
|
|
|
|
{
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ⚠️ v24 第 10 条:**已完成不出现在看板里**(用户原话「直接消失」)。
|
|
|
|
|
|
// 所以这里根本不查 status='done';它们改由 doneList() 单独呈现。
|
|
|
|
|
|
// 看板上仍然只有 todo / doing 两列 —— 第三列没了,才是"已完成消失"。
|
|
|
|
|
|
// 老任务的 board_id 是 NULL → 一律算在**默认看板**上(COALESCE 到默认看板 id)
|
|
|
|
|
|
$defId = self::ensureDefaultBoard($familyId);
|
|
|
|
|
|
$boardId = $boardId > 0 ? $boardId : $defId;
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
|
|
|
|
|
|
na.nickname AS assignee_nick,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
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,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
(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 = ?
|
2026-10-03 08:18:05 +08:00
|
|
|
|
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, ?) = ?
|
2026-10-02 09:28:07 +08:00
|
|
|
|
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
|
|
|
|
|
|
ORDER BY t.position, t.id'
|
|
|
|
|
|
);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$st->execute([$personId, $personId, $familyId, $defId, $boardId, $personId, $personId]);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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);
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ⚠️ 只有 todo / doing 两列:done 已经从看板上撤走了(v24 第 10 条)。
|
|
|
|
|
|
// 保留 'done' 这个键(恒空)只为兼容个别直接按 key 取的老调用。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$out = ['todo' => [], 'doing' => [], 'done' => []];
|
|
|
|
|
|
foreach ($rows as $r) {
|
|
|
|
|
|
$k = (string)$r['status'];
|
|
|
|
|
|
$out[$k][] = $r;
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @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(
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'INSERT INTO tasks (family_id, board_id, category_id, parent_id, title, note, status, due_at,
|
|
|
|
|
|
position, creator_id, assignee_id, repeat_rule)
|
|
|
|
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
|
2026-10-02 09:28:07 +08:00
|
|
|
|
)->execute([
|
|
|
|
|
|
$familyId,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
self::boardIdOf($familyId, (int)($d['board_id'] ?? 0)),
|
|
|
|
|
|
self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0)),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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=?,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
parent_id=?, board_id=?, category_id=? WHERE id=? AND family_id=?'
|
2026-10-02 09:28:07 +08:00
|
|
|
|
)->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,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
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'],
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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> 再转义会把标签变成 <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] = '<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 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|