Files
FamilyBook/app/Task.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

868 lines
35 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();
}
// ---------------- 读 ----------------
/**
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
*
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*/
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;
}
/** @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);
}
}
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 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;
}
}