diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 3612ba3..89353d9 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -1712,6 +1712,28 @@ final class ApiHandler 任务 ═══════════════════════════════════════════════════════════════ */ + /** + * 一条任务 → 给 App 的形状。 + * + * 🔴 v79 为什么要包这一层:`Task::find()` 只 SELECT `t.*`,**不含看板名/看板色** + * (网页端是在模板里再查一次)。端上面板要显示"这条在哪个看板"、 + * 还要拿它的颜色去画那颗小圆点 —— 让端上为了两个字段再打一次 + * `/tasks?view=boards` 是多余的一跳,就在服务端补齐。 + * `board_color_calc` 与首页任务用的是**同一个**派生口径, + * 否则同一个看板在首页和详情里是两种颜色(用户能一眼看出来)。 + */ + private static function taskOut(int $fam, int $id, int $pid): array + { + $t = Task::find($fam, $id, $pid); + if (!$t) { + return []; + } + $bid = (int)($t['board_id'] ?? 0); + $t['board_name'] = Task::boardName($fam, $bid > 0 ? $bid : null); + $t['board_color_calc'] = Task::boardColorById($fam, $bid > 0 ? $bid : null); + return $t; + } + private function tasks(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); @@ -1724,12 +1746,12 @@ final class ApiHandler if ($sub === 'status' && ($method === 'POST' || $method === 'PATCH')) { Api::requireWrite(); Task::setStatus($fam, $id, Api::str('status', 'todo'), Api::int('position')); - Api::ok(Task::find($fam, $id, $pid)); + Api::ok(self::taskOut($fam, $id, $pid)); } if ($sub === 'move' && $method === 'POST') { Api::requireWrite(); Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid); - Api::ok(Task::find($fam, $id, $pid)); + Api::ok(self::taskOut($fam, $id, $pid)); } if ($sub === 'comments') { if ($method === 'GET') { @@ -1743,7 +1765,53 @@ final class ApiHandler } } - if ($method === 'GET') { Api::ok($t); } + /* ═══════════════════════════════════════════════════════════ + v79(用户 2026-10-22):「鸿蒙端的功能,也需要同样适配…… + 任务模块的功能需要完善。」 + ⇒ 端上要对齐网页 v78 的「就地面板」,而面板里的**加附件**一直没有 API + (网页端走的是 `tasks/attach_add` 这条表单路由)。 + ──────────────────────────────────────────────────────────── + POST /api/v1/tasks//attach multipart,字段名 `file` + DELETE /api/v1/tasks//attach 参数 `attachment_id` + ⚠️ 路由只解析「资源/id/子动作」**三段**(`Api::dispatch`),没有第四段 + ⇒ 删附件**必须**把 id 放参数里。写成 `/attach/` 的话, + `Api::dispatch` 只会给到 `$sub='attach'`,aid 直接丢掉(静默 404)。 + ⚠️ 两个分支都回**整条任务**(`Task::find`,含 attachs):端上拿到就能 + 直接重画面板,不用再补一次 GET。 + ⚠️ 权限除了 `requireWrite()` 还要 `Task::canWrite()`:前者只说明 + "这个账号不是只读访客",后者才说明"这条任务归我管"。 + ═══════════════════════════════════════════════════════════ */ + if ($sub === 'attach') { + Api::requireWrite(); + if (!Task::canWrite($t, $pid)) { + Api::fail('这项任务不能改。', 'forbidden', 403); + } + if ($method === 'POST') { + $file = $_FILES['file'] ?? null; + if (!is_array($file)) { + Api::fail('没有收到文件。', 'unprocessable', 422); + } + $r = \App\Attachment::save($fam, Task::ATTACH, $id, $file, $pid); + if (empty($r['ok'])) { + $msg = (string)($r['error'] ?? ''); + Api::fail($msg !== '' ? $msg : '文件没存下来', 'unprocessable', 422); + } + Api::ok(self::taskOut($fam, $id, $pid), 201); + } + if ($method === 'DELETE') { + $aid = Api::int('attachment_id'); + if ($aid <= 0) { + Api::fail('要指定删哪个附件。', 'bad_request', 400); + } + if (!\App\Attachment::removeFrom(Task::ATTACH, $id, $aid)) { + Api::notFound('这个附件'); + } + Api::ok(self::taskOut($fam, $id, $pid)); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'GET') { Api::ok(self::taskOut($fam, $id, $pid)); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); Task::update($fam, $id, [ @@ -1757,7 +1825,7 @@ final class ApiHandler 'board_id' => Api::int('board_id', (int)($t['board_id'] ?? 0)), 'status' => (string)$t['status'], // 状态只走 /status 子动作 ]); - Api::ok(Task::find($fam, $id, $pid)); + Api::ok(self::taskOut($fam, $id, $pid)); } if ($method === 'DELETE') { Api::requireWrite(); @@ -1787,7 +1855,7 @@ final class ApiHandler foreach (Api::people(Api::ints('shares'), $fam) as $sp) { Task::setShare($newId, $sp, 'write'); } - Api::ok(Task::find($fam, $newId, $pid), 201); + Api::ok(self::taskOut($fam, $newId, $pid), 201); } // ---- 列表:三种视图 ---- @@ -1801,6 +1869,33 @@ final class ApiHandler if ($view === 'done') { Api::ok(Task::doneList($fam, $pid, Api::int('category_id'), Api::str('keyword'))); } + /* v79:**只给元数据**(看板 + 分类),不带任务。 + 🔴 为什么需要它:端上「挪看板 / 选分类」要的是"**全部**看板", + 而看板视图(下面那段)每列都塞满了任务,为了拿 5 个名字去解析几百条任务 + 很浪费;更关键的是——**空看板在别的视图里根本不存在** + (端上原来只能从已加载任务里反推看板 ⇒ 空看板永远选不到)。 + 网页端同一份数据来自 `Task::boards()` / `Task::categories()`。 */ + if ($view === 'boards') { + $bs = []; + foreach (Task::boards($fam) as $b) { + $bs[] = [ + 'id' => (int)$b['id'], + 'name' => (string)$b['name'], + 'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']), + 'open' => (int)($b['open_count'] ?? 0), + ]; + } + $cs = []; + foreach (Task::categories($fam) as $c) { + $cs[] = ['id' => (int)$c['id'], 'name' => (string)$c['name']]; + } + Api::ok([ + 'boards' => $bs, + 'categories' => $cs, + 'status' => Task::STATUS, + 'repeat' => Task::REPEAT, + ]); + } // 看板视图:把"列"与"任务"一起给出来(App 一次请求就够画整屏) $boards = Task::boards($fam); /* ⚠️ `Task::boardColumns()` 返回的是**包了一层的数组** diff --git a/app/Attachment.php b/app/Attachment.php index d7cfb37..3e82c66 100644 --- a/app/Attachment.php +++ b/app/Attachment.php @@ -128,6 +128,29 @@ final class Attachment } } + /** + * 删**某个宿主名下**的一颗附件(v79,App API 用)。 + * + * 🔴 为什么不能直接用 `remove($id)`:那个只认附件 id,谁传一个别的任务的 + * 附件 id 过来都能删掉(越权)。这里把 owner 一起放进 WHERE, + * 「不是这条任务的」一律返回 false,调用方据此回 404。 + */ + public static function removeFrom(string $type, int $targetId, int $id): bool + { + if ($id <= 0) { + return false; + } + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?' + ); + $st->execute([$id, $type, $targetId]); + if ((int)$st->fetchColumn() <= 0) { + return false; + } + self::remove($id); + return true; + } + /** * 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。 *