- 财务:勾选多个标签时上面那张「总金额」图按**选中集合**统计(新增 ledger/total_chart 路由 + 单一容器 data-ck-total-host,按集合现取并缓存; Ledger::timeline 第二参数放宽为 string|array) - 首页日程:全天日程移出滚动容器固定置顶;进场把当前时刻红线居中 (scrollTop 计算,不用 scrollIntoView),触发条件不绑 $showNow - 任务:复选框去除 title 提示;看板可设颜色(Task::boardColor/updateBoard), 首页任务行用该颜色显示所属看板 - 导航:展示页补回 id=engine-tabs(切换搜索引擎无效的根因);补内置引擎; 新增站点输 URL 可复用他人已加过的站点(sharedByUrl/copyIconFile/adoptSite) - 任务详情页页头去掉复选框(已有「标记完成」按钮)
1165 lines
51 KiB
PHP
1165 lines
51 KiB
PHP
<?php
|
||
/**
|
||
* 任务(v12,用户 2026-10-01 第 9 条):看板 + 两级子任务 + 评论@人 + 共享 + 循环。
|
||
*
|
||
* 看板三列直接落在 `status` 上(todo / doing / done),不另存"列"字段 ——
|
||
* "列"和"状态"本来就是一件事,存两份必然对不上(拖到 done 了状态还是 doing 之类)。
|
||
*
|
||
* ────────────────────────────────────────────────────────────────
|
||
* 「待办」与「进行中」到底差什么(v24 第 25 批 用户第 6 条要求说清楚)
|
||
*
|
||
* 用户原话:「任务的状态也不太对,待办和进行中,有什么区别?去网上查查,
|
||
* 别人都是怎么设置状态的。」
|
||
*
|
||
* 查证结果(这个答得干脆,业界早就收敛了):
|
||
* 主流做法就是 Kanban 的**三态**,而且**列名与状态名是同一件事**:
|
||
* · **To Do / 待办** —— 已记下来、**还没动手**。它是一份"排队"。
|
||
* · **In Progress / 进行中** —— **已经动手了**(哪怕只做了两分钟)。
|
||
* · **Done / 已完成** —— 做完并验收。
|
||
* 出处:Todoist 的 Kanban 指南、Wrike 的 Kanban 词条("What Are The Kanban
|
||
* To Do, Doing, Done Lists?")都把这三列定义为**工作流阶段**;
|
||
* ONES 的中文词条更直白:「待处理=已创建但尚未开始」「进行中=正在执行过程中,
|
||
* 让其他人知道**已有人在处理**,避免重复工作」。
|
||
*
|
||
* ⇒ 所以分界线是**"有没有开始"**,不是"重不重要"、也不是"谁在管":
|
||
* 待办 = 队列里排着(我还没碰它)
|
||
* 进行中 = 手上有活(**别人不该再认领**)
|
||
* 两条纪律(Kanban 的 WIP 限制):
|
||
* ① 「进行中」**不要堆太多** —— 那是"同时开了 N 摊",做不完;
|
||
* ② 首页只显示「进行中」(+ 已逾期的待办),因为"我手头在做什么"才是要看的。
|
||
* 这正是 v24 第 14 批 用户第 2 条那句「按状态取,不看板取」的来由。
|
||
*
|
||
* ⚠️ **不做**「待确认 / 待审核 / 已暂停」这类扩展状态:
|
||
* 那是团队协作的产物(要有人审、有人批)。家庭场景三个人做家务,
|
||
* 多一个状态就多一次"该选哪个"的犹豫。三态够用,且与业界口径一致。
|
||
* 真需要更细,用**看板**分("属于哪摊事")而不是加压状态 —— 两个维度本来就分开。
|
||
*
|
||
* 两级:只做父任务 / 子任务两层(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' => '已完成'];
|
||
|
||
/**
|
||
* 每个状态「到底是什么意思」——**给界面上的人看**(v24 第 25 批 用户第 6 条)。
|
||
*
|
||
* 用户原话:「待办和进行中,有什么区别?」——他不知道区别,就是这一处没写清楚。
|
||
* 所以这句话要出现在**他看到状态的地方**:卡片上的 title、状态说明、
|
||
* 以及任务页头那句总说明(见 templates/tasks/index.php)。
|
||
*
|
||
* ⚠️ 文案刻意用"我/手头"这种第一人称口语:这是家庭场景,
|
||
* 写成"尚未开始执行"那种公文腔他自己也不会去读。
|
||
*/
|
||
public const STATUS_HINT = [
|
||
'todo' => '记下来了,还没动手 —— 排队等着',
|
||
'doing' => '已经动手了(哪怕刚开始)—— 别人就别再认领了',
|
||
'done' => '做完了',
|
||
];
|
||
|
||
/** 状态的一句话解释(界面上用它做 title / 说明) */
|
||
public static function statusHint(string $k): string
|
||
{
|
||
return self::STATUS_HINT[$k] ?? '';
|
||
}
|
||
|
||
/**
|
||
* 界面上「点一下就能完成」的那颗复选框要提交的目标状态。
|
||
*
|
||
* 用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方,
|
||
* 点这个复选框,这个任务就完成了。」
|
||
* ⇒ 所以勾上 = `done`(**不是**"推进到下一个状态")。
|
||
* ⚠️ 与卡片上那颗状态徽标(点一下轮转 todo→doing→done)刻意不同:
|
||
* 复选框只有一个意思「完成」,徽标是"改状态"。两个控件、两种语义。
|
||
*/
|
||
public const DONE_STATUS = 'done';
|
||
|
||
public const REPEAT = [
|
||
'' => '不循环',
|
||
'day' => '每天',
|
||
'week' => '每周',
|
||
'month' => '每月',
|
||
'year' => '每年',
|
||
];
|
||
|
||
/** 附件的宿主类型 */
|
||
public const ATTACH = 'task';
|
||
|
||
// ---------------- 看板与分类(v24 用户第 9、10 条)----------------
|
||
|
||
/**
|
||
* 这个家庭的看板列表。
|
||
*
|
||
* ⚠️ 「看板」与「状态」是两件事:状态是这条任务做到哪一步,看板是它属于哪一块
|
||
* (家务 / 装修 / 旅行准备)。之前只有状态,想分开看只能给标题加前缀。
|
||
*/
|
||
public static function boards(int $familyId): array
|
||
{
|
||
$st = Database::get()->prepare(
|
||
'SELECT b.*, (SELECT COUNT(*) FROM tasks t WHERE t.board_id = b.id AND t.parent_id IS NULL
|
||
AND t.status <> "done") AS open_count
|
||
FROM task_boards b WHERE b.family_id = ? ORDER BY b.position, b.id'
|
||
);
|
||
$st->execute([$familyId]);
|
||
return $st->fetchAll();
|
||
}
|
||
|
||
public static function categories(int $familyId): array
|
||
{
|
||
$st = Database::get()->prepare(
|
||
'SELECT c.*, (SELECT COUNT(*) FROM tasks t WHERE t.category_id = c.id) AS used_count
|
||
FROM task_categories c WHERE c.family_id = ? ORDER BY c.position, c.id'
|
||
);
|
||
$st->execute([$familyId]);
|
||
return $st->fetchAll();
|
||
}
|
||
|
||
public static function boardName(int $familyId, ?int $boardId): string
|
||
{
|
||
if (!$boardId) {
|
||
return '默认看板';
|
||
}
|
||
$st = Database::get()->prepare('SELECT name FROM task_boards WHERE id = ? AND family_id = ?');
|
||
$st->execute([$boardId, $familyId]);
|
||
$v = $st->fetchColumn();
|
||
return $v === false ? '' : (string)$v;
|
||
}
|
||
|
||
// ====================================================================
|
||
// 看板颜色(v24 第 26 批 用户第 3 条)
|
||
//
|
||
// 用户原话:「任务在设置看板的时候,可以选择颜色,这样在首页列表中显示的时候,
|
||
// 就可以更加直观的看到他属于哪个看板。」
|
||
//
|
||
// 设计要点(两条,都是为了"一眼能认出来"):
|
||
// ① 用户没选过颜色时**按看板名派生**一个稳定色 —— 同名永远同色。
|
||
// 若改成"按 id 派生",看板一改名颜色就跳,用户反而记不住;
|
||
// 若统一给一个默认色,所有看板长得一样,就完全失去这条需求的意义。
|
||
// ② 派生只从**固定调色板**里挑(跟名册头像色块同一套观感),
|
||
// 不做 HSL 随机 —— 随机色会造出刺眼/低对比的颜色,白字压不住。
|
||
// ====================================================================
|
||
|
||
/** 看板可选色的调色板(10 个,覆盖暖冷中性;都够深,能压白字) */
|
||
public const BOARD_COLORS = [
|
||
'#C0533F', '#B87333', '#8A7B3F', '#5E9E4E', '#3F8F7A',
|
||
'#3E7A9E', '#5B6DC0', '#8B5CF6', '#B0508A', '#7A6A5E',
|
||
];
|
||
|
||
/**
|
||
* 取一个看板该用什么颜色。
|
||
*
|
||
* @param string|null $color 库里存的(可能为 NULL / 空 / 非法值)
|
||
* @param string $name 看板名(没存颜色时按它派生)
|
||
* @return string '#RRGGBB'
|
||
*/
|
||
public static function boardColor(?string $color, string $name = ''): string
|
||
{
|
||
$c = strtolower(trim((string)$color));
|
||
if (preg_match('/^#[0-9a-f]{6}$/', $c)) {
|
||
return $c;
|
||
}
|
||
// 按名字派生:同一个名字永远同一个色(改 id 不影响,用户能记住)
|
||
$h = 0;
|
||
foreach (preg_split('//u', $name, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $ch) {
|
||
// 用码点而不是字节:中文名在不同编码下字节不同,码点是稳的
|
||
$h = ($h * 31 + (int)(mb_ord($ch, 'UTF-8') ?: 0)) % 1000000007;
|
||
}
|
||
return self::BOARD_COLORS[abs($h) % count(self::BOARD_COLORS)];
|
||
}
|
||
|
||
/** 看板 id → 颜色(board_name 一起取,省一次查询;查不到给灰) */
|
||
public static function boardColorById(int $familyId, ?int $boardId): string
|
||
{
|
||
if (!$boardId) {
|
||
return self::boardColor(null, '默认看板');
|
||
}
|
||
$st = Database::get()->prepare('SELECT name, color FROM task_boards WHERE id = ? AND family_id = ?');
|
||
$st->execute([$boardId, $familyId]);
|
||
$r = $st->fetch();
|
||
if (!$r) {
|
||
return self::boardColor(null, '');
|
||
}
|
||
return self::boardColor($r['color'] ?? null, (string)$r['name']);
|
||
}
|
||
|
||
/**
|
||
* 改看板名 + 颜色(设置页那一行表单)。
|
||
*
|
||
* ⚠️ 颜色与名字**同一个动作**:用户改色时不该被迫把名字也重打一遍
|
||
* (表单里两个都在、一起提交,这里是它们共同的落点)。
|
||
* ⚠️ 颜色合法性在这里收口(只认 `#RRGGBB`),空串 = 清除自定义、回到按名派生。
|
||
*/
|
||
public static function updateBoard(int $familyId, int $id, string $name, string $color): void
|
||
{
|
||
$db = Database::get();
|
||
$c = strtolower(trim($color));
|
||
$c = preg_match('/^#[0-9a-f]{6}$/', $c) ? $c : null;
|
||
$db->prepare('UPDATE task_boards SET name = ?, color = ? WHERE id = ? AND family_id = ?')
|
||
->execute([self::shortName($name), $c, $id, $familyId]);
|
||
}
|
||
|
||
/**
|
||
* 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。
|
||
* @return int 默认看板的 id
|
||
*/
|
||
public static function ensureDefaultBoard(int $familyId): int
|
||
{
|
||
$db = Database::get();
|
||
$st = $db->prepare('SELECT id FROM task_boards WHERE family_id = ? ORDER BY position, id LIMIT 1');
|
||
$st->execute([$familyId]);
|
||
$id = (int)$st->fetchColumn();
|
||
if ($id > 0) {
|
||
return $id;
|
||
}
|
||
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
|
||
->execute([$familyId, '日常', 100]);
|
||
return (int)$db->lastInsertId();
|
||
}
|
||
|
||
/** @return int 新建看板的 id */
|
||
public static function createBoard(int $familyId, string $name): int
|
||
{
|
||
$db = Database::get();
|
||
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
|
||
->execute([$familyId, self::shortName($name), self::nextBoardPosition($familyId)]);
|
||
return (int)$db->lastInsertId();
|
||
}
|
||
|
||
/* ⚠️ 原来的 `renameBoard()` 已在 v24 第 26 批**并入 `updateBoard()`**(用户第 3 条
|
||
要给看板加颜色,两个字段同一个动作),这里不再保留 —— 留一个只改名字的方法,
|
||
下一个改颜色的人会顺手调错它("改了色但名字被重置"这类事故就是这么来的)。 */
|
||
|
||
/**
|
||
* 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。
|
||
*/
|
||
public static function deleteBoard(int $familyId, int $id): void
|
||
{
|
||
$db = Database::get();
|
||
$st = $db->prepare('SELECT COUNT(*) FROM task_boards WHERE family_id = ?');
|
||
$st->execute([$familyId]);
|
||
if ((int)$st->fetchColumn() <= 1) {
|
||
return; // 最后一个看板不许删:删了界面上就没处放任务
|
||
}
|
||
$def = self::ensureDefaultBoard($familyId);
|
||
$db->prepare('UPDATE tasks SET board_id = ? WHERE board_id = ? AND family_id = ?')
|
||
->execute([$def === $id ? null : $def, $id, $familyId]);
|
||
$db->prepare('DELETE FROM task_boards WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
|
||
}
|
||
|
||
/** @return int 新建分类的 id */
|
||
public static function createCategory(int $familyId, string $name): int
|
||
{
|
||
$db = Database::get();
|
||
$db->prepare('INSERT INTO task_categories (family_id, name, position) VALUES (?,?,?)')
|
||
->execute([$familyId, self::shortName($name), self::nextCategoryPosition($familyId)]);
|
||
return (int)$db->lastInsertId();
|
||
}
|
||
|
||
public static function renameCategory(int $familyId, int $id, string $name): void
|
||
{
|
||
Database::get()->prepare('UPDATE task_categories SET name = ? WHERE id = ? AND family_id = ?')
|
||
->execute([self::shortName($name), $id, $familyId]);
|
||
}
|
||
|
||
/** 删分类:用过的任务变成「未分类」,任务本身不动 */
|
||
public static function deleteCategory(int $familyId, int $id): void
|
||
{
|
||
$db = Database::get();
|
||
$db->prepare('UPDATE tasks SET category_id = NULL WHERE category_id = ? AND family_id = ?')
|
||
->execute([$id, $familyId]);
|
||
$db->prepare('DELETE FROM task_categories WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
|
||
}
|
||
|
||
/**
|
||
* 已完成的任务(v24 第 10 条:从看板上消失,另外一处按分类查看/搜索)。
|
||
*
|
||
* @param int $categoryId 0=全部分类
|
||
* @param string $keyword 标题里含这个串(空=不限)
|
||
*/
|
||
public static function doneList(int $familyId, int $personId, int $categoryId = 0, string $keyword = ''): array
|
||
{
|
||
$kw = trim($keyword);
|
||
$sql = 'SELECT t.*, c.name AS category_name,
|
||
a.name AS assignee_name, a.avatar, a.avatar_color,
|
||
na.nickname AS assignee_nick
|
||
FROM tasks t
|
||
LEFT JOIN task_categories c ON c.id = t.category_id
|
||
LEFT JOIN persons a ON a.id = t.assignee_id
|
||
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
|
||
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
|
||
WHERE t.family_id = ? AND t.status = "done" AND t.parent_id IS NULL
|
||
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)';
|
||
$args = [$personId, $personId, $familyId, $personId, $personId];
|
||
if ($categoryId > 0) {
|
||
$sql .= ' AND t.category_id = ?';
|
||
$args[] = $categoryId;
|
||
}
|
||
if ($kw !== '') {
|
||
$sql .= ' AND t.title LIKE ?';
|
||
$args[] = '%' . $kw . '%';
|
||
}
|
||
$sql .= ' ORDER BY t.done_at DESC, t.id DESC';
|
||
$st = Database::get()->prepare($sql);
|
||
$st->execute($args);
|
||
return $st->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* 看板 id 归一化:0 / 不存在的 id → 默认看板,并且**必须属于这个家庭**
|
||
* (不校验的话,改一下表单就能把任务挂到别人家的看板上)。
|
||
* @return int|null NULL 表示"用默认看板"落在库里也存 NULL,读的时候 COALESCE 回来
|
||
*/
|
||
private static function boardIdOf(int $familyId, int $id): ?int
|
||
{
|
||
if ($id <= 0) {
|
||
return self::ensureDefaultBoard($familyId);
|
||
}
|
||
$st = Database::get()->prepare('SELECT id FROM task_boards WHERE id = ? AND family_id = ?');
|
||
$st->execute([$id, $familyId]);
|
||
$v = $st->fetchColumn();
|
||
return $v === false ? null : (int)$v;
|
||
}
|
||
|
||
/** 分类 id 归一化:0 = 未分类(NULL);不属于这个家庭的一律不认 */
|
||
private static function categoryIdOf(int $familyId, int $id): ?int
|
||
{
|
||
if ($id <= 0) {
|
||
return null;
|
||
}
|
||
$st = Database::get()->prepare('SELECT id FROM task_categories WHERE id = ? AND family_id = ?');
|
||
$st->execute([$id, $familyId]);
|
||
$v = $st->fetchColumn();
|
||
return $v === false ? null : (int)$v;
|
||
}
|
||
|
||
private static function shortName(string $n): string
|
||
{
|
||
$n = trim(preg_replace('/\s+/u', ' ', $n ?? ''));
|
||
return mb_substr($n, 0, 40, 'UTF-8');
|
||
}
|
||
|
||
private static function nextBoardPosition(int $familyId): int
|
||
{
|
||
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_boards WHERE family_id = ?');
|
||
$st->execute([$familyId]);
|
||
return (int)$st->fetchColumn();
|
||
}
|
||
|
||
private static function nextCategoryPosition(int $familyId): int
|
||
{
|
||
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_categories WHERE family_id = ?');
|
||
$st->execute([$familyId]);
|
||
return (int)$st->fetchColumn();
|
||
}
|
||
|
||
// ---------------- 读 ----------------
|
||
|
||
/**
|
||
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
|
||
*
|
||
* ⚠️ v24(用户 2026-10-03 第 7 条):**看板的列 = 用户自己建的看板**,
|
||
* 不再是 todo / doing 两列。原话:「任务模块跟我想的不同,每个人自定义的看板,
|
||
* 不是显示在现在的看板上面,而是**直接代替现在的待办和进行中**,是看板,
|
||
* 而不是看板的分类。」
|
||
* 所以 `status` 仍然存着 todo/doing(拖动换栏 = 改 status),
|
||
* 但**界面上的列**是 task_boards 里的那些行 —— 一列一个看板。
|
||
* 这一层只负责"把任务按看板分堆",列名/顺序由 boards() 给。
|
||
*
|
||
* 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
|
||
*
|
||
* @return array<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, b.color AS board_color,
|
||
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
|
||
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done
|
||
FROM tasks t
|
||
LEFT JOIN persons a ON a.id = t.assignee_id
|
||
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
|
||
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
|
||
LEFT JOIN task_categories c ON c.id = t.category_id
|
||
LEFT JOIN task_boards b ON b.id = t.board_id
|
||
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status = ?
|
||
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
|
||
ORDER BY COALESCE(t.due_at, ?) ASC, t.position, t.id'
|
||
);
|
||
$st->execute([$personId, $personId, $familyId, $status, $personId, $personId, '9999-12-31 00:00:00']);
|
||
$rows = $st->fetchAll();
|
||
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
|
||
foreach ($rows as &$r) {
|
||
$r['children'] = $children[(int)$r['id']] ?? [];
|
||
}
|
||
unset($r);
|
||
return $rows;
|
||
}
|
||
|
||
/** @return array<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> 再转义会把标签变成 <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 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;
|
||
}
|
||
}
|