['游玩', '🎡'], 'meal' => ['用餐', '🍜'], 'move' => ['赶路', '🚗'], 'stay' => ['住宿', '🏨'], 'other' => ['其他', '📌'], ]; public const NODE_STATUS = [ 'planned' => '计划中', 'ongoing' => '进行中', 'done' => '已完成', 'cancelled' => '已取消', ]; /** * 出行方式(v24 第 16 批 第 7 条,照 TripLog 的 travel_modes)。 * * ⚠️ 这里存的是**枚举 key**(不是 TripLog 那种另一张可配置的表): * 出行方式是**固定的物理事实**(走路/开车/高铁/飞机…), * 不像"节点类型"那样因人而异 —— 没人需要自定义一种"新的交通方式"。 * 用户第 8 条要的"出行方式设置",实际要设的是**每种方式下的常用项** * (比如自己常坐的车次/常用航空公司),那是"记忆上次填的"而不是"新增方式", * 见 Setting 里那几条 last_* 记忆。所以这里用常量,界面那页做的是"提示 + 记忆"。 */ public const TRAVEL_MODES = [ 'walk' => ['步行', '🚶'], 'bike' => ['骑行', '🚲'], 'drive' => ['自驾', '🚗'], 'taxi' => ['打车', '🚕'], 'bus' => ['公交', '🚌'], 'subway' => ['地铁', '🚇'], 'train' => ['火车', '🚄'], 'plane' => ['飞机', '✈️'], 'ship' => ['轮船', '🚢'], 'other' => ['其他', '🧭'], ]; /** * 自驾类(**只有自驾**才需要选车辆)。 * * ⚠️ v24 第 17 批 用户第 3 条把范围收窄了: * 原话「出行方式,只有选择**自驾**,才让选车辆」。 * 上一批是按 TripLog 的 `DRIVE_MODES` 把「打车」也算进来 * (打车时"坐的是哪辆车"确实可填),但用户明确不要 —— * 打车是"叫一辆车",车牌没有记忆价值,选了反而逼人去车库里挑一台不相干的车。 */ public const DRIVE_MODES = ['drive']; /** * 只需要填「车次 / 航班号」的出行方式(用户第 17 批 第 3 条)。 * * 用户原话:「飞机、铁路,只记录车次、航班号就可以,其他的没法记录, * 因为可能是多个人同行,座位什么的不记录了。」 * * ⚠️ 于是这两个方式下**不再出现**「座位 / 车厢」「登机口 / 站台」 * —— 这两个字段对"一家人出行"毫无意义(一次订五六张票,座位号不在一起, * 而登机口在出发当天才知道)。表单与列表都彻底不显示它们。 * `trip_nodes.seat_info` / `gate` 两列**保留不删**(老数据不丢,见 Schema)。 * * ⚠️ 名单外的出行方式(公交/地铁/轮船等)连车次都不用填: * 地铁没有"车次"可言,轮船的船名填在标题里就够。 */ public const TICKET_MODES = ['train', 'plane']; /** * 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。 * * 用户原话(2026-10-05): * 「刚开始添加的时候,可以选择是计划行程还是实际行程。」 * 「如果是计划行程,则这个行程下面有几个按钮…一个是删除,如果是标记为删除, * 不要真的删除,只是做一个标记,意味着这次计划没有成行…」 * 「另外一个按钮,用来记录实际的情况,按钮名字就叫修正,但是记住,这个实际的 * 行程和计划行程,是一条记录,不是两条…」「还有一个按钮,就是完成… * 这个行程直接就变成了实际行程。」 * 「实际行程,都会出现在日历和首页的往年今日中。计划行程,只有在未执行上面 * 三个操作的时候,才出现在日程中。一旦标记为删除,或转变为实际行程, * 则计划行程就不再出现。」 * * ┌─ plan 计划行程:**唯一会以"计划时间"上日程**的状态。 * │ 三个动作:标记未成行 / 修正 / 完成。 * ├─ actual 实际行程:上日程(按**实际时间**)、也进「往年今日」。 * └─ skipped 标记未成行:**不上日程、不进往年今日**;但在旅程模块里**仍然显示** * 并标注"未成行"(用户明确要求"还是要显示,只不过要标记上")。 * * ⚠️ 旧的 `category(plan/reality)` 是**两条独立记录**, * 而用户要的是"一条记录 + 反复修正",所以业务一律看 stage。 */ public const NODE_STAGES = [ 'plan' => '计划行程', 'actual' => '实际行程', 'skipped' => '未成行', ]; /** 会出现在日程/往年今日里的状态(计划用计划时间、实际用实际时间) */ public const STAGE_ON_CALENDAR = ['plan', 'actual']; /** 状态 → 徽标样式(模板用) */ public static function stageWord(string $key): string { return self::NODE_STAGES[$key] ?? '计划行程'; } /** 这条行程该不该上日程/往年今日 */ public static function stageOnCalendar(string $key): bool { return in_array($key, self::STAGE_ON_CALENDAR, true); } // ==================== 活动的时间与目的地:**从旅程派生** ==================== // // 用户原话(2026-10-05 第 15 批 第 2 条): // 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间, // 和最晚的旅程结束时间,计算出来的。目的地也不需要,因为一次活动, // 可能去好几个城市,所以去的城市,也是根据旅程,得到的。」 // // ⚠️ 这三样**不再是活动的字段**(表单里删了),一律读时算出来: // · 开始 = 最早一段旅程的 starts_at // · 结束 = 最晚一段旅程的 ends_at(缺 ends_at 就退回它的 starts_at) // · 城市 = 各段旅程 place 的去重集合 // 理由与"参与人是合集"完全同一条:同一份数据存两处,迟早对不上。 // 自己填的时间和行程里的时间一旦打架,看的人不知道该信哪个。 // // ⚠️ 老数据里 events.starts_at / location 可能是有值的 —— **保留不动** // (万一将来要回退),但页面一律不读它们,只读派生值。 // 迁移成本为 0,而"删列"是不可逆的。 /** * 一条活动的派生时间与城市(单条详情用)。 * * @return array{starts_at:?string, ends_at:?string, cities:string[], city:string} * city 是给"一行文字"用的拼法(如「青岛 · 威海 → 济南」)。 */ public static function derived(int $eventId): array { $st = Database::get()->prepare( "SELECT MIN(n.starts_at) AS start_at, MAX(COALESCE(n.ends_at, n.starts_at)) AS end_at FROM trip_nodes n WHERE n.event_id = ? AND n.stage IN ('plan','actual')" ); $st->execute([$eventId]); $r = $st->fetch() ?: []; $cities = self::cities($eventId); return [ 'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')), 'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')), 'cities' => $cities, 'city' => self::citiesWord($cities), ]; } /** * 一条旅程去过的**城市**(用户第 17 批 第 1/4 条)。 * * 用户原话: * · 第 1 条:「统计出来的不是目的地,也不是车站,而是**城市**… * 城市要聚合,比如说有多段行程,都有青岛作为出发地或者目的地, * 那么就只记一次。」 * · 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,而不是终点地址。」 * * 做法:把每条行程的 `from_city`(出发地城市)与 `to_city`(到达地城市) * 都算进来,**去重** —— 出发地与到达地各算一票,同一城市只记一次。 * * ⚠️ 这两个列是**保存行程时**由 `App\Geo` 解析好写进去的,不是读时现算: * 解析要打高德接口(有配额、可能超时),而城市是"写一次看很多次"的字段。 * 老数据由补迁脚本回填(见 `_fb_b17_migrate.php`)。 * ⚠️ 两个列都空时**不回落**去读 `place`:`place` 里是「溪水国家森林公园」 * 这种**地点名**(用户明确说"不是目的地,也不是车站"), * 拿它当城市正是这一批要改掉的东西。宁可暂时少一个城市,也不要显示错的。 * ⚠️ 排序按**最早出现的时间**:先后顺序本身是信息 * ("青岛 → 伊春 → 黑河"能看出路线,字母序看不出任何东西)。 * 同一时刻(老数据没时间的)用城市名兜底,保证顺序稳定可复现。 * ⚠️ skipped(未成行)**不算去过**:计划去但没去成的地方不该出现在 * "去过哪几个城市"里(与"不上日程、不进往年今日"是同一条口径)。 */ public static function cities(int $eventId): array { // ⚠️ ORDER BY 里**不能**写聚合别名(first_at):MySQL/MariaDB 会报 // `Reference 'first_at' not supported (reference to group function)`, // 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。 // 写成 `ORDER BY MIN(t), city` 就没问题(聚合函数本身可用)。 $sql = "SELECT c.city AS city, MIN(c.t) AS first_at, MIN(c.role) AS first_role FROM ( SELECT from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role FROM trip_nodes WHERE event_id = ? AND stage IN ('plan','actual') AND from_city IS NOT NULL AND from_city <> '' UNION ALL SELECT to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role FROM trip_nodes WHERE event_id = ? AND stage IN ('plan','actual') AND to_city IS NOT NULL AND to_city <> '' ) c GROUP BY c.city ORDER BY MIN(c.t), MIN(c.role), c.city"; $st = Database::get()->prepare($sql); $st->execute([$eventId, $eventId]); $out = []; foreach ($st->fetchAll() as $r) { $p = trim((string)$r['city']); if ($p !== '' && !in_array($p, $out, true)) { $out[] = mb_substr($p, 0, 60, 'UTF-8'); } } return $out; } /** * 城市列表 → 一行字。 * * 用「 · 」分隔而不是顿号:城市名里本来就可能含顿号或逗号 * ("海拉尔、满洲里"这种写法真有人用),混在一起会分不清有几个城市。 */ public static function citiesWord(array $cities): string { return implode(' · ', $cities); } /** * 🔴 v54(用户 2026-10-16 第 3 条):给**一批**旅程补上「代表坐标」 * —— 旅程列表的**地图视图**用它把每趟旅程钉在地图上的一个点。 * * 用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,可以在地图上显示旅程数据。 * 1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」 * * 做法(移植 TripLog `ActivityController::index` 的思路,但适配本仓的数据): * TripLog 是「把该活动下所有节点的经纬度取**重心**」—— * 本仓的数据更规整(`trip_nodes` 已有 from_lng/from_lat/to_lng/to_lat 四列, * 见 Schema 的 v17 段),所以照同一思路来: * * ① 优先用**真实坐标**:把该旅程所有节点的 from/to 坐标收集起来取平均 * (= 重心)。为什么取重心而不是"第一段/最后一段": * 几段行程串起来的一条线,重心最能代表"这趟旅程大概在哪儿"; * 取端点会让"青岛→伊春"这种整条北上的旅程偏到某一头。 * ② 没有坐标就**退到城市**:拿 `cities()` 里第一个城市, * 用本仓内置的 `Geo::lookup()`(离线城市表)换中心坐标。 * ⚠️ 这是离线表、**不发网络请求** —— 列表页要为几十趟旅程算坐标, * 若逐条打高德逆地理,一次开页面就是几十个外部请求(配额与延迟都受不了)。 * ③ 两者都没有 ⇒ 该旅程**不在地图上出现**(`lng/lat` 为 null), * 由模板跳过;列表视图里它照常显示。 * * ⚠️ skipped(未成行)的节点**不算**:计划去但没去成的地方不该把标记拽过去 * (与 `cities()` 同一口径)。 * ⚠️ 一次查完(IN 批查 + 内存分组),不做 N+1 —— 与 `attachDerived` 同一理由。 * ⚠️ 只**读**,不改 `$rows` 里既有的键之外的东西;算完挂 `trip_lng`/`trip_lat`/`trip_spot` * (前缀 `trip_` 避免与节点的 `lng/lat` 撞名)。 */ public static function attachSpots(array &$rows): void { $ids = []; foreach ($rows as $r) { $id = (int)($r['id'] ?? 0); if ($id > 0) { $ids[] = $id; } } if (!$ids) { return; } $in = implode(',', array_fill(0, count($ids), '?')); $st = Database::get()->prepare( "SELECT event_id, from_lng, from_lat, to_lng, to_lat FROM trip_nodes WHERE event_id IN ($in) AND stage IN ('plan','actual')" ); $st->execute($ids); $acc = []; // event_id => [sumLng, sumLat, n] foreach ($st->fetchAll() as $n) { $eid = (int)$n['event_id']; foreach ([['from_lng', 'from_lat'], ['to_lng', 'to_lat']] as $pair) { $lng = $n[$pair[0]] ?? null; $lat = $n[$pair[1]] ?? null; if ($lng === null || $lat === null || $lng === '' || $lat === '') { continue; } $lng = (float)$lng; $lat = (float)$lat; // 0,0 是"没坐标"的常见脏值(高德没返回时容易被写成 0),跳过 if ($lng === 0.0 && $lat === 0.0) { continue; } if (!isset($acc[$eid])) { $acc[$eid] = [0.0, 0.0, 0]; } $acc[$eid][0] += $lng; $acc[$eid][1] += $lat; $acc[$eid][2]++; } } foreach ($rows as &$r) { $eid = (int)($r['id'] ?? 0); $lng = null; $lat = null; if (isset($acc[$eid]) && $acc[$eid][2] > 0) { $lng = $acc[$eid][0] / $acc[$eid][2]; $lat = $acc[$eid][1] / $acc[$eid][2]; } else { /* 退到城市(离线表,不发网络请求;见方法头的说明)。 🔴 这里取**该旅程所有能认出的城市的中心点的平均值**(重心), 而不是"第一个城市" —— 与上面"有坐标时取重心"**同一口径**。 为什么不取第一个:`cities()` 的顺序是**按时间**(出发地在前), 而出发地往往是同一个家(青岛)⇒ 所有旅程的标记会**全叠在家上**, 地图就没有信息量了。取重心则"青岛→哈尔滨"落在两地之间, 与"有坐标"那条路的表现一致(写成两套口径迟早对不上)。 */ $sumLng = 0.0; $sumLat = 0.0; $nCity = 0; foreach ((array)($r['cities'] ?? []) as $c) { $hit = \App\Geo::lookup((string)$c); if ($hit) { $sumLng += (float)$hit['lon']; $sumLat += (float)$hit['lat']; $nCity++; } } if ($nCity > 0) { $lng = $sumLng / $nCity; $lat = $sumLat / $nCity; } } $r['trip_lng'] = $lng; $r['trip_lat'] = $lat; $r['trip_spot'] = ($lng !== null && $lat !== null); } unset($r); } /** * 给**一批**活动补上派生时间与城市(列表页用;N+1 会让几十趟活动明显变慢)。 * * ⚠️ 与 attachAttendees 同一思路:一次查询按 event_id 分桶,再挂回每行。 */ public static function attachDerived(array &$rows): void { $ids = []; foreach ($rows as $r) { $id = (int)($r['id'] ?? 0); if ($id > 0) { $ids[] = $id; } } $span = []; $city = []; foreach ($ids as $id) { $span[$id] = ['starts_at' => null, 'ends_at' => null]; $city[$id] = []; } if ($ids) { $in = implode(',', array_fill(0, count($ids), '?')); $st = Database::get()->prepare( "SELECT event_id, MIN(starts_at) AS start_at, MAX(COALESCE(ends_at, starts_at)) AS end_at FROM trip_nodes WHERE event_id IN ($in) AND stage IN ('plan','actual') GROUP BY event_id" ); $st->execute($ids); foreach ($st->fetchAll() as $r) { $id = (int)$r['event_id']; $span[$id] = [ 'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')), 'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')), ]; } // ⚠️ 同 cities():只认 from_city / to_city(第 17 批第 1 条的城市聚合), // ORDER BY 只写聚合函数、不写别名。原来读的是 place —— 那是**地点名** // (「溪水国家森林公园」),拿它当城市正是这批要改掉的。 $st2 = Database::get()->prepare( "SELECT c.event_id, c.city, MIN(c.t) AS first_at, MIN(c.role) AS first_role FROM ( SELECT event_id, from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role FROM trip_nodes WHERE event_id IN ($in) AND stage IN ('plan','actual') AND from_city IS NOT NULL AND from_city <> '' UNION ALL SELECT event_id, to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role FROM trip_nodes WHERE event_id IN ($in) AND stage IN ('plan','actual') AND to_city IS NOT NULL AND to_city <> '' ) c GROUP BY c.event_id, c.city ORDER BY c.event_id, MIN(c.t), MIN(c.role), c.city" ); $st2->execute(array_merge($ids, $ids)); foreach ($st2->fetchAll() as $r) { $id = (int)$r['event_id']; $p = mb_substr(trim((string)$r['city']), 0, 60, 'UTF-8'); if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) { $city[$id][] = $p; } } } foreach ($rows as &$r) { $id = (int)$r['id']; $r['starts_at'] = $span[$id]['starts_at'] ?? null; $r['ends_at'] = $span[$id]['ends_at'] ?? null; $r['cities'] = $city[$id] ?? []; $r['city'] = self::citiesWord($r['cities']); } unset($r); } /** 全部旅程(按**派生**的出发时间倒序 —— 活动自己没有时间字段了) */ public static function all(int $familyId, string $keyword = ''): array { // ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。 // 用 LEFT JOIN + COALESCE 而不是 INNER JOIN: // 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程 // 从列表里消失("行程丢了"比"多显示一条"严重得多), // COALESCE(...,1) 表示"没有这行就算公开"。 // ⚠️ 参与人 = **所有行程参与人的合集**(v24 第 14 批 用户第 4 条): // 只在活动上登记、每段行程都没填成员的人也要算进来(否则老数据会"人没了")。 // ⚠️ e.starts_at / e.ends_at / e.location 这三列**仍然 SELECT 出来**, // 但马上会被 attachDerived() 的派生值覆盖(用户第 15 批 第 2 条)。 // 留着 select 是为了:① 不 SELECT 的话 PHP 侧少键,若哪天有代码先读再做 // 兜底,会出现 Undefined index;② 出问题时肉眼能对着看"老值是什么"。 $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public, /* ⚠️ 不能写成 SELECT COUNT(*) FROM (… WHERE a.event_id = e.id UNION …) u:派生表里的 e.id 是**外层**列,MySQL 8 之前 不支持(要 LATERAL),MariaDB 也一样 —— 直接报列不存在。 所以拆成两段相加:活动上登记的 + 行程里**没重复**的那些。 */ ((SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) + (SELECT COUNT(DISTINCT m.person_id) FROM trip_node_members m JOIN trip_nodes n2 ON n2.id = m.node_id WHERE n2.event_id = e.id AND m.person_id NOT IN (SELECT a2.person_id FROM event_attendees a2 WHERE a2.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 = ? AND COALESCE(d.is_public, 1) = 1'; $bind = [$familyId, 'trip']; $kw = trim($keyword); if ($kw !== '') { // ⚠️ 搜索里的时间/城市也是**派生**的: // 活动自己的 location 已经不用了(用户第 2 条), // 搜城市必须搜到"在行程里填过的地方",否则搜"青岛"搜不到那趟青岛之行。 $sql .= ' AND (e.title LIKE ? OR e.note LIKE ? OR EXISTS (SELECT 1 FROM trip_nodes n3 WHERE n3.event_id = e.id AND n3.place LIKE ?))'; $like = '%' . $kw . '%'; array_push($bind, $like, $like, $like); } // 排序也走派生时间:子查询里没有行程时得排到最后(NULL) $sql .= ' ORDER BY (SELECT MIN(n4.starts_at) FROM trip_nodes n4 WHERE n4.event_id = e.id AND n4.stage IN ("plan","actual")) DESC, e.id DESC'; $st = Database::get()->prepare($sql); $st->execute($bind); $rows = $st->fetchAll(); // 派生时间与城市:一次查询批量补(见 attachDerived 的说明) self::attachDerived($rows); return $rows; } /** * 给一批旅程补上「同行的人」= **所有行程参与人的合集**(用户第 4 条)。 * * ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。 * 一次查询拿全部(活动登记 ∪ 各段行程成员),再按 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), '?')); // 合集:**活动上登记的人** ∪ **每段行程的参与人**(用户第 4 条)。 // UNION 自带去重,所以同一个人既在活动上、又在三段行程里也只出现一次。 $st = Database::get()->prepare( 'SELECT u.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM ( SELECT a.event_id, a.person_id FROM event_attendees a WHERE a.event_id IN (' . $in . ') UNION SELECT nd.event_id, m.person_id FROM trip_node_members m JOIN trip_nodes nd ON nd.id = m.node_id WHERE nd.event_id IN (' . $in . ') ) u JOIN persons p ON p.id = u.person_id LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? ORDER BY u.event_id, p.id' ); $st->execute(array_merge($ids, $ids, [$viewerId])); 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( // ⚠️ 单条详情**不按 is_public 拦**:勾了"不公开"只代表不进列表/日历, // 直接拿到链接的人(或行程成员)仍应该能打开它 —— 否则非遗漏一次就找不回来。 "SELECT e.*, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public 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; } // 参与人 = 所有行程参与人的**合集**(用户第 4 条),不是只在活动上登记的那几个 $row['attendees'] = self::tripPeople($id, $viewerId); $row['photos'] = \App\Attachment::list('trip', $id); // 时间与城市 = 从各段旅程派生(用户第 15 批 第 2 条)。 // ⚠️ **覆盖**掉 events 上的老值:老数据可能自己填过起止与目的地, // 但页面必须以行程为准 —— 否则会出现"活动写着 8/1–8/5,行程只有 8/3 一段" // 这种自相矛盾的显示,而看的人无从判断该信哪个。 $d = self::derived($id); $row['starts_at'] = $d['starts_at']; $row['ends_at'] = $d['ends_at']; $row['cities'] = $d['cities']; $row['city'] = $d['city']; $row['location'] = $d['city']; // 模板里还有读 location 的地方,一并给派生值 return $row; } /** * 一趟旅程的参与人 = **所有行程(节点)参与人的合集**(v24 第 14 批 用户第 4 条)。 * * 用户原话:「一个活动的参与人,应该是这个活动下的所有旅行(程)的参与人的合集才对。」 * * 为什么不在保存行程时"顺手同步"到活动上: * 那就是同一份名单存两处,改行程要记得改活动、删行程要记得减人 —— * 任何一处漏了就对不上。合求是**读的时候算出来的**,永远不会漂移。 * * ⚠️ 取的是**并集**(活动上单独登记的人也保留): * 老数据里很多人只在活动上登记过、每段行程没填成员, * 只按行程算会让这些人"凭空消失"。 * * @return array 去重后的人(带「我叫他什么」与主色) */ public static function tripPeople(int $eventId, int $viewerId = 0): array { $st = Database::get()->prepare( 'SELECT DISTINCT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM ( SELECT a.person_id FROM event_attendees a WHERE a.event_id = ? UNION SELECT m.person_id FROM trip_node_members m JOIN trip_nodes nd ON nd.id = m.node_id WHERE nd.event_id = ? ) u JOIN persons p ON p.id = u.person_id LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? ORDER BY p.id' ); $st->execute([$eventId, $eventId, $viewerId]); return $st->fetchAll(); } /** 同上,但只给一个**人数**(列表页用,避免为每行再拉一堆人) */ public static function tripPeopleCount(int $eventId): int { $st = Database::get()->prepare( 'SELECT COUNT(*) FROM ( SELECT a.person_id FROM event_attendees a WHERE a.event_id = ? UNION SELECT m.person_id FROM trip_node_members m JOIN trip_nodes nd ON nd.id = m.node_id WHERE nd.event_id = ? ) u' ); $st->execute([$eventId, $eventId]); return (int)$st->fetchColumn(); } /** 同行的人(带「我叫他什么」与主色,页面按人画标签) */ 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(); // ⚠️ 用户第 15 批 第 2 条:活动**不存**起止时间与目的地 —— // 它们从各段旅程派生(见 derived())。这里显式写 NULL, // 不接表单传来的值(表单里也没有这些字段了)。 // events.starts_at / ends_at / location 保持可空,只是为了不动表结构。 $db->prepare( "INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref) VALUES (?, ?, 'trip', ?, NULL, NULL, NULL, ?, ?)" )->execute([ $familyId, $creatorId, (string)$d['title'], self::textOrNull($d['note'] ?? ''), $d['source_ref'] ?? null, ]); $id = (int)$db->lastInsertId(); self::saveDetails($id, $d); // ⚠️ 只有**真的传了** attendees 才动这份名单。 // v24 第 16 批 用户第 10 条之后,旅程表单里已经没有"同行的人"字段了 —— // 如果这里写成 `$d['attendees'] ?? []`,每次编辑旅程都会把 // **活动级**的老名单整个清空(老数据里很多人的参与人只登记在这一层, // 那时还没有行程级参与人)→ 用户看到的是"我一编辑,参与人全没了"。 // 新数据的参与人从行程里长出来(tripPeople 的合集口径),不受影响。 if (array_key_exists('attendees', $d)) { self::saveAttendees($id, (array)$d['attendees']); } return $id; } public static function update(int $familyId, int $id, array $d): void { $db = Database::get(); // ⚠️ 与 create 同理:**不碰** starts_at / ends_at / location。 // 连"置 NULL"都不做 —— 老数据里可能存过值,编辑一次活动不该把它抹掉 // (页面反正不读它们;真出问题时还能拿老值对着看)。 $db->prepare( "UPDATE events SET title=?, note=? WHERE id=? AND family_id=? AND kind='trip'" )->execute([ (string)$d['title'], self::textOrNull($d['note'] ?? ''), $id, $familyId, ]); self::saveDetails($id, $d); // ⚠️ 同 create:没传就不动(见上面那段说明) if (array_key_exists('attendees', $d)) { self::saveAttendees($id, (array)$d['attendees']); } } private static function saveDetails(int $eventId, array $d): void { $db = Database::get(); /* 先读一次现有值 —— `cover_key` **没传时要保留原值**。 🔴 v77 修的真实 bug(2026-10-22 查出来的): 端上编辑旅程走 `PATCH /api/v1/trips/`,那个分支的 `$d` 里 只有 title / note / summary / is_public —— 于是这里 `$d['cover_key'] ?? ''` 得到空串 ⇒ `keyOrNull('')` ⇒ **null** ⇒ 用户在手机上只改了个名字,题图就被**静默清空**了。 (网页版没这个病:`index.php` 的保存路径每次都显式带上旧值。) ⚠️ 语义按"没传 = 不动":要清空题图必须显式传 `cover_key = ''`。 */ $sel = $db->prepare('SELECT cover_key FROM trip_details WHERE event_id = ?'); $sel->execute([$eventId]); $cur = $sel->fetchColumn(); $has = $cur !== false; $cover = array_key_exists('cover_key', $d) ? self::keyOrNull((string)$d['cover_key']) : ($has ? self::keyOrNull((string)$cur) : null); $row = [ $cover, self::textOrNull($d['summary'] ?? ''), // 用户第 4 条:是否公开。 // ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」: // · 没传(程序化创建 / 迁移 / 老调用)→ **按公开(1)**, // 与建表默认值一致;默认 0 会让数据**静默消失**在列表里, // 那是最坏的一种失败(用户只会看到"我的行程不见了")。 // · 传了 0(表单里取消勾选)→ 真的不公开。 array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1, ]; if ($has) { $db->prepare('UPDATE trip_details SET cover_key=?, summary=?, is_public=? WHERE event_id=?') ->execute([...$row, $eventId]); } else { $db->prepare('INSERT INTO trip_details (cover_key, summary, is_public, event_id) VALUES (?,?,?,?)') ->execute([...$row, $eventId]); } } /** * 单独换题图(端上 `POST /api/v1/trips//cover` 用)。 * * ⚠️ 为什么不复用 `update()`:那条要求 `title` 必填、而且会把 * note / summary / is_public 一起重写 —— 只想换张图却要先把 * 整趟旅程的其它字段原样传一遍,最容易在下次加字段时漏掉一个 * (漏了的后果就是静默清空)。换图就只动 cover_key 一列。 * * @param string $key 新的存储键(`covers/xxx.jpg`);空串 = 清掉题图 */ public static function setCover(int $eventId, string $key): void { $db = Database::get(); $val = self::keyOrNull($key); $sel = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?'); $sel->execute([$eventId]); if ((int)$sel->fetchColumn() > 0) { $db->prepare('UPDATE trip_details SET cover_key = ? WHERE event_id = ?') ->execute([$val, $eventId]); } else { // 老旅程可能压根没有 trip_details 行(早于这张表的年份)⇒ 补一行 $db->prepare( 'INSERT INTO trip_details (cover_key, summary, is_public, event_id) VALUES (?, NULL, 1, ?)' )->execute([$val, $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]); } // ---------------- 行程节点 ---------------- /** 这趟旅程的全部节点(计划 / 实际 / 未成行,按**开始时间正序**排) */ public static function nodes(int $eventId, int $viewerId = 0): array { /* 🔴 v24 第 21 批 第 5 条:排序口径改成「按开始时间」。 用户原话:「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的, 最新的在最上面,而不是按照编辑修改时间。」 ⚠️ 原来排的是 `category, position, starts_at, id`: `category` 只有 plan/reality 两个值(老的双轨字段,现在由 stage 派生), `position` 是**默认同一个数**(旧迁移给的多是 0 或 100), 于是真正决定顺序的只剩 position —— 同一 position 里按 starts_at 排, 但 plan 组永远排在 reality 组前面。 实测线上 #222 就渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」, 既不是时间正序也不是倒序 —— 一条时间线上看,位置是"乱"的。 ⚠️ 排序方向:**正序**(早的在前)。 用户对**旅程列表**的要求是"最新在最上面"(那是列表,见 Trip::all 的 DESC); 但**行程列表**是一趟旅行内部的行程单 —— 从出发读到返回才符合直觉。 这一点已跟用户确认过(第 21 批选的是"时间正序")。 ⚠️ 两者方向不同是**刻意的**,不是漏改:一个是"挑哪趟旅行", 一个是"读这趟旅行的行程"。别为了"看起来统一"把其中一个拧过来。 ⚠️ 没有开始时间的排在最后(否则 NULL 会被当成最小值顶到最前面)。 `(starts_at IS NULL)` 在 MySQL/MariaDB 里就是 1/0,用它当第一个键即可。 ⚠️ `id` 兜底:同一时刻的两段(中转/换乘常常同分)要有稳定顺序, 否则每次渲染可能换位置。 */ $st = Database::get()->prepare( 'SELECT * FROM trip_nodes WHERE event_id = ? ORDER BY (starts_at IS NULL), starts_at, id' ); $st->execute([$eventId]); $rows = $st->fetchAll(); $ids = array_map('intval', array_column($rows, 'id')); /* 🔴 v41(用户 2026-10-10 第 8 条):行程行要把「自驾的车牌号」直接跟在 「自驾」两个字后面 ⇒ 这里一次性把用到的车辆**车牌**挂上去。 ⚠️ 用一条 IN 查询而不是逐行 JOIN:一张"十几段"的行程单逐行查会把 N 次往返变成 N 次;车辆是低频引用(多数段没有车), IN 一次就够。 ⚠️ 字段名 `vehicle_plate` 与 `Vehicle::label` 的取向不同: label 是给"选车下拉"用的(车牌 + 名称,方便辨认), 详情页要的是**车牌号本身**(用户原话就是"车牌号")。 */ $vehIds = array_values(array_filter(array_map( static fn($n) => (int)($n['vehicle_id'] ?? 0), $rows))); if ($vehIds) { $vin = implode(',', array_fill(0, count(array_unique($vehIds)), '?')); $sv = Database::get()->prepare( "SELECT id, plate FROM vehicles WHERE id IN ($vin)" ); $sv->execute(array_values(array_unique($vehIds))); $plates = []; foreach ($sv->fetchAll() as $v) { $plates[(int)$v['id']] = (string)$v['plate']; } foreach ($rows as &$nv) { $nv['vehicle_plate'] = $plates[(int)($nv['vehicle_id'] ?? 0)] ?? ''; } unset($nv); } // 一次性把全部节点的附件取出来(逐节点查会让"十几段的旅程"变成几十次查询) $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); // 每段花了多少(用户第 2 条:总花销 = 各段之和;这里是各段的那个"段值") \App\Expense::attachSums($rows); return $rows; } /** * 一段行程(单条)。 * * 🔴 v24 第 19 批 第 5 条修的是这里:这个方法原来只 `SELECT * FROM trip_nodes`, * **不带 `members` / `attachs`** —— 而 `Trip::nodes()`(列表)是带的。 * 结果:编辑行程时 `$n['members']` 永远是空数组 → * `_person_picker` 收到 `$ppSelected = []` → 参与人框**一片空白**。 * 用户原话:「编辑行程,参与的人,没有带进来,还是空的。」 * 同一处还让"编辑页的照片墙"永远不显示(attachs 也缺)。 * * ⚠️ 所以单条也要挂上这两样,与 `nodes()` **同一口径** —— * 两个方法一个带、一个不带,正是这类"只有编辑页不显示"的根源。 * * @param int $viewerId 按"谁在看"取成员的昵称(编辑表单只用 id,传 0 也行) */ public static function node(int $id, int $viewerId = 0): ?array { $st = Database::get()->prepare('SELECT * FROM trip_nodes WHERE id = ?'); $st->execute([$id]); $r = $st->fetch(); if (!$r) { return null; } $r['members'] = self::nodeMembersFor($id, $viewerId); $r['attachs'] = \App\Attachment::list('trip_node', $id); // 每段花了多少(与 nodes() 一致:详情页/编辑页的"这一段花了多少"读它) $rows = [$r]; \App\Expense::attachSums($rows); return $rows[0]; } /** * 保存一段行程(新增 / 编辑)。 * * v24 第 16 批 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的 * 添加节点功能,跟那个一样」→ 字段**全量照搬**: * 类型 / 标题 / 起止时间 / 地点 / 出行方式 + 车辆 / 线路名 / 承运方 / * 车次航班号 / 座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(+角色)/ 照片。 * (花销走 Expense 单独增删,不在这张表单里一并提交:一笔一笔加更符合实际, * 也避免"提交一次行程就要把整串花销重算一遍"。) * * ⚠️ 交通类字段(车次/座位/登机口…)**不做条件列**:用户可能先填了高铁车次, * 后来把出行方式改成自驾 —— 那时按逻辑该清空车次,但"清空"意味着丢数据。 * 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 + * 该出行方式相关的),这样换个方式再换回来,填过的东西还在。 */ public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int { $db = Database::get(); // v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。 // 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。 $stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true) ? (string)$d['stage'] : 'plan'; // ⚠️ category 只作**兼容保留**(老代码/导出还会读它),业务一律看 stage。 // 这里同步维护,保证两边不打架:stage=actual → reality,其余 → plan。 $category = $stage === 'actual' ? 'reality' : 'plan'; // 车辆:只认本家的(负数/0 是"来自财务的引用",那不是本表的行,存不进去) $vid = (int)($d['vehicle_id'] ?? 0); if ($vid > 0 && !\App\Vehicle::find($familyId, $vid)) { $vid = 0; } // ---- v24 第 17 批:出发地 / 到达地的**详细地址 + 坐标 + 城市** ---- // 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。 // 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」 // → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。 $fromPlace = (string)($d['from_place'] ?? ''); $toPlace = (string)($d['to_place'] ?? ''); $fromAddr = trim((string)($d['from_addr'] ?? '')); $toAddr = trim((string)($d['to_addr'] ?? '')); $fromLng = self::coordOrNull($d['from_lng'] ?? null); $fromLat = self::coordOrNull($d['from_lat'] ?? null); $toLng = self::coordOrNull($d['to_lng'] ?? null); $toLat = self::coordOrNull($d['to_lat'] ?? null); // 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本), // 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。 $fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'); $toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'); if ($fromCity === '') { $fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr); } if ($toCity === '') { $toCity = \App\Geo::cityFromPoint($toLng, $toLat, $toPlace !== '' ? $toPlace : $toAddr); } // 详细地址为空时用地点名兜底(用户看到的是"出发地"那行字,不能空着) if ($fromAddr === '') { $fromAddr = $fromPlace; } if ($toAddr === '') { $toAddr = $toPlace; } $fields = [ self::textOrNull2($d['title'] ?? ''), self::textOrNull($d['detail'] ?? ''), self::textOrNull($d['place'] ?? ''), self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::nodeType($familyId, $d['node_type'] ?? ''), in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned', $category, (int)($d['position'] ?? 100), $stage, // ---- v21 新增:交通与起终点(TripLog 同款)---- isset(self::TRAVEL_MODES[(string)($d['travel_mode'] ?? '')]) ? (string)$d['travel_mode'] : null, $vid > 0 ? $vid : null, self::textOrNull2($d['line_name'] ?? ''), self::textOrNull2($d['carrier'] ?? ''), self::textOrNull($d['trip_no'] ?? ''), // ⚠️ v24 第 20 批 第 5 条:线路名 / 承运方**不再从表单接收** // (用户:「添加行程,线路名、承运方,都不要了。」)。 // 它们与上面的 seat_info / gate 同一处境:**只在 INSERT 时写入, // UPDATE 时不参与** —— 否则表单不带这两个字段 → `$d` 里是空串 → // `textOrNull2('')` 返回 `''`(不是 null!)→ 每次编辑行程都会把 // TripLog 迁过来的「京沪高铁 / 中国铁路」**静默清成空串**。 // 这正是"第 17 批删了字段但数据没丢"能成立的原因,照抄同一套。 // (下面 $colsKeep 与 array_splice 的说明里也点了这两列。) // ⚠️ 第 17 批:这两个字段**不再从表单接收**(用户第 3 条要求去掉)。 // 但编辑老行程时如果把它们写成 null,TripLog 迁来的座位信息就永远消失了 // —— 所以这里"保持原值不变"(下面对 UPDATE 单独处理这两列)。 self::textOrNull($d['seat_info'] ?? ''), self::textOrNull($d['gate'] ?? ''), self::textOrNull2($fromPlace), self::textOrNull2($toPlace), // ---- v22 新增:城市 / 详细地址 / 坐标 ---- self::textOrNull($fromCity), self::textOrNull($toCity), self::textOrNull($fromAddr), self::textOrNull($toAddr), $fromLng, $fromLat, $toLng, $toLat, ]; $cols = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?, category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?, carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?, from_city=?, to_city=?, from_addr=?, to_addr=?, from_lng=?, from_lat=?, to_lng=?, to_lat=?'; if ($id !== null && $id > 0) { // ⚠️ `seat_info` / `gate` 在 UPDATE 里**不参与**(表单已不提交它们): // 写成 null 会把 TripLog 迁来的老数据抹掉。这两列从此只读不写。 // ⚠️ v24 第 20 批 第 5 条:`line_name` / `carrier` 也加进"只读不写"那一组 —— // 原因见上面 $fields 里那段(表单不再收它们,写进去就会清空老数据)。 $colsKeep = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?, category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, trip_no=?, from_place=?, to_place=?, from_city=?, to_city=?, from_addr=?, to_addr=?, from_lng=?, from_lat=?, to_lng=?, to_lat=?'; $keep = $fields; // ⚠️ 先 splice **下标大**的那一组,再 splice 小的 —— 反了会错位。 // $fields 的下标:12=line_name, 13=carrier, 15=seat_info, 16=gate。 array_splice($keep, 15, 2); // 去掉 seat_info / gate array_splice($keep, 12, 2); // 去掉 line_name / carrier $db->prepare('UPDATE trip_nodes SET ' . $colsKeep . ' WHERE id=?') ->execute([...$keep, $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, stage, travel_mode, vehicle_id, line_name, carrier, trip_no, seat_info, gate, from_place, to_place, from_city, to_city, from_addr, to_addr, from_lng, from_lat, to_lng, to_lat) VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')' )->execute([$familyId, $eventId, ...$fields]); $nid = (int)$db->lastInsertId(); } // ⚠️ 第 17 批:**角色标注功能已去掉**(用户第 3 条:「给参与人标注角色这个功能 // 去掉,不要了」)→ 不再传 $d['roles']。saveNodeMembers 的第三个参数 // 保留(老调用点/修正流程还在用),传空数组即可。 self::saveNodeMembers($nid, $d['members'] ?? []); return $nid; } /** 经纬度:只接受合理的数值范围,其余一律 null(脏数据不进库) */ private static function coordOrNull($v): ?float { if ($v === null || $v === '' || !is_numeric($v)) { return null; } $f = (float)$v; // 经度 ±180、纬度 ±90;超出范围的显然是解析错了,宁可不存 if (abs($f) > 180.0) { return null; } return $f; } /** * 行程类型:存**名字**(可自定义的类型表里那个 name),认不出退回 'other'。 * * ⚠️ 为什么存名字而不是 id:用户把类型改名时要级联到所有行程 * (NodeType::save 里做了),存名字让"改名"是一句 UPDATE; * 存 id 则要在每次显示时 JOIN,而且删除类型会让老行程显示成"未知"。 * 兼容老数据:英文 key(play/move/…)原样保留 —— NodeType::iconOf 认得它。 */ private static function nodeType(int $familyId, string $v): string { $v = trim($v); if ($v === '') { return 'other'; } if (isset(self::NODE_TYPES[$v])) { return $v; // 老数据的英文 key } // 是否是本家定义过的类型名(含刚预置的那几种) foreach (\App\NodeType::all($familyId) as $t) { if ((string)$t['name'] === $v) { return $v; } } return 'other'; } // ---------------- 计划行程的三个动作(v24 第 13 批) ---------------- // // 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作」 // · 标记未成行(用户原话叫"删除",但明确"不要真的删除,只是做一个标记") // · 修正(记录实际执行情况,与计划是**同一条记录**,可无限次) // · 完成(实际与计划完全相同,直接变实际行程) /** * 「修正」:记下一次实际执行情况,**行程本身仍是计划**(用户确认的语义: * 修正后要再点一次「标记为实际」才正式转为实际行程)。 * * 🔴 关键:这是**同一条记录**,不是新建第二条。所以做法是 * ① 把这次填的实际值**快照**进 trip_node_revisions(历史,可无限次); * ② 同时把 trip_nodes 上的实际值写进去(这样"计划"旁边就能看到"实际打算怎么走"), * 但**不动 stage** —— 它还是 plan,用户点「标记为实际」才会变 actual。 * 这样"改动前的计划数据"永远留着(历史表第一份快照就是原计划),日后可对比。 * ⚠️ 原计划数据**不能丢**:第一次修正前先把当前值存成 seq=1 的历史快照。 * * @return int 这次修正的序号(seq) */ public static function reviseNode(int $nodeId, array $d, ?int $byPerson = null): int { $db = Database::get(); $cur = self::node($nodeId); if (!$cur) { return 0; } // 第一次修正前,先把**原计划**存成 seq=1 —— 否则"之前的计划数据要保留" // 这条就落空了(用户明确要求保留,为了以后做对比)。 $n = (int)($cur['revision_n'] ?? 0); if ($n === 0) { $db->prepare( 'INSERT INTO trip_node_revisions (node_id, seq, starts_at, ends_at, place, detail, members, created_by) VALUES (?,?,?,?,?,?,?,?)' )->execute([$nodeId, 1, $cur['starts_at'], $cur['ends_at'], $cur['place'], $cur['detail'], self::membersCsv($nodeId), $byPerson]); $n = 1; } $seq = $n + 1; $members = isset($d['members']) ? implode(',', array_map('intval', (array)$d['members'])) : null; $db->prepare( 'INSERT INTO trip_node_revisions (node_id, seq, starts_at, ends_at, place, detail, members, created_by) VALUES (?,?,?,?,?,?,?,?)' )->execute([$nodeId, $seq, self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::textOrNull($d['place'] ?? ''), self::textOrNull($d['detail'] ?? ''), $members, $byPerson]); // 行程上同步"实际打算"的起止与地点(stage 不动,仍是计划) $db->prepare( 'UPDATE trip_nodes SET starts_at=COALESCE(?, starts_at), ends_at=COALESCE(?, ends_at), place=COALESCE(?, place), revised_at=NOW(), revision_n=? WHERE id=?' )->execute([self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::textOrNull($d['place'] ?? ''), $seq, $nodeId]); if (isset($d['members'])) { self::saveNodeMembers($nodeId, (array)$d['members']); } return $seq; } /** * 「标记为实际」:把这条**计划行程**正式转成实际行程。 * * 用户确认的流程是"先修正(可多次)→ 再点标记为实际"。 * 转到 actual 之后:它会以**实际时间**出现在日历,并进「往年今日」。 */ public static function markActual(int $nodeId): bool { $cur = self::node($nodeId); if (!$cur) { return false; } Database::get()->prepare( "UPDATE trip_nodes SET stage='actual', category='reality', status='done', updated_at=NOW() WHERE id=?" )->execute([$nodeId]); return true; } /** * 「完成」:实际执行与计划**完全相同**,无需任何修改,直接变成实际行程。 * * 与「修正 + 标记为实际」的区别:**不产生修正历史**(没有变化就不该有历史), * 只是把 stage 推到 actual、status 推到 done。 */ public static function completeNode(int $nodeId): bool { $cur = self::node($nodeId); if (!$cur) { return false; } Database::get()->prepare( "UPDATE trip_nodes SET stage='actual', category='reality', status='done', updated_at=NOW() WHERE id=?" )->execute([$nodeId]); return true; } /** * 「标记未成行」:**不是真删**,只打标记(用户明确要求)。 * * 后果:① 不再出现在日程;② 不进「往年今日」("就权当没有这个日程"); * ③ 但在旅程模块里**仍然显示**并标注"未成行"(用户要求保留可查)。 */ public static function skipNode(int $nodeId): bool { $cur = self::node($nodeId); if (!$cur) { return false; } Database::get()->prepare( "UPDATE trip_nodes SET stage='skipped', status='cancelled', updated_at=NOW() WHERE id=?" )->execute([$nodeId]); return true; } /** 撤销「未成行」,退回计划行程(点错了要能改回来) */ public static function restoreNode(int $nodeId): bool { Database::get()->prepare( "UPDATE trip_nodes SET stage='plan', category='plan', status='planned', updated_at=NOW() WHERE id=? AND stage='skipped'" )->execute([$nodeId]); return true; } /** 一条行程的修正历史(按 seq 升序:第 1 条 = 原计划,后面是每次修正) */ public static function revisions(int $nodeId): array { $st = Database::get()->prepare( 'SELECT * FROM trip_node_revisions WHERE node_id = ? ORDER BY seq' ); $st->execute([$nodeId]); return $st->fetchAll(); } /** 把某行程的参与人取成 "1,2,3"(存进修正快照用 —— 快照要留当时的名单) */ private static function membersCsv(int $nodeId): string { $st = Database::get()->prepare( 'SELECT person_id FROM trip_node_members WHERE node_id = ? ORDER BY person_id' ); $st->execute([$nodeId]); return implode(',', array_map('intval', array_column($st->fetchAll(), 'person_id'))); } 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, m.role_tag 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(); } /** * 存一段行程的参与人(整组替换)。 * * @param array $roles 可选:person_id => 角色标签(TripLog 的"司机/摄影担当"); * 不传就是没有角色,与老调用完全兼容。 */ public static function saveNodeMembers(int $nodeId, array $personIds, array $roles = []): void { $db = Database::get(); // ⚠️ v24 第 17 批:**不再整表 DELETE 再插**,改成"差集增删"。 // 原因:用户第 3 条把角色标注功能去掉了(表单不再提交 roles), // 如果这里照旧先删光再插,`role_tag` 就会被写成 null —— // TripLog 迁来的角色数据(虽然界面不再显示)会被**静默抹掉**。 // 现在只做两件事:删掉"本次没选的人",插入"新选的人"; // 已存在且仍被选中的,**原样不动**(role_tag 保住)。 $keep = []; foreach ($personIds as $pid) { $pid = (int)$pid; if ($pid > 0) { $keep[$pid] = true; } } if ($keep) { $in = implode(',', array_fill(0, count($keep), '?')); $db->prepare("DELETE FROM trip_node_members WHERE node_id = ? AND person_id NOT IN ($in)") ->execute([$nodeId, ...array_keys($keep)]); } else { $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, role_tag) VALUES (?,?,?)'); foreach (array_keys($keep) as $pid) { $role = mb_substr(trim((string)($roles[$pid] ?? $roles[(string)$pid] ?? '')), 0, 30, 'UTF-8'); $ins->execute([$nodeId, $pid, $role !== '' ? $role : null]); } } /** * 一段旅程(活动)的**汇总信息**(v24 第 16 批 用户第 2 条)。 * * 用户原话:「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、 * 一共几段行程,一共花了多少钱,这些信息,都从该活动下的所有行程 * 统计总结。」 * * 五项全是从行程**算出来**的(与"时间/城市派生"同一条原则,不存冗余): * · people —— 各段行程参与人的并集(就是 tripPeople,已有口径) * · cities —— 各段行程去过的地点去重(未成行段不算) * · days —— 从最早一段的开始 到 最晚一段的结束,共几天(含首尾; * 跨天算天,同一天往返算 1 天) * · nodes —— 行程段数(**只数会显示的**:计划 + 实际;未成行的单独给一个数) * · cents —— 各段行程花销之和 * * ⚠️ days 的算法要说清楚,否则"3 天"到底是几天会各人一套算法: * 按**日历天**算(8/1 14:00 出发、8/3 10:00 回来 = 3 天), * 不是按 24 小时整除(那样会算成 2 天,与人的直觉不符)。 */ public static function summary(int $eventId, int $viewerId = 0): array { $st = Database::get()->prepare( "SELECT MIN(starts_at) AS start_at, MAX(COALESCE(ends_at, starts_at)) AS end_at, SUM(CASE WHEN stage IN ('plan','actual') THEN 1 ELSE 0 END) AS shown_n, SUM(CASE WHEN stage = 'skipped' THEN 1 ELSE 0 END) AS skipped_n FROM trip_nodes WHERE event_id = ?" ); $st->execute([$eventId]); $r = $st->fetch() ?: []; $days = 0; $start = (string)($r['start_at'] ?? ''); $end = (string)($r['end_at'] ?? ''); if ($start !== '') { $a = strtotime(substr($start, 0, 10)); $b = strtotime(substr($end !== '' ? $end : $start, 0, 10)); if ($a !== false && $b !== false && $b >= $a) { // 含首尾:8/1 → 8/3 是 3 天(floor(差/86400) + 1) $days = (int)floor(($b - $a) / 86400) + 1; } } return [ 'people' => self::tripPeople($eventId, $viewerId), 'cities' => self::cities($eventId), 'days' => $days, 'nodes' => (int)($r['shown_n'] ?? 0), 'skipped' => (int)($r['skipped_n'] ?? 0), 'cents' => \App\Expense::sumOfEvent($eventId), 'spent' => \App\Expense::money(\App\Expense::sumOfEvent($eventId)), ]; } // ---------------- 展示 ---------------- /** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */ /** * 活动的时间文案(用户第 15 批 第 2 条:时间是从旅程算出来的)。 * * ⚠️ 空的时候**不能**沿用 formatSpan 的「日期待补」:那是给"一段没填时间的行程" * 用的短语,放在活动上会让人以为"这里本来该由我填、我忘了填"。 * 活动的时间本来就该由旅程决定 —— 没有旅程时要说清楚是**去加一段旅程**。 */ public static function activitySpan(?string $start, ?string $end): string { if ($start === null || $start === '') { return '还没有旅程'; } $s = date('Y年n月j日', (int)strtotime($start)); if ($end === null || $end === '') { return $s . ' 起'; } return $s . ' – ' . date('Y年n月j日', (int)strtotime($end)); } 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); } /** * 一段行程的时间,**精确到时分**(用户第 17 批 第 4 条末)。 * * 用户原话:「行程中,时间明明具体到了时分秒,怎么不显示?」 * * ⚠️ 原来用的是 `formatSpan()` —— 它只到**日**(`Y年n月j日`)。 * 那个精度是给"旅程"(容器)用的:一次出行说"7月10日到7月12日"就够。 * 但**一段行程**(比如"10:30 出发去机场")只显示日期等于没说 —— * 用户填进去的时分秒被吞掉了,看起来像"我填的时间没保存"。 * * 展示规则(同一天时只写一次日期,避免"2026年7月10日 10:00 – 2026年7月10日 12:00" * 这种把同一天写两遍的啰嗦写法): * · 同一天:`2026年7月10日 10:00 – 12:00` * · 跨天: `2026年7月10日 10:00 – 7月12日 17:00` * · 只有开始:`2026年7月10日 10:00` * · 时分是 00:00(老数据只存了日期):**不写 00:00** —— 那会让人以为 * "这个人专门挑午夜开始",其实只是没填时间。 */ public static function nodeSpan(?string $start, ?string $end): string { if ($start === null || $start === '') { return '日期待补'; } $ts = strtotime($start); if ($ts === false) { return '日期待补'; } $sDate = date('Y年n月j日', $ts); $sTime = date('H:i', $ts); // 午夜 00:00 视为"没填具体时间"(日期控件的默认值),不显示它 $sHasTime = $sTime !== '00:00'; $head = $sDate . ($sHasTime ? ' ' . $sTime : ''); if ($end === null || $end === '') { return $head; } $te = strtotime($end); if ($te === false) { return $head; } $eTime = date('H:i', $te); $eHasTime = $eTime !== '00:00'; // 同一天:后半段只写时分 if (date('Y-m-d', $ts) === date('Y-m-d', $te)) { if (!$eHasTime && !$sHasTime) { return $sDate; // 两头都没时分 → 就是一天 } if (!$eHasTime) { return $head . ' 起'; // 结束没填时分:说"从几点开始"更准 } return $sDate . ($sHasTime ? ' ' . $sTime : '') . ' – ' . $eTime; } // 跨天:结束那天写月日(不重复年份) return $head . ' – ' . date('n月j日', $te) . ($eHasTime ? ' ' . $eTime : ''); } /** * 行程类型的**名字**。 * * ⚠️ 两种取值来源,顺序不能反: * ① 用户在设置里定义的类型表(`NodeType::all()`,按名字存进 trip_nodes)—— * 这是"用户改过的真相",优先级最高; * ② 老数据的英文 key(play/move/…)→ 内置常量表。 * 少了 ① 就会把用户自定义的类型名显示成"其他"(看起来像类型丢了)。 * * @param int $familyId 传 0 表示"只按内置表解析"(没有家庭上下文时用) */ public static function typeWord(string $key, int $familyId = 0): string { if (isset(self::NODE_TYPES[$key])) { return self::NODE_TYPES[$key][0]; } if ($familyId > 0) { foreach (\App\NodeType::all($familyId) as $t) { if ((string)$t['name'] === $key) { return $key; } } } return $key !== '' ? $key : '其他'; } public static function typeIcon(string $key, int $familyId = 0): string { if ($familyId > 0) { return \App\NodeType::iconOf($familyId, $key); } return self::NODE_TYPES[$key][1] ?? '📌'; } /** 出行方式的名字 / 图标 */ public static function modeWord(string $key): string { return self::TRAVEL_MODES[$key][0] ?? ''; } public static function modeIcon(string $key): string { return self::TRAVEL_MODES[$key][1] ?? '🧭'; } /** 这个出行方式要不要选车辆 */ public static function modeNeedsVehicle(string $key): bool { return in_array($key, self::DRIVE_MODES, true); } 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; } }