Files
FamilyBook/app/Task.php
T
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

983 lines
41 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 任务(v12,用户 2026-10-01 第 9 条):看板 + 两级子任务 + 评论@人 + 共享 + 循环。
*
* 看板三列直接落在 `status` 上(todo / doing / done),不另存"列"字段 ——
* "列"和"状态"本来就是一件事,存两份必然对不上(拖到 done 了状态还是 doing 之类)。
*
* 两级:只做父任务 / 子任务两层(parent_id)。**不做无限层级** ——
* 家庭任务("周末大扫除"下面挂"擦玻璃""洗车")两层足够,
* 无限层级要递归查询 + 缩进渲染,代价远大于收益。
* ⚠️ 子任务的 status 独立于父任务:父任务可以还在 doing 而某个子任务已经 done,
* 这正好是"还剩几件没做"的进度条来源。
*
* 循环:标为 done 时,如果 repeat_rule 非空,就**新生成一条**下个周期的任务,
* 原任务保留在 done 列里(历史可回看)。不原地改 due_at ——
* 那样"上次是什么时候做的"就永远消失了。
*/
declare(strict_types=1);
namespace App;
final class Task
{
public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成'];
public const REPEAT = [
'' => '不循环',
'day' => '每天',
'week' => '每周',
'month' => '每月',
'year' => '每年',
];
/** 附件的宿主类型 */
public const ATTACH = 'task';
// ---------------- 看板与分类(v24 用户第 9、10 条)----------------
/**
* 这个家庭的看板列表。
*
* ⚠️ 「看板」与「状态」是两件事:状态是这条任务做到哪一步,看板是它属于哪一块
* (家务 / 装修 / 旅行准备)。之前只有状态,想分开看只能给标题加前缀。
*/
public static function boards(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT b.*, (SELECT COUNT(*) FROM tasks t WHERE t.board_id = b.id AND t.parent_id IS NULL
AND t.status <> "done") AS open_count
FROM task_boards b WHERE b.family_id = ? ORDER BY b.position, b.id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function categories(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT c.*, (SELECT COUNT(*) FROM tasks t WHERE t.category_id = c.id) AS used_count
FROM task_categories c WHERE c.family_id = ? ORDER BY c.position, c.id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function boardName(int $familyId, ?int $boardId): string
{
if (!$boardId) {
return '默认看板';
}
$st = Database::get()->prepare('SELECT name FROM task_boards WHERE id = ? AND family_id = ?');
$st->execute([$boardId, $familyId]);
$v = $st->fetchColumn();
return $v === false ? '' : (string)$v;
}
/**
* 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。
* @return int 默认看板的 id
*/
public static function ensureDefaultBoard(int $familyId): int
{
$db = Database::get();
$st = $db->prepare('SELECT id FROM task_boards WHERE family_id = ? ORDER BY position, id LIMIT 1');
$st->execute([$familyId]);
$id = (int)$st->fetchColumn();
if ($id > 0) {
return $id;
}
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, '日常', 100]);
return (int)$db->lastInsertId();
}
/** @return int 新建看板的 id */
public static function createBoard(int $familyId, string $name): int
{
$db = Database::get();
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, self::shortName($name), self::nextBoardPosition($familyId)]);
return (int)$db->lastInsertId();
}
public static function renameBoard(int $familyId, int $id, string $name): void
{
Database::get()->prepare('UPDATE task_boards SET name = ? WHERE id = ? AND family_id = ?')
->execute([self::shortName($name), $id, $familyId]);
}
/**
* 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。
*/
public static function deleteBoard(int $familyId, int $id): void
{
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM task_boards WHERE family_id = ?');
$st->execute([$familyId]);
if ((int)$st->fetchColumn() <= 1) {
return; // 最后一个看板不许删:删了界面上就没处放任务
}
$def = self::ensureDefaultBoard($familyId);
$db->prepare('UPDATE tasks SET board_id = ? WHERE board_id = ? AND family_id = ?')
->execute([$def === $id ? null : $def, $id, $familyId]);
$db->prepare('DELETE FROM task_boards WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/** @return int 新建分类的 id */
public static function createCategory(int $familyId, string $name): int
{
$db = Database::get();
$db->prepare('INSERT INTO task_categories (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, self::shortName($name), self::nextCategoryPosition($familyId)]);
return (int)$db->lastInsertId();
}
public static function renameCategory(int $familyId, int $id, string $name): void
{
Database::get()->prepare('UPDATE task_categories SET name = ? WHERE id = ? AND family_id = ?')
->execute([self::shortName($name), $id, $familyId]);
}
/** 删分类:用过的任务变成「未分类」,任务本身不动 */
public static function deleteCategory(int $familyId, int $id): void
{
$db = Database::get();
$db->prepare('UPDATE tasks SET category_id = NULL WHERE category_id = ? AND family_id = ?')
->execute([$id, $familyId]);
$db->prepare('DELETE FROM task_categories WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/**
* 已完成的任务(v24 第 10 条:从看板上消失,另外一处按分类查看/搜索)。
*
* @param int $categoryId 0=全部分类
* @param string $keyword 标题里含这个串(空=不限)
*/
public static function doneList(int $familyId, int $personId, int $categoryId = 0, string $keyword = ''): array
{
$kw = trim($keyword);
$sql = 'SELECT t.*, c.name AS category_name,
a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick
FROM tasks t
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
WHERE t.family_id = ? AND t.status = "done" AND t.parent_id IS NULL
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)';
$args = [$personId, $personId, $familyId, $personId, $personId];
if ($categoryId > 0) {
$sql .= ' AND t.category_id = ?';
$args[] = $categoryId;
}
if ($kw !== '') {
$sql .= ' AND t.title LIKE ?';
$args[] = '%' . $kw . '%';
}
$sql .= ' ORDER BY t.done_at DESC, t.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
/**
* 看板 id 归一化:0 / 不存在的 id → 默认看板,并且**必须属于这个家庭**
* (不校验的话,改一下表单就能把任务挂到别人家的看板上)。
* @return int|null NULL 表示"用默认看板"落在库里也存 NULL,读的时候 COALESCE 回来
*/
private static function boardIdOf(int $familyId, int $id): ?int
{
if ($id <= 0) {
return self::ensureDefaultBoard($familyId);
}
$st = Database::get()->prepare('SELECT id FROM task_boards WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$v = $st->fetchColumn();
return $v === false ? null : (int)$v;
}
/** 分类 id 归一化:0 = 未分类(NULL);不属于这个家庭的一律不认 */
private static function categoryIdOf(int $familyId, int $id): ?int
{
if ($id <= 0) {
return null;
}
$st = Database::get()->prepare('SELECT id FROM task_categories WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$v = $st->fetchColumn();
return $v === false ? null : (int)$v;
}
private static function shortName(string $n): string
{
$n = trim(preg_replace('/\s+/u', ' ', $n ?? ''));
return mb_substr($n, 0, 40, 'UTF-8');
}
private static function nextBoardPosition(int $familyId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_boards WHERE family_id = ?');
$st->execute([$familyId]);
return (int)$st->fetchColumn();
}
private static function nextCategoryPosition(int $familyId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_categories WHERE family_id = ?');
$st->execute([$familyId]);
return (int)$st->fetchColumn();
}
// ---------------- 读 ----------------
/**
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
*
* ⚠️ v24(用户 2026-10-03 第 7 条):**看板的列 = 用户自己建的看板**,
* 不再是 todo / doing 两列。原话:「任务模块跟我想的不同,每个人自定义的看板,
* 不是显示在现在的看板上面,而是**直接代替现在的待办和进行中**,是看板,
* 而不是看板的分类。」
* 所以 `status` 仍然存着 todo/doing(拖动换栏 = 改 status),
* 但**界面上的列**是 task_boards 里的那些行 —— 一列一个看板。
* 这一层只负责"把任务按看板分堆",列名/顺序由 boards() 给。
*
* 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*
* @return array<int, array> 看板 id => 该列的任务(键 0 = 没有看板归属的兜底列)
*/
public static function boardColumns(int $familyId, int $personId): array
{
$boards = self::boards($familyId);
$defId = self::ensureDefaultBoard($familyId);
$cols = [];
foreach ($boards as $b) {
$cols[(int)$b['id']] = [];
}
// ⚠️ 兜底列(键 0):老任务的 board_id 是 NULL,或者看板被删过之后没搬到默认看板。
// 必须有个地方放它们,否则那些任务在看板上**直接消失**(比显示错更糟)。
$cols[0] = [];
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done,
(SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count,
(SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
LEFT JOIN task_categories c ON c.id = t.category_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status <> "done"
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.status, t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $personId, $personId]);
$rows = $st->fetchAll();
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as $r) {
$r['children'] = $children[(int)$r['id']] ?? [];
$bid = (int)($r['board_id'] ?? 0);
if (!isset($cols[$bid])) {
$bid = 0; // 看板已被删 / 不认识 → 落进兜底列
}
$cols[$bid][] = $r;
}
return ['cols' => $cols, 'boards' => $boards, 'defId' => $defId];
}
/**
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
*
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*
* @param int $boardId 0 = 全部看板
*/
public static function board(int $familyId, int $personId, int $boardId = 0): array
{
// ⚠️ v24 第 10 条:**已完成不出现在看板里**(用户原话「直接消失」)。
// 所以这里根本不查 status='done';它们改由 doneList() 单独呈现。
// 看板上仍然只有 todo / doing 两列 —— 第三列没了,才是"已完成消失"。
// 老任务的 board_id 是 NULL → 一律算在**默认看板**上(COALESCE 到默认看板 id)
$defId = self::ensureDefaultBoard($familyId);
$boardId = $boardId > 0 ? $boardId : $defId;
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done,
(SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count,
(SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
LEFT JOIN task_categories c ON c.id = t.category_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status <> "done"
AND COALESCE(t.board_id, ?) = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $defId, $boardId, $personId, $personId]);
$rows = $st->fetchAll();
// 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢)
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as &$r) {
$r['children'] = $children[(int)$r['id']] ?? [];
}
unset($r);
// ⚠️ 只有 todo / doing 两列:done 已经从看板上撤走了(v24 第 10 条)。
// 保留 'done' 这个键(恒空)只为兼容个别直接按 key 取的老调用。
$out = ['todo' => [], 'doing' => [], 'done' => []];
foreach ($rows as $r) {
$k = (string)$r['status'];
$out[$k][] = $r;
}
return $out;
}
/** @return array<int,array> 父任务 id => 子任务列表 */
public static function childrenFor(array $parentIds, int $viewerId = 0): array
{
$out = [];
$ids = array_values(array_filter(array_map('intval', $parentIds)));
if (!$ids) {
return $out;
}
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT c.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick
FROM tasks c
LEFT JOIN persons a ON a.id = c.assignee_id
LEFT JOIN nicknames na ON na.target_id = c.assignee_id AND na.viewer_id = ?
WHERE c.parent_id IN ($in) ORDER BY c.position, c.id"
);
$st->execute(array_merge([$viewerId], $ids));
foreach ($st->fetchAll() as $c) {
$out[(int)$c['parent_id']][] = $c;
}
return $out;
}
/**
* 一条任务(带子任务 / 评论 / 附件 / 共享)。看不到返回 null。
*/
public static function find(int $familyId, int $id, int $personId): ?array
{
$st = Database::get()->prepare(
// ⚠️ 父任务的标题列叫 `title`,**不是 `name`** —— 这里写过 `p.name`,
// php -l 一点问题都没有(SQL 是字符串),只在真的打开任务详情页时
// 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以
// "200 + Fatal error 正文"吐出来,看着像页面正常返回。
// 改 SQL 之后一定要用「真实库表结构」校验列名(见 sql-schema-column-check)。
'SELECT t.*, p.title AS parent_title,
a.name AS assignee_name, a.avatar AS assignee_avatar, a.avatar_color AS assignee_color,
na.nickname AS assignee_nick,
cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color,
nc.nickname AS creator_nick
FROM tasks t
LEFT JOIN tasks p ON p.id = t.parent_id
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN persons cr ON cr.id = t.creator_id
LEFT JOIN nicknames nc ON nc.target_id = t.creator_id AND nc.viewer_id = ?
WHERE t.id = ? AND t.family_id = ?'
);
$st->execute([$personId, $personId, $id, $familyId]);
$t = $st->fetch();
if (!$t) {
return null;
}
if (!self::visibleTo($t, $personId)) {
return null;
}
// 子任务的任务详情里要能看到父任务,父任务也要能看到全部兄弟
$t['children'] = self::childrenFor([(int)$t['id']], $personId)[(int)$t['id']] ?? [];
$t['comments'] = self::comments((int)$t['id'], $personId);
$t['attachs'] = \App\Attachment::list(self::ATTACH, (int)$t['id']);
$t['shares'] = self::shares((int)$t['id']);
return $t;
}
/** 我能看这条吗(建的 / 分给我的 / 共享给我的 / 父任务的子任务) */
public static function visibleTo(array $t, int $personId): bool
{
if ((int)($t['creator_id'] ?? 0) === $personId || (int)($t['assignee_id'] ?? 0) === $personId) {
return true;
}
if (self::isShared((int)$t['id'], $personId)) {
return true;
}
// 子任务:父任务能看,子任务也就能看(否则点开父任务会看到一半的子任务)
$pid = (int)($t['parent_id'] ?? 0);
if ($pid > 0) {
$st = Database::get()->prepare(
'SELECT creator_id, assignee_id FROM tasks WHERE id = ?'
);
$st->execute([$pid]);
$p = $st->fetch();
if ($p && ((int)$p['creator_id'] === $personId || (int)$p['assignee_id'] === $personId
|| self::isShared($pid, $personId))) {
return true;
}
}
return false;
}
public static function isShared(int $taskId, int $personId): bool
{
$st = Database::get()->prepare('SELECT 1 FROM task_shares WHERE task_id = ? AND person_id = ?');
$st->execute([$taskId, $personId]);
return $st->fetchColumn() !== false;
}
/** 我能改吗(建的人 / 负责人 / 共享为 write / 子任务随父) */
public static function canWrite(array $t, int $personId): bool
{
$id = (int)($t['id'] ?? 0);
if ($id <= 0) {
return false;
}
if ((int)($t['creator_id'] ?? 0) === $personId || (int)($t['assignee_id'] ?? 0) === $personId) {
return true;
}
if (self::sharePerm($id, $personId) === 'write') {
return true;
}
// 子任务的权限跟着父任务走:父任务能改,它的子任务也就能改
$pid = (int)($t['parent_id'] ?? 0);
if ($pid > 0) {
$st = Database::get()->prepare('SELECT id, parent_id, creator_id, assignee_id FROM tasks WHERE id = ?');
$st->execute([$pid]);
$p = $st->fetch();
if ($p) {
return self::canWrite($p, $personId);
}
}
return false;
}
/** 我对这条任务的共享权限:'' / 'read' / 'write' */
private static function sharePerm(int $taskId, int $personId): string
{
$st = Database::get()->prepare('SELECT perm FROM task_shares WHERE task_id = ? AND person_id = ?');
$st->execute([$taskId, $personId]);
$v = $st->fetchColumn();
return $v === false ? '' : (string)$v;
}
// ---------------- 写 ----------------
/** @return int 新任务 id */
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO tasks (family_id, board_id, category_id, parent_id, title, note, status, due_at,
position, creator_id, assignee_id, repeat_rule)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$familyId,
self::boardIdOf($familyId, (int)($d['board_id'] ?? 0)),
self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0)),
self::parentId($familyId, (int)($d['parent_id'] ?? 0)),
self::title($d['title'] ?? ''),
self::textOrNull($d['note'] ?? ''),
self::status($d['status'] ?? ''),
self::dtOrNull($d['due_at'] ?? ''),
self::nextPosition($familyId, self::status($d['status'] ?? '')),
$creatorId ?: null,
(int)($d['assignee_id'] ?? 0) ?: null,
self::repeat($d['repeat_rule'] ?? ''),
]);
return (int)$db->lastInsertId();
}
public static function update(int $familyId, int $id, array $d): void
{
$db = Database::get();
$old = self::row($familyId, $id);
if (!$old) {
return;
}
// ⚠️ 父任务不能把自己挂到自己(或自己的子孙)下面 —— 会成环,
// 看板递归时会无限展开。直接忽略这种改动。
$pid = self::parentId($familyId, (int)($d['parent_id'] ?? 0), $id);
$status = self::status($d['status'] ?? '');
$db->prepare(
'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?,
parent_id=?, board_id=?, category_id=? WHERE id=? AND family_id=?'
)->execute([
self::title($d['title'] ?? ''),
self::textOrNull($d['note'] ?? ''),
$status,
self::dtOrNull($d['due_at'] ?? ''),
(int)($d['assignee_id'] ?? 0) ?: null,
self::repeat($d['repeat_rule'] ?? ''),
$pid,
array_key_exists('board_id', $d) ? self::boardIdOf($familyId, (int)$d['board_id']) : $old['board_id'],
array_key_exists('category_id', $d)
? self::categoryIdOf($familyId, (int)$d['category_id'])
: $old['category_id'],
$id, $familyId,
]);
if ($status === 'done' && (string)$old['status'] !== 'done') {
self::markDone($familyId, $id);
}
}
/**
* 改状态(看板拖动 / 勾复选框都走这里)。
*
* ⚠️ 循环在这里发生:变成 done 且 repeat_rule 非空 → 生成下一条。
* 只在**从非 done 变成 done** 时生成(重复提交 / 刷新不会刷出一堆)。
*
* @return int 因循环而新生成的任务 id(没有则 0)
*/
public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int
{
$old = self::row($familyId, $id);
if (!$old) {
return 0;
}
$status = self::status($status);
$db = Database::get();
if ($position > 0) {
$db->prepare('UPDATE tasks SET status=?, position=? WHERE id=? AND family_id=?')
->execute([$status, $position, $id, $familyId]);
} else {
$db->prepare('UPDATE tasks SET status=? WHERE id=? AND family_id=?')
->execute([$status, $id, $familyId]);
}
if ($status === 'done' && (string)$old['status'] !== 'done') {
self::markDone($familyId, $id);
return self::spawnNext($familyId, $old);
}
return 0;
}
/**
* 看板里搬一张卡:改状态 + 插到指定位置。
*
* ⚠️ 位置是**整列重排**(10, 20, 30…)而不是"取相邻两个 position 的中点":
* 中点在两卡相邻时会挤不开(pos 相同 → 顺序由 id 兜底 → 拖了跟没拖一样),
* 而一列通常只有几十条,按序重排的代价可以忽略,换来的是永远不会排错。
*
* @param int $beforeId 放在这张卡**前面**(0 = 放到末尾)
*/
public static function moveTo(int $familyId, int $id, string $status, int $beforeId = 0): void
{
$old = self::row($familyId, $id);
if (!$old) {
return;
}
$status = self::status($status);
$db = Database::get();
$st = $db->prepare(
'SELECT id FROM tasks WHERE family_id = ? AND status = ? AND parent_id IS NULL AND id <> ?
ORDER BY position, id'
);
$st->execute([$familyId, $status, $id]);
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
$idx = count($ids);
if ($beforeId > 0) {
$k = array_search($beforeId, $ids, true);
if ($k !== false) {
$idx = (int)$k;
}
}
array_splice($ids, $idx, 0, [$id]);
$up = $db->prepare('UPDATE tasks SET status = ?, position = ? WHERE id = ? AND family_id = ?');
foreach ($ids as $i => $tid) {
$up->execute([$status, ($i + 1) * 10, $tid, $familyId]);
}
if ($status === 'done' && (string)$old['status'] !== 'done') {
self::markDone($familyId, $id);
self::spawnNext($familyId, $old);
}
}
/**
* 看板里把一张卡搬到**另一个看板**(v24 用户 2026-10-03 第 7 条)。
*
* ⚠️ 与 moveTo() 的分工(别再混起来):
* · v24 第 7 条之前,界面上的两列是「待办 / 进行中」→ 搬卡 = 改 status(moveTo);
* · 现在界面上的一列一个**看板** → 搬卡 = 改 board_id(这个方法)。
* 两个字段都还在、都有用:board 是"属于哪摊事",status 是"做到哪一步"。
* 看板上只显示未完成的(status<>done),所以搬卡不动 status ——
* 顺手把 status 重置成 todo 反而会把用户"进行中"的标记抹掉。
*
* @param int $beforeId 放在这张卡**前面**(0 = 放到末尾)
* @param int $personId 谁在搬(用来只在他看得见的那些卡里排序)
*/
public static function moveToBoard(int $familyId, int $id, int $boardId, int $beforeId = 0, int $personId = 0): void
{
$old = self::row($familyId, $id);
if (!$old) {
return;
}
// 看板 id 归一化:0 或不认识的一律落到默认看板(不校验就等着"搬到别人家的看板")
$target = self::boardIdOf($familyId, $boardId);
$db = Database::get();
if ($target === null) {
$target = self::ensureDefaultBoard($familyId);
}
// 同列重排:把这一列的 id 按目标顺序重排成 10/20/30…
// (与 moveTo 同一套做法:中点法在两卡相邻时会挤不开,整列重排永远不会排错)
// ⚠️ 排序范围按"这个人能看到的卡"算 —— 否则把别人私有的任务也算进来,
// position 会被那些看不见的卡占掉(表现为"拖了跟没拖一样")。
$st = $db->prepare(
'SELECT t.id FROM tasks t
WHERE t.family_id = ? AND t.status <> "done" AND t.parent_id IS NULL AND t.id <> ?
AND COALESCE(t.board_id, 0) = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR EXISTS (
SELECT 1 FROM task_shares sh WHERE sh.task_id = t.id AND sh.person_id = ?))
ORDER BY t.position, t.id'
);
$st->execute([$familyId, $id, $target, $personId, $personId, $personId]);
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
$idx = count($ids);
if ($beforeId > 0) {
$k = array_search($beforeId, $ids, true);
if ($k !== false) {
$idx = (int)$k;
}
}
array_splice($ids, $idx, 0, [$id]);
$up = $db->prepare('UPDATE tasks SET board_id = ?, position = ? WHERE id = ? AND family_id = ?');
foreach ($ids as $i => $tid) {
$up->execute([$target, ($i + 1) * 10, $tid, $familyId]);
}
}
public static function destroy(int $familyId, int $id): void
{
// 子任务随父任务级联(外键 ON DELETE CASCADE 已保证),
// 但**附件与评论里的附件**没有外键,得自己清。
$st = Database::get()->prepare('SELECT id FROM tasks WHERE family_id = ? AND (id = ? OR parent_id = ?)');
$st->execute([$familyId, $id, $id]);
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
foreach ($ids as $tid) {
\App\Attachment::removeAll(self::ATTACH, $tid);
$cs = Database::get()->prepare('SELECT id FROM task_comments WHERE task_id = ?');
$cs->execute([$tid]);
foreach ($cs->fetchAll() as $c) {
\App\Attachment::removeAll('task_comment', (int)$c['id']);
}
}
Database::get()->prepare('DELETE FROM tasks WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
// ---------------- 评论 ----------------
public static function comments(int $taskId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT m.*, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM task_comments m JOIN persons p ON p.id = m.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE m.task_id = ? ORDER BY m.created_at, m.id'
);
$st->execute([$viewerId, $taskId]);
return $st->fetchAll();
}
public static function addComment(int $taskId, int $personId, string $content): int
{
$content = trim($content);
if ($content === '') {
return 0;
}
$db = Database::get();
$db->prepare('INSERT INTO task_comments (task_id, person_id, content) VALUES (?,?,?)')
->execute([$taskId, $personId, mb_substr($content, 0, 4000, 'UTF-8')]);
return (int)$db->lastInsertId();
}
public static function deleteComment(int $id, int $personId): void
{
// 只能删自己的(管理员也不删别人的留言 —— 家庭场景下这是聊天记录)
Database::get()->prepare('DELETE FROM task_comments WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
/**
* 把评论里的 @某人 高亮出来。
*
* ⚠️ 先转义、再替换:**绝不能**反过来(先塞 <b> 再转义会把标签变成 &lt;b&gt;,
* 页面上一堆字面的尖括号)。匹配按"长的名字优先",
* 否则「@张三丰」里的「@张三」会先被短名字命中,剩下「丰」没被高亮。
*
* @param array $names 可匹配的名字(含昵称),按长度降序排好
*/
public static function commentHtml(string $content, array $names): string
{
$html = e($content);
$pairs = [];
foreach ($names as $nm) {
$nm = trim((string)$nm);
if ($nm !== '') {
$pairs[$nm] = '<b class="mention">@' . e($nm) . '</b>';
}
}
uksort($pairs, static fn($a, $b) => mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8'));
foreach ($pairs as $nm => $rep) {
$html = str_replace('@' . e($nm), $rep, $html);
}
return nl2br($html);
}
// ---------------- 共享 ----------------
public static function shares(int $taskId): array
{
$st = Database::get()->prepare(
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM task_shares s JOIN persons p ON p.id = s.person_id
LEFT JOIN nicknames n ON n.target_id = p.id
WHERE s.task_id = ? ORDER BY p.id'
);
$st->execute([$taskId]);
return $st->fetchAll();
}
public static function setShare(int $taskId, int $personId, string $perm): void
{
if ($personId <= 0) {
return;
}
Database::get()->prepare(
'INSERT INTO task_shares (task_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$taskId, $personId, $perm === 'read' ? 'read' : 'write']);
}
public static function dropShare(int $taskId, int $personId): void
{
Database::get()->prepare('DELETE FROM task_shares WHERE task_id = ? AND person_id = ?')
->execute([$taskId, $personId]);
}
// ---------------- 展示 ----------------
public static function statusWord(string $k): string
{
return self::STATUS[$k] ?? '待办';
}
public static function 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;
}
}