v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:
- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
(实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
金额一律以「分」存,不用浮点。
验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
This commit is contained in:
+222
-9
@@ -35,6 +35,202 @@ final class Task
|
||||
/** 附件的宿主类型 */
|
||||
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();
|
||||
}
|
||||
|
||||
// ---------------- 读 ----------------
|
||||
|
||||
/**
|
||||
@@ -43,24 +239,33 @@ final class Task
|
||||
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
|
||||
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
|
||||
*/
|
||||
public static function board(int $familyId, int $personId): array
|
||||
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,
|
||||
(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,
|
||||
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 = ?
|
||||
WHERE t.family_id = ? AND t.parent_id IS NULL
|
||||
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, $personId, $personId]);
|
||||
$st->execute([$personId, $personId, $familyId, $defId, $boardId, $personId, $personId]);
|
||||
$rows = $st->fetchAll();
|
||||
|
||||
// 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢)
|
||||
@@ -70,6 +275,8 @@ final class Task
|
||||
}
|
||||
unset($r);
|
||||
|
||||
// ⚠️ 只有 todo / doing 两列:done 已经从看板上撤走了(v24 第 10 条)。
|
||||
// 保留 'done' 这个键(恒空)只为兼容个别直接按 key 取的老调用。
|
||||
$out = ['todo' => [], 'doing' => [], 'done' => []];
|
||||
foreach ($rows as $r) {
|
||||
$k = (string)$r['status'];
|
||||
@@ -216,11 +423,13 @@ final class Task
|
||||
{
|
||||
$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 (?,?,?,?,?,?,?,?,?,?)'
|
||||
'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'] ?? ''),
|
||||
@@ -247,7 +456,7 @@ final class Task
|
||||
$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=?'
|
||||
parent_id=?, board_id=?, category_id=? WHERE id=? AND family_id=?'
|
||||
)->execute([
|
||||
self::title($d['title'] ?? ''),
|
||||
self::textOrNull($d['note'] ?? ''),
|
||||
@@ -256,6 +465,10 @@ final class Task
|
||||
(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') {
|
||||
|
||||
Reference in New Issue
Block a user