Files
FamilyBook/app/Trip.php
T
laoyang c30ca99d22 v24 第 17 批:城市聚合 / 高德地址 / 表单精简(用户 10 条)
用户 10 条原话要点与落地:
- ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程,
  都有青岛作为出发地或者目的地,那么就只记一次」
  → trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成
    「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。
    详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市,
    而不是终点地址」)。
- ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」
  → NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。
- ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车,
  只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive'];
  Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。
- ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
  → seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢;
    saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。
- ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」
  +「给参与人标注角色这个功能去掉」
  → 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 +
    导航链接,没配 key 也能手输);参与人改用全局弹框选人组件;
    角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。
- ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」
  → 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、
    车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。
- ④「行程中,时间明明具体到了时分秒,怎么不显示?」
  → Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、
    两头都没时分就不写 00:00)。

其他:
- App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。
  中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、
  离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。
- config.example.php 加 amap 段(key / security_js / nav_src)。

验证:
- e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)·
  城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)·
  桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。
- 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 /
  打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。
- 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。
2026-10-05 16:45:10 +08:00

1290 lines
62 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 旅程(v12,从 TripLog 迁入;导航条上叫「旅程」)。
*
* 与观影一样挂在**统一事件原语**上:
* events(kind='trip') —— 骨架:标题 / 起止时刻 / 地点(目的地)/ 备注
* trip_details —— 卫星表:封面图 + 一段话小结
* event_attendees —— 同行的人
* attachments(target=trip) —— 照片与附件
* trip_nodes —— 行程节点:这趟旅程里的一段一段(计划 / 实际走过的双轨)
*
* 为什么不把 TripLog 的 activities/trip_nodes 原样搬过来:
* TripLog 的 activities 与家书的 events 是同一个东西(一次出行 = 一个有时间、有人、
* 要上日历的事件)。再建一张 activities 就会出现"两条时间线",日历要同时读两张表,
* 迁移后还得保证两边同步 —— 那正是统一事件原语要消灭的重复。
*
* 计划/执行双轨(TripLog 最有价值的一点)保留了:
* 出发前排的行程是 plan,真正走完记的是 reality,两轨并存,
* 于是"我们本来打算去哪、后来实际上去了哪"都能回看。
*/
declare(strict_types=1);
namespace App;
final class Trip
{
/** 节点类型(TripLog 的 node_types 简化成固定几种 —— 家庭出游用不到那么多) */
public const NODE_TYPES = [
'play' => ['游玩', '🎡'],
'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);
}
/**
* 给**一批**活动补上派生时间与城市(列表页用;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();
$row = [
self::keyOrNull($d['cover_key'] ?? ''),
self::textOrNull($d['summary'] ?? ''),
// 用户第 4 条:是否公开。
// ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」:
// · 没传(程序化创建 / 迁移 / 老调用)→ **按公开(1)**,
// 与建表默认值一致;默认 0 会让数据**静默消失**在列表里,
// 那是最坏的一种失败(用户只会看到"我的行程不见了")。
// · 传了 0(表单里取消勾选)→ 真的不公开。
array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1,
];
$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=?, 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]);
}
}
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);
// 每段花了多少(用户第 2 条:总花销 = 各段之和;这里是各段的那个"段值")
\App\Expense::attachSums($rows);
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;
}
/**
* 保存一段行程(新增 / 编辑)。
*
* 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'] ?? ''),
// ⚠️ 第 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 迁来的老数据抹掉。这两列从此只读不写。
$colsKeep = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
carrier=?, trip_no=?, from_place=?, to_place=?,
from_city=?, to_city=?, from_addr=?, to_addr=?,
from_lng=?, from_lat=?, to_lng=?, to_lat=?';
$keep = $fields;
// 去掉 seat_info / gate 两项(它们在 $fields 里的下标是 15、16)
array_splice($keep, 15, 2);
$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;
}
}