v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。 观影(1-4) - 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法) - 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库), 瀑布流卡片也按人显示;五处观影模板全部改用它 - 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名) - 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块) 家谱(5) - 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹", 根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人) - focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方 —— 修掉"母亲登录看不到孩子" - 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势) 统一事件原语(6-9) - 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。 复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留) - 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本, 内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变); 没有具体时分 = 全天;月视图参照群晖 Calendar - 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写, 默认折叠、可搜标题与正文 - 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期) - 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算 模块化(10-11) - App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现) - 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来) - 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色 其他(12-13) - 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己) - 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。 家人卡片从首页撤走(看人去名册) 验证(全部通过) - e2e 542 条(新增 4 模块端到端 60+ 条) - 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123 - SQL 列名静态校验 0 处可疑(对着真实表结构) - 反向验证 18 条新注入全部抓住 顺带修掉的既有问题 - 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级) - 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备 - 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
This commit is contained in:
+654
@@ -0,0 +1,654 @@
|
||||
<?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';
|
||||
|
||||
// ---------------- 读 ----------------
|
||||
|
||||
/**
|
||||
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
|
||||
*
|
||||
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
|
||||
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
|
||||
*/
|
||||
public static function board(int $familyId, int $personId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
|
||||
na.nickname AS assignee_nick,
|
||||
(SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id) AS child_total,
|
||||
(SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id AND c.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 = ?
|
||||
WHERE t.family_id = ? AND t.parent_id IS NULL
|
||||
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, $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);
|
||||
|
||||
$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, parent_id, title, note, status, due_at, position,
|
||||
creator_id, assignee_id, repeat_rule)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)'
|
||||
)->execute([
|
||||
$familyId,
|
||||
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=? 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,
|
||||
$id, $familyId,
|
||||
]);
|
||||
if ($status === 'done' && (string)$old['status'] !== 'done') {
|
||||
self::markDone($familyId, $id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 改状态(看板拖动 / 勾复选框都走这里)。
|
||||
*
|
||||
* ⚠️ 循环在这里发生:变成 done 且 repeat_rule 非空 → 生成下一条。
|
||||
* 只在**从非 done 变成 done** 时生成(重复提交 / 刷新不会刷出一堆)。
|
||||
*
|
||||
* @return int 因循环而新生成的任务 id(没有则 0)
|
||||
*/
|
||||
public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int
|
||||
{
|
||||
$old = self::row($familyId, $id);
|
||||
if (!$old) {
|
||||
return 0;
|
||||
}
|
||||
$status = self::status($status);
|
||||
$db = Database::get();
|
||||
if ($position > 0) {
|
||||
$db->prepare('UPDATE tasks SET status=?, position=? WHERE id=? AND family_id=?')
|
||||
->execute([$status, $position, $id, $familyId]);
|
||||
} else {
|
||||
$db->prepare('UPDATE tasks SET status=? WHERE id=? AND family_id=?')
|
||||
->execute([$status, $id, $familyId]);
|
||||
}
|
||||
if ($status === 'done' && (string)$old['status'] !== 'done') {
|
||||
self::markDone($familyId, $id);
|
||||
return self::spawnNext($familyId, $old);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板里搬一张卡:改状态 + 插到指定位置。
|
||||
*
|
||||
* ⚠️ 位置是**整列重排**(10, 20, 30…)而不是"取相邻两个 position 的中点":
|
||||
* 中点在两卡相邻时会挤不开(pos 相同 → 顺序由 id 兜底 → 拖了跟没拖一样),
|
||||
* 而一列通常只有几十条,按序重排的代价可以忽略,换来的是永远不会排错。
|
||||
*
|
||||
* @param int $beforeId 放在这张卡**前面**(0 = 放到末尾)
|
||||
*/
|
||||
public static function moveTo(int $familyId, int $id, string $status, int $beforeId = 0): void
|
||||
{
|
||||
$old = self::row($familyId, $id);
|
||||
if (!$old) {
|
||||
return;
|
||||
}
|
||||
$status = self::status($status);
|
||||
$db = Database::get();
|
||||
$st = $db->prepare(
|
||||
'SELECT id FROM tasks WHERE family_id = ? AND status = ? AND parent_id IS NULL AND id <> ?
|
||||
ORDER BY position, id'
|
||||
);
|
||||
$st->execute([$familyId, $status, $id]);
|
||||
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||
|
||||
$idx = count($ids);
|
||||
if ($beforeId > 0) {
|
||||
$k = array_search($beforeId, $ids, true);
|
||||
if ($k !== false) {
|
||||
$idx = (int)$k;
|
||||
}
|
||||
}
|
||||
array_splice($ids, $idx, 0, [$id]);
|
||||
$up = $db->prepare('UPDATE tasks SET status = ?, position = ? WHERE id = ? AND family_id = ?');
|
||||
foreach ($ids as $i => $tid) {
|
||||
$up->execute([$status, ($i + 1) * 10, $tid, $familyId]);
|
||||
}
|
||||
if ($status === 'done' && (string)$old['status'] !== 'done') {
|
||||
self::markDone($familyId, $id);
|
||||
self::spawnNext($familyId, $old);
|
||||
}
|
||||
}
|
||||
|
||||
public static function destroy(int $familyId, int $id): void
|
||||
{
|
||||
// 子任务随父任务级联(外键 ON DELETE CASCADE 已保证),
|
||||
// 但**附件与评论里的附件**没有外键,得自己清。
|
||||
$st = Database::get()->prepare('SELECT id FROM tasks WHERE family_id = ? AND (id = ? OR parent_id = ?)');
|
||||
$st->execute([$familyId, $id, $id]);
|
||||
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||
foreach ($ids as $tid) {
|
||||
\App\Attachment::removeAll(self::ATTACH, $tid);
|
||||
$cs = Database::get()->prepare('SELECT id FROM task_comments WHERE task_id = ?');
|
||||
$cs->execute([$tid]);
|
||||
foreach ($cs->fetchAll() as $c) {
|
||||
\App\Attachment::removeAll('task_comment', (int)$c['id']);
|
||||
}
|
||||
}
|
||||
Database::get()->prepare('DELETE FROM tasks WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
|
||||
}
|
||||
|
||||
// ---------------- 评论 ----------------
|
||||
|
||||
public static function comments(int $taskId, int $viewerId = 0): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT m.*, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
|
||||
FROM task_comments m JOIN persons p ON p.id = m.person_id
|
||||
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
|
||||
WHERE m.task_id = ? ORDER BY m.created_at, m.id'
|
||||
);
|
||||
$st->execute([$viewerId, $taskId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
public static function addComment(int $taskId, int $personId, string $content): int
|
||||
{
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return 0;
|
||||
}
|
||||
$db = Database::get();
|
||||
$db->prepare('INSERT INTO task_comments (task_id, person_id, content) VALUES (?,?,?)')
|
||||
->execute([$taskId, $personId, mb_substr($content, 0, 4000, 'UTF-8')]);
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
public static function deleteComment(int $id, int $personId): void
|
||||
{
|
||||
// 只能删自己的(管理员也不删别人的留言 —— 家庭场景下这是聊天记录)
|
||||
Database::get()->prepare('DELETE FROM task_comments WHERE id = ? AND person_id = ?')
|
||||
->execute([$id, $personId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把评论里的 @某人 高亮出来。
|
||||
*
|
||||
* ⚠️ 先转义、再替换:**绝不能**反过来(先塞 <b> 再转义会把标签变成 <b>,
|
||||
* 页面上一堆字面的尖括号)。匹配按"长的名字优先",
|
||||
* 否则「@张三丰」里的「@张三」会先被短名字命中,剩下「丰」没被高亮。
|
||||
*
|
||||
* @param array $names 可匹配的名字(含昵称),按长度降序排好
|
||||
*/
|
||||
public static function commentHtml(string $content, array $names): string
|
||||
{
|
||||
$html = e($content);
|
||||
$pairs = [];
|
||||
foreach ($names as $nm) {
|
||||
$nm = trim((string)$nm);
|
||||
if ($nm !== '') {
|
||||
$pairs[$nm] = '<b class="mention">@' . e($nm) . '</b>';
|
||||
}
|
||||
}
|
||||
uksort($pairs, static fn($a, $b) => mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8'));
|
||||
foreach ($pairs as $nm => $rep) {
|
||||
$html = str_replace('@' . e($nm), $rep, $html);
|
||||
}
|
||||
return nl2br($html);
|
||||
}
|
||||
|
||||
// ---------------- 共享 ----------------
|
||||
|
||||
public static function shares(int $taskId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
|
||||
FROM task_shares s JOIN persons p ON p.id = s.person_id
|
||||
LEFT JOIN nicknames n ON n.target_id = p.id
|
||||
WHERE s.task_id = ? ORDER BY p.id'
|
||||
);
|
||||
$st->execute([$taskId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
public static function setShare(int $taskId, int $personId, string $perm): void
|
||||
{
|
||||
if ($personId <= 0) {
|
||||
return;
|
||||
}
|
||||
Database::get()->prepare(
|
||||
'INSERT INTO task_shares (task_id, person_id, perm) VALUES (?,?,?)
|
||||
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
|
||||
)->execute([$taskId, $personId, $perm === 'read' ? 'read' : 'write']);
|
||||
}
|
||||
|
||||
public static function dropShare(int $taskId, int $personId): void
|
||||
{
|
||||
Database::get()->prepare('DELETE FROM task_shares WHERE task_id = ? AND person_id = ?')
|
||||
->execute([$taskId, $personId]);
|
||||
}
|
||||
|
||||
// ---------------- 展示 ----------------
|
||||
|
||||
public static function statusWord(string $k): string
|
||||
{
|
||||
return self::STATUS[$k] ?? '待办';
|
||||
}
|
||||
|
||||
public static function repeatWord(?string $k): string
|
||||
{
|
||||
$k = (string)$k;
|
||||
return self::REPEAT[$k] ?? '不循环';
|
||||
}
|
||||
|
||||
/** 截止日期的人话(今天/明天/逾期…),任务卡片与日历共用 */
|
||||
public static function dueWord(?string $due): string
|
||||
{
|
||||
if ($due === null || $due === '') {
|
||||
return '';
|
||||
}
|
||||
$ts = strtotime($due);
|
||||
if ($ts === false) {
|
||||
return '';
|
||||
}
|
||||
$hasTime = substr((string)$due, 11, 5) !== '00:00';
|
||||
$d = date('Y-m-d', $ts);
|
||||
$today = date('Y-m-d');
|
||||
if ($d === $today) {
|
||||
return ($hasTime ? '今天 ' . substr((string)$due, 11, 5) : '今天') . ' 截止';
|
||||
}
|
||||
if ($d === date('Y-m-d', strtotime('+1 day'))) {
|
||||
return '明天截止';
|
||||
}
|
||||
if ($d < $today) {
|
||||
return '已逾期(' . date('n月j日', $ts) . ')';
|
||||
}
|
||||
return date('n月j日', $ts) . ($hasTime ? ' ' . substr((string)$due, 11, 5) : '') . ' 截止';
|
||||
}
|
||||
|
||||
public static function isOverdue(?string $due): bool
|
||||
{
|
||||
if ($due === null || $due === '') {
|
||||
return false;
|
||||
}
|
||||
return substr((string)$due, 0, 10) < date('Y-m-d');
|
||||
}
|
||||
|
||||
// ---------------- 内部 ----------------
|
||||
|
||||
private static function row(int $familyId, int $id): ?array
|
||||
{
|
||||
$st = Database::get()->prepare('SELECT * FROM tasks WHERE id = ? AND family_id = ?');
|
||||
$st->execute([$id, $familyId]);
|
||||
$r = $st->fetch();
|
||||
return $r ?: null;
|
||||
}
|
||||
|
||||
/** 标完成:记 done_at */
|
||||
private static function markDone(int $familyId, int $id): void
|
||||
{
|
||||
Database::get()->prepare(
|
||||
"UPDATE tasks SET done_at = NOW() WHERE id = ? AND family_id = ? AND done_at IS NULL"
|
||||
)->execute([$id, $familyId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环:生成下一条。**只在有截止日期时**生成 ——
|
||||
* 没有基准日期的循环任务算不出"下一次是什么时候",硬造一条只会变成一条空的待办。
|
||||
*
|
||||
* @return int 新任务 id(没生成则 0)
|
||||
*/
|
||||
private static function spawnNext(int $familyId, array $old): int
|
||||
{
|
||||
$rule = (string)($old['repeat_rule'] ?? '');
|
||||
$due = (string)($old['due_at'] ?? '');
|
||||
if ($rule === '' || $due === '') {
|
||||
return 0;
|
||||
}
|
||||
$next = self::advance($due, $rule);
|
||||
if ($next === '') {
|
||||
return 0;
|
||||
}
|
||||
$db = Database::get();
|
||||
$db->prepare(
|
||||
'INSERT INTO tasks (family_id, parent_id, title, note, status, due_at, position,
|
||||
creator_id, assignee_id, repeat_rule)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)'
|
||||
)->execute([
|
||||
$familyId,
|
||||
$old['parent_id'] !== null ? (int)$old['parent_id'] : null,
|
||||
(string)$old['title'],
|
||||
$old['note'],
|
||||
'todo',
|
||||
$next,
|
||||
$old['position'],
|
||||
$old['creator_id'],
|
||||
$old['assignee_id'],
|
||||
$rule,
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
/** 按循环规则把日期往后推一个周期(以**原截止日**为基准,不是以今天) */
|
||||
private static function advance(string $due, string $rule): string
|
||||
{
|
||||
$ts = strtotime($due);
|
||||
if ($ts === false) {
|
||||
return '';
|
||||
}
|
||||
$map = ['day' => '+1 day', 'week' => '+1 week', 'month' => '+1 month', 'year' => '+1 year'];
|
||||
if (!isset($map[$rule])) {
|
||||
return '';
|
||||
}
|
||||
// 推到"不早于今天"为止:一个每周任务拖了三周才勾,应该出现下周三,
|
||||
// 而不是连着补三条过期的一周前。
|
||||
$t = $ts;
|
||||
for ($i = 0; $i < 400; $i++) {
|
||||
$t = strtotime($map[$rule], $t);
|
||||
if ($t === false) {
|
||||
return '';
|
||||
}
|
||||
if (date('Y-m-d', $t) >= date('Y-m-d')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return date('Y-m-d H:i:s', $t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 父任务 id 的合法性:存在、同家庭、不是自己、也不是自己的子孙(否则成环)。
|
||||
*
|
||||
* @param int $selfId 被编辑的那条自己(0=新建,不存在成环问题)
|
||||
*/
|
||||
private static function parentId(int $familyId, int $pid, int $selfId = 0): ?int
|
||||
{
|
||||
if ($pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
if ($selfId > 0 && $pid === $selfId) {
|
||||
return null;
|
||||
}
|
||||
$db = Database::get();
|
||||
$st = $db->prepare('SELECT parent_id FROM tasks WHERE id = ? AND family_id = ?');
|
||||
$st->execute([$pid, $familyId]);
|
||||
$r = $st->fetch();
|
||||
if (!$r) {
|
||||
return null; // 不存在 / 别家的
|
||||
}
|
||||
// 从候选父任务往上走,撞到自己就说明"想把自己挂到自己的后代下面"
|
||||
$cur = (int)($r['parent_id'] ?? 0);
|
||||
for ($guard = 0; $guard < 10 && $cur > 0; $guard++) {
|
||||
if ($selfId > 0 && $cur === $selfId) {
|
||||
return null;
|
||||
}
|
||||
$up = $db->prepare('SELECT parent_id FROM tasks WHERE id = ?');
|
||||
$up->execute([$cur]);
|
||||
$u = $up->fetch();
|
||||
if (!$u) {
|
||||
break;
|
||||
}
|
||||
$cur = (int)($u['parent_id'] ?? 0);
|
||||
}
|
||||
return $pid;
|
||||
}
|
||||
|
||||
private static function nextPosition(int $familyId, string $status): int
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT COALESCE(MAX(position),0)+10 FROM tasks WHERE family_id = ? AND status = ? AND parent_id IS NULL'
|
||||
);
|
||||
$st->execute([$familyId, $status]);
|
||||
return (int)$st->fetchColumn();
|
||||
}
|
||||
|
||||
private static function title(string $v): string
|
||||
{
|
||||
return mb_substr(trim($v), 0, 200, 'UTF-8');
|
||||
}
|
||||
|
||||
private static function status(string $v): string
|
||||
{
|
||||
return array_key_exists($v, self::STATUS) ? $v : 'todo';
|
||||
}
|
||||
|
||||
private static function repeat(string $v): ?string
|
||||
{
|
||||
return array_key_exists($v, self::REPEAT) && $v !== '' ? $v : null;
|
||||
}
|
||||
|
||||
private static function dtOrNull(string $v): ?string
|
||||
{
|
||||
$v = trim($v);
|
||||
if ($v === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {
|
||||
$v .= ' 00:00:00';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $v)) {
|
||||
$v .= ':00';
|
||||
}
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v) ? $v : null;
|
||||
}
|
||||
|
||||
private static function textOrNull(string $v): ?string
|
||||
{
|
||||
$v = trim($v);
|
||||
return $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user