Files
FamilyBook/app/Trip.php
T
laoyang 2e51d3f5e4 v24 第 16 批:旅程 / 行程两级(术语固定 · 题图16:9 · 五项统计 · 独立行程页 · 设置三页)
用户十条,逐条落地:

1. 「题图应该按照 16:9 显示,因为添加的时候是按这个比例裁剪的,现在的显示比例不对。」
   → 详情页题图容器从 4:3 改成 16:9,与 ImageSlot 的 cover 槽位同一个数字
     (部署断言比对这两处,避免哪天改一处漏一处)。

2. 「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、一共几段行程,
     一共花了多少钱,这些信息,都从该活动下的所有行程统计总结,简介那一段话保留。」
   → 新增 Trip::summary():参与人(各段并集)/ 城市(去重)/ 天数 / 段数 / 总花销,
     全部读时算(与"时间城市派生"同一条原则,不存冗余)。
     天数按**日历天**含首尾(8/1→8/3 = 3 天,不是 24 小时整除的 2 天)。
     城市与时间从标题旁挪进信息区,简介保留。

3. 「活动页面,不需要上传图片,图片视频,都是针对一次具体的行程的。」
   → 删掉旅程页的照片上传板块与照片墙;照片一律挂在**行程**上
     (行程表单里有上传,附件 target_type='trip_node')。

4. 「计划行程、未成行的行程,都不要显示……只要是没有就是 0 条,那个类型就不显示了,
     现在显示的是 0 条,这样没意义。」
   → 空分组直接跳过不渲染;全空时给一句引导(否则新建旅程页面一片空白)。
     有内容的分组照常显示(计划 / 实际 / 未成行三种)。

5. 「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
   → 行程的新增与编辑改成**独立页面**(trips/node_new / trips/node_edit)。
     原来内联在详情页:N 份表单互相撞元素 id、密码管理器逐个扫描变卡,
     正是"把页面撑破"的成因。

6. 「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
   → 详情页右上角两个按钮(+ 计划行程 / + 实际行程),指向独立新增页。
     顺带:标题旁边不再放时间(时间移到题图右侧信息区)。

7. 「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
   → 字段全量照搬 TripLog 的 nodes/form.php:
     类型 / 标题 / 起止时间 / 出行方式 + 车辆 / 线路名 / 承运方 / 车次航班号 /
     座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(含角色)/ 照片 / 花销。
     交通字段不做条件列(先填高铁车次、后来改成自驾,填过的东西不该丢)。

8. 「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
     注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加那里面的车辆,
     直接读取就可以,但是可以添加其他的车辆,比如租的车,借别人的车。」
   → 新增旅程设置菜单(三页):车辆管理 / 出行方式 / 节点类型。
     车辆**两个来源刻意不合并**:财务里 kind='car' 的账目(那是资产,没有车牌车型)
     由 Vehicle::fromLedger() 只读列出,选车下拉里标「来自财务」;
     自家 / 租的 / 借的车录进 vehicles 表。不复制财务的车(避免同一台车两处维护)。
     节点类型做成**可自定义**的表(TripLog 同款),内置 5 种不可删;
     行程里存类型**名字**,所以改类型名会级联更新已用它的行程。

9. 「活动都改名吧,改成旅程,新建旅程,旅程包含行程,这个名字就固定下来。」
   → 术语固定:**旅程**(容器)包含**行程**(单元)。
     顶层按钮「新建旅程」、详情页三组叫「计划的行程 / 实际行程 / 未成行」、
     列表「N 段行程」、设置菜单「旅程设置」。上一批的"活动"叫法全部换掉。

10. 「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,这些都是从行程里面
      总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
   → 旅程表单只剩:标题 → 题图 → 简介 + 公开开关。
     备注与同行的人全部移除(参与人 = 各段行程参与人的合集)。
     ⚠️ 但**老数据里活动级那份参与人名单不能被清空**(那时还没有行程级参与人),
     所以路由"没传这个键就不动",并加了断言钉住这一点。

数据结构(Schema v21,老库自动升级):
· 新表 vehicles(自家/租的/借的)、node_types(可自定义)、trip_expenses(行程级花销)
· trip_nodes 补 9 列:出行方式 / 车辆 / 线路名 / 承运方 / 车次 / 座位 / 登机口 / 起终点
· trip_node_members 补 role_tag(司机 / 摄影担当这类角色)
· 花销金额存**分**,解析走字符串拆小数点(128.55*100 在浮点下会差一分)

验证:e2e 891/0(+46 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 交互 92/0 ·
窄屏溢出 30/0(扩到行程表单 + 设置三页 + 详情页)· 题图点击 4/0 · 静态体检全绿。
**反向验证 6/6**:题图退回 4:3、标题旁加回时间、空分组照旧渲染、编辑退回非独立页、
车辆不读财务、总花销不汇总 —— 六种坏法全部被断言抓住。
部署静态断言新增第 16 批一组(含"空分组不许渲染""行程页不许有照片上传"
"旅程表单不许有备注与同行的人""题图必须 16:9"等反退化防线)。
部署后服务器自检 19/19,六个关键文件 md5 与线上逐一核对一致。
2026-10-05 13:26:49 +08:00

1098 lines
50 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' => ['其他', '🧭'],
];
/** 自驾类(这两种才需要选车辆) */
public const DRIVE_MODES = ['drive', 'taxi'];
/**
* 行程的**状态机**(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),
];
}
/**
* 一条活动去过的城市(各段旅程 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(n.starts_at), n.place` 就没问题(聚合函数本身可用)。
$st = Database::get()->prepare(
"SELECT n.place, MIN(n.starts_at) AS first_at
FROM trip_nodes n
WHERE n.event_id = ? AND n.stage IN ('plan','actual')
AND n.place IS NOT NULL AND n.place <> ''
GROUP BY n.place
ORDER BY MIN(n.starts_at), n.place"
);
$st->execute([$eventId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$p = trim((string)$r['place']);
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():ORDER BY 只写聚合函数,不写别名
$st2 = Database::get()->prepare(
"SELECT event_id, place, MIN(starts_at) AS first_at
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
AND place IS NOT NULL AND place <> ''
GROUP BY event_id, place
ORDER BY event_id, MIN(starts_at), place"
);
$st2->execute($ids);
foreach ($st2->fetchAll() as $r) {
$id = (int)$r['event_id'];
$p = mb_substr(trim((string)$r['place']), 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;
}
$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'] ?? ''),
self::textOrNull($d['seat_info'] ?? ''),
self::textOrNull($d['gate'] ?? ''),
self::textOrNull2($d['from_place'] ?? ''),
self::textOrNull2($d['to_place'] ?? ''),
];
$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=?';
if ($id !== null && $id > 0) {
$db->prepare('UPDATE trip_nodes SET ' . $cols . ' 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, stage, travel_mode, vehicle_id, line_name,
carrier, trip_no, seat_info, gate, from_place, to_place)
VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')'
)->execute([$familyId, $eventId, ...$fields]);
$nid = (int)$db->lastInsertId();
}
self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []);
return $nid;
}
/**
* 行程类型:存**名字**(可自定义的类型表里那个 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();
$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 ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
}
$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);
}
/**
* 行程类型的**名字**。
*
* ⚠️ 两种取值来源,顺序不能反:
* ① 用户在设置里定义的类型表(`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;
}
}