'开心', '🥰' => '温暖', '😍' => '喜欢', '🤩' => '兴奋', '🤗' => '满足', '😎' => '得意', '🤔' => '沉思', '😌' => '平静', '😋' => '好吃', '😅' => '无奈', '😴' => '累了', '😵' => '晕了', '😢' => '难过', '😭' => '大哭', '😱' => '惊吓', '😠' => '生气', '🤒' => '不舒服', ]; public static function isValidEmotion(string $emoji): bool { return $emoji !== '' && isset(self::EMOTIONS[$emoji]); } /** 某个节点的参与人列表(带姓名、头像、角色标签名) */ public static function forNode(int $nodeId): array { $stmt = Database::get()->prepare(' SELECT nm.*, u.name, u.avatar, u.role FROM node_members nm LEFT JOIN users u ON u.id = nm.user_id WHERE nm.trip_node_id = ? ORDER BY nm.id ASC'); $stmt->execute([$nodeId]); $rows = $stmt->fetchAll(); $tagNames = self::roleTagMap(); foreach ($rows as &$r) { $ids = json_decode((string)($r['role_tag_ids'] ?? '[]'), true); $ids = is_array($ids) ? array_map('intval', $ids) : []; $r['role_tag_ids'] = $ids; $r['role_tag_names'] = array_values(array_filter(array_map( static fn($id) => $tagNames[$id] ?? null, $ids ))); } unset($r); return $rows; } /** 一次查完多个节点的参与人数与情绪,供列表/卡片使用 */ public static function summaryForNodes(array $nodeIds): array { if (empty($nodeIds)) return []; $ph = implode(',', array_fill(0, count($nodeIds), '?')); $stmt = Database::get()->prepare("SELECT trip_node_id, COUNT(*) AS cnt, GROUP_CONCAT(emotion ORDER BY id SEPARATOR '') AS emotions FROM node_members WHERE trip_node_id IN ($ph) GROUP BY trip_node_id"); $stmt->execute(array_values($nodeIds)); $out = []; foreach ($stmt->fetchAll() as $row) { $out[(int)$row['trip_node_id']] = [ 'count' => (int)$row['cnt'], 'emotions' => (string)($row['emotions'] ?? ''), ]; } return $out; } /** * 一次查完多个节点的参与人明细(头像 / 姓名 / 情绪),供活动页的节点卡片使用。 * 一条 IN 查询带回全部节点,避免每张卡片单独查(N+1)。 * @return array> node_id => 参与人列表(按 node_members.id ASC) */ public static function forNodes(array $nodeIds): array { if (empty($nodeIds)) return []; $ph = implode(',', array_fill(0, count($nodeIds), '?')); $stmt = Database::get()->prepare(' SELECT nm.trip_node_id, nm.user_id, nm.emotion, u.name, u.avatar FROM node_members nm LEFT JOIN users u ON u.id = nm.user_id WHERE nm.trip_node_id IN (' . $ph . ') ORDER BY nm.id ASC'); $stmt->execute(array_values($nodeIds)); $out = []; foreach ($stmt->fetchAll() as $row) { $out[(int)$row['trip_node_id']][] = $row; } return $out; } /** * 新增或更新一个参与人(同一节点同一人只留一条) * @return array{ok: bool, error?: string} */ public static function save(int $nodeId, int $userId, array $roleTagIds, string $emotion, ?string $checkIn): array { $db = Database::get(); if ($userId <= 0) { return ['ok' => false, 'error' => '请选择参与人']; } // 只能选本家庭的成员(注意:必须同时比对 users.family_id, // 否则「节点存在 + 用户存在」就放行,别家用户也能被塞进来) $stmt = $db->prepare('SELECT u.id, a.family_id FROM trip_nodes tn JOIN activities a ON a.id = tn.activity_id JOIN users u ON u.id = ? AND u.family_id = a.family_id WHERE tn.id = ?'); $stmt->execute([$userId, $nodeId]); $row = $stmt->fetch(); if (!$row) { return ['ok' => false, 'error' => '该成员不属于这个活动所在家庭']; } // 角色标签也要属于同一个家庭 $validTags = array_keys(self::roleTagMap((int)$row['family_id'])); $roleTagIds = array_values(array_intersect(array_map('intval', $roleTagIds), $validTags)); if ($emotion !== '' && !self::isValidEmotion($emotion)) { return ['ok' => false, 'error' => '情绪表情不在可选范围内']; } $checkIn = $checkIn !== null && trim($checkIn) !== '' ? trim($checkIn) : null; if ($checkIn !== null) { $ts = strtotime(str_replace('T', ' ', $checkIn)); $checkIn = $ts !== false ? date('Y-m-d H:i:s', $ts) : null; } $stmt = $db->prepare('INSERT INTO node_members (trip_node_id, user_id, role_tag_ids, emotion, check_in_time) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE role_tag_ids = VALUES(role_tag_ids), emotion = VALUES(emotion), check_in_time = VALUES(check_in_time)'); $stmt->execute([ $nodeId, $userId, $roleTagIds ? json_encode($roleTagIds, JSON_UNESCAPED_UNICODE) : null, $emotion !== '' ? $emotion : null, $checkIn, ]); return ['ok' => true]; } public static function remove(int $nodeId, int $userId): void { $stmt = Database::get()->prepare('DELETE FROM node_members WHERE trip_node_id = ? AND user_id = ?'); $stmt->execute([$nodeId, $userId]); } /** * 勾选式同步:把节点参与人「对齐」成 $userIds(节点表单里直接勾选用)。 * * 只做增删 —— 留下来的人的情绪 / 签到 / 角色标签**一律不动**(编辑节点时 * 重新勾一遍参与人不该把之前记的心情清掉)。新增走 save(), * 家庭归属校验与「一键加入」那条链路完全一致。 * * @return array{added:int, removed:int} */ public static function syncForNode(int $nodeId, array $userIds): array { $db = Database::get(); $want = []; foreach ($userIds as $uid) { $uid = (int)$uid; if ($uid > 0 && !in_array($uid, $want, true)) { $want[] = $uid; } } $stmt = $db->prepare('SELECT user_id FROM node_members WHERE trip_node_id = ?'); $stmt->execute([$nodeId]); $have = []; foreach ($stmt->fetchAll(\PDO::FETCH_COLUMN) as $uid) { $have[] = (int)$uid; } $added = 0; $removed = 0; foreach (array_diff($have, $want) as $uid) { self::remove($nodeId, (int)$uid); $removed++; } foreach (array_diff($want, $have) as $uid) { if (self::save($nodeId, (int)$uid, [], '', null)['ok']) { $added++; } } return ['added' => $added, 'removed' => $removed]; } /** 一次把一批人加进节点(用于「把活动成员一键全部加入」) */ public static function addMany(int $nodeId, array $userIds, array $roleTagIds = []): int { $n = 0; foreach (array_unique(array_map('intval', $userIds)) as $uid) { $r = self::save($nodeId, $uid, $roleTagIds, '', null); if ($r['ok']) $n++; } return $n; } /** 家庭内的角色标签 [id => name] */ public static function roleTagMap(?int $familyId = null): array { $db = Database::get(); if ($familyId === null) { $stmt = $db->query('SELECT id, name, family_id FROM role_tags'); } else { $stmt = $db->prepare('SELECT id, name, family_id FROM role_tags WHERE family_id = ?'); $stmt->execute([$familyId]); } $out = []; foreach ($stmt->fetchAll() as $row) { $out[(int)$row['id']] = $row['name']; } return $out; } /** 家庭内可作为参与人的用户(guest 也算,记录出行时常常是"待激活"的亲戚) */ public static function familyUsers(int $familyId): array { $stmt = Database::get()->prepare('SELECT id, name, avatar, role FROM users WHERE family_id = ? ORDER BY FIELD(role, "admin","user","guest"), id ASC'); $stmt->execute([$familyId]); return $stmt->fetchAll(); } }