['游玩', '🎡'], 'meal' => ['用餐', '🍜'], 'move' => ['赶路', '🚗'], 'stay' => ['住宿', '🏨'], 'other' => ['其他', '📌'], ]; public const NODE_STATUS = [ 'planned' => '计划中', 'ongoing' => '进行中', 'done' => '已完成', 'cancelled' => '已取消', ]; /** 全部旅程(按出发时间倒序) */ public static function all(int $familyId, string $keyword = ''): array { $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note, d.cover_key, d.summary, (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, (SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count FROM events e LEFT JOIN trip_details d ON d.event_id = e.id WHERE e.family_id = ? AND e.kind = ?'; $bind = [$familyId, 'trip']; $kw = trim($keyword); if ($kw !== '') { $sql .= ' AND (e.title LIKE ? OR e.location LIKE ? OR e.note LIKE ?)'; $like = '%' . $kw . '%'; array_push($bind, $like, $like, $like); } $sql .= ' ORDER BY e.starts_at DESC, e.id DESC'; $st = Database::get()->prepare($sql); $st->execute($bind); return $st->fetchAll(); } /** * 给一批旅程补上「同行的人」。 * * ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。 * 一次查询拿全部 event_attendees,再按 event_id 分桶挂回去。 */ public static function attachAttendees(array &$rows, int $viewerId = 0): void { $ids = []; foreach ($rows as $r) { $id = (int)($r['id'] ?? 0); if ($id > 0) { $ids[] = $id; } } $byEvent = []; foreach ($rows as $r) { $byEvent[(int)$r['id']] = []; } if ($ids) { $in = implode(',', array_fill(0, count($ids), '?')); $st = Database::get()->prepare( 'SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM event_attendees a JOIN persons p ON p.id = a.person_id LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE a.event_id IN (' . $in . ') ORDER BY a.position, p.id' ); $st->execute(array_merge([$viewerId], $ids)); foreach ($st->fetchAll() as $a) { $byEvent[(int)$a['event_id']][] = $a; } } foreach ($rows as &$r) { $r['attendees'] = $byEvent[(int)$r['id']] ?? []; } unset($r); } /** 一条完整旅程 */ public static function find(int $familyId, int $id, int $viewerId = 0): ?array { $st = Database::get()->prepare( "SELECT e.*, d.cover_key, d.summary FROM events e LEFT JOIN trip_details d ON d.event_id = e.id WHERE e.family_id = ? AND e.id = ? AND e.kind = 'trip'" ); $st->execute([$familyId, $id]); $row = $st->fetch(); if (!$row) { return null; } $row['attendees'] = self::attendees($id, $viewerId); $row['photos'] = \App\Attachment::list('trip', $id); return $row; } /** 同行的人(带「我叫他什么」与主色,页面按人画标签) */ public static function attendees(int $eventId, int $viewerId = 0): array { $st = Database::get()->prepare( 'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM event_attendees a JOIN persons p ON p.id = a.person_id LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE a.event_id = ? ORDER BY a.position, p.id' ); $st->execute([$viewerId, $eventId]); return $st->fetchAll(); } /** @return int 事件 id */ public static function create(int $familyId, ?int $creatorId, array $d): int { $db = Database::get(); $db->prepare( "INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref) VALUES (?, ?, 'trip', ?, ?, ?, ?, ?, ?)" )->execute([ $familyId, $creatorId, (string)$d['title'], self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), $d['source_ref'] ?? null, ]); $id = (int)$db->lastInsertId(); self::saveDetails($id, $d); self::saveAttendees($id, $d['attendees'] ?? []); return $id; } public static function update(int $familyId, int $id, array $d): void { $db = Database::get(); $db->prepare( "UPDATE events SET title=?, starts_at=?, ends_at=?, location=?, note=? WHERE id=? AND family_id=? AND kind='trip'" )->execute([ (string)$d['title'], self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), $id, $familyId, ]); self::saveDetails($id, $d); self::saveAttendees($id, $d['attendees'] ?? []); } private static function saveDetails(int $eventId, array $d): void { $db = Database::get(); $row = [ self::keyOrNull($d['cover_key'] ?? ''), self::textOrNull($d['summary'] ?? ''), ]; $has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?'); $has->execute([$eventId]); if ((int)$has->fetchColumn() > 0) { $db->prepare('UPDATE trip_details SET cover_key=?, summary=? WHERE event_id=?') ->execute([...$row, $eventId]); } else { $db->prepare('INSERT INTO trip_details (cover_key, summary, event_id) VALUES (?,?,?)') ->execute([...$row, $eventId]); } } public static function saveAttendees(int $eventId, array $personIds): void { $db = Database::get(); $db->prepare('DELETE FROM event_attendees WHERE event_id = ?')->execute([$eventId]); $ins = $db->prepare('INSERT IGNORE INTO event_attendees (event_id, person_id, position) VALUES (?,?,?)'); $pos = 0; foreach ($personIds as $pid) { $pid = (int)$pid; if ($pid > 0) { $pos += 10; $ins->execute([$eventId, $pid, $pos]); } } } public static function destroy(int $familyId, int $id): void { // 照片与节点附件一并清(events 级联只带走数据库行,存储对象要自己删) foreach (\App\Attachment::list('trip', $id) as $a) { \App\Attachment::remove((int)$a['id']); } foreach (self::nodes($id) as $n) { foreach (\App\Attachment::list('trip_node', (int)$n['id']) as $a) { \App\Attachment::remove((int)$a['id']); } } Database::get()->prepare("DELETE FROM events WHERE id=? AND family_id=? AND kind='trip'") ->execute([$id, $familyId]); } // ---------------- 行程节点 ---------------- /** 这趟旅程的全部节点(plan / reality 两轨,按 position 排) */ public static function nodes(int $eventId, int $viewerId = 0): array { $st = Database::get()->prepare( 'SELECT * FROM trip_nodes WHERE event_id = ? ORDER BY category, position, starts_at, id' ); $st->execute([$eventId]); $rows = $st->fetchAll(); $ids = array_map('intval', array_column($rows, 'id')); // 一次性把全部节点的附件取出来(逐节点查会让"十几段的旅程"变成几十次查询) $attach = []; foreach ($ids as $nid) { $attach[$nid] = []; } if ($ids) { $in = implode(',', array_fill(0, count($ids), '?')); $sa = Database::get()->prepare( "SELECT * FROM attachments WHERE target_type = 'trip_node' AND target_id IN ($in) ORDER BY position, id" ); $sa->execute($ids); foreach ($sa->fetchAll() as $a) { $attach[(int)$a['target_id']][] = $a; } } foreach ($rows as &$n) { $n['members'] = self::nodeMembersFor((int)$n['id'], $viewerId); $n['attachs'] = $attach[(int)$n['id']] ?? []; } unset($n); return $rows; } public static function node(int $id): ?array { $st = Database::get()->prepare('SELECT * FROM trip_nodes WHERE id = ?'); $st->execute([$id]); $r = $st->fetch(); return $r ?: null; } public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int { $db = Database::get(); $fields = [ self::textOrNull2($d['title'] ?? ''), self::textOrNull($d['detail'] ?? ''), self::textOrNull($d['place'] ?? ''), self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), in_array($d['node_type'] ?? '', array_keys(self::NODE_TYPES), true) ? $d['node_type'] : 'other', in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned', ($d['category'] ?? '') === 'reality' ? 'reality' : 'plan', (int)($d['position'] ?? 100), ]; if ($id !== null && $id > 0) { $db->prepare( 'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?, category=?, position=? WHERE id=?' )->execute([...$fields, $id]); $nid = $id; } else { $db->prepare( 'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at, node_type, status, category, position) VALUES (?,?,?,?,?,?,?,?,?,?,?)' )->execute([$familyId, $eventId, ...$fields]); $nid = (int)$db->lastInsertId(); } self::saveNodeMembers($nid, $d['members'] ?? []); return $nid; } public static function deleteNode(int $id): void { foreach (\App\Attachment::list('trip_node', $id) as $a) { \App\Attachment::remove((int)$a['id']); } Database::get()->prepare('DELETE FROM trip_nodes WHERE id = ?')->execute([$id]); } /** * 这一段里有谁。 * * ⚠️ 昵称必须按**看页面的那个人**的视角取,所以要显式传 $viewerId —— * 成员表里只有"谁参加了",没有"谁在看"。 */ public static function nodeMembersFor(int $nodeId, int $viewerId): array { $st = Database::get()->prepare( 'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM trip_node_members 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.node_id = ? ORDER BY p.id' ); $st->execute([$viewerId, $nodeId]); return $st->fetchAll(); } public static function saveNodeMembers(int $nodeId, array $personIds): void { $db = Database::get(); $db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]); $ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id) VALUES (?,?)'); foreach ($personIds as $pid) { $pid = (int)$pid; if ($pid > 0) { $ins->execute([$nodeId, $pid]); } } } // ---------------- 展示 ---------------- /** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */ public static function formatSpan(?string $start, ?string $end): string { if ($start === null || $start === '') { return '日期待补'; } $ts = strtotime($start); if ($ts === false) { return '日期待补'; } $s = date('Y年n月j日', $ts); if ($end === null || $end === '') { return $s; } $te = strtotime($end); if ($te === false) { return $s; } return $s . ' – ' . date('Y年n月j日', $te); } public static function typeWord(string $key): string { return self::NODE_TYPES[$key][0] ?? '其他'; } public static function typeIcon(string $key): string { return self::NODE_TYPES[$key][1] ?? '📌'; } public static function statusWord(string $key): string { return self::NODE_STATUS[$key] ?? '计划中'; } // ---------------- 小工具 ---------------- 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'; } 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; } private static function textOrNull2(string $v): string { return mb_substr(trim($v), 0, 200, 'UTF-8'); } private static function keyOrNull(string $v): ?string { $v = ltrim(trim($v), '/'); if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) { return null; } return $v; } }