Files
FamilyBook/app/Trip.php
T
laoyang 8d79857f1a v54:首页任务标题截断(真根因=两列网格被撑破)/ 名册分组→标签且候选读词表 / 旅程地图视图 / 旅程设置按钮 / 显示方式默认列表或地图
用户 2026-10-16 傍晚五条。

① 首页「近期任务」标题太长会挤成两行
   用户原话:「首页**近期任务**那个地方要注意截断:如果任务标题太长,
   会把这个任务**挤成两行**。」
   🔴🔴 **真根因是两层,第一版只修了表面**:
     · 表面:`.dash-what` 只有 `flex:1 1 auto; min-width:0`,**缺** nowrap/hidden/ellipsis
       ⇒ 收缩后文字在框内换行;
     · 真身:`.home-duo` 是**两列 grid**,grid 子项默认 `min-width: auto`
       ("我最小也得装下我的内容")⇒ 一条长标题把那一列顶到 **1763px**
       (父 `home-duo` 才 1032px)⇒ **整个两列布局被撑破**、页面横向溢出到 **2089px**
       (视口 1440)。⇒ 行宽 1721px、`scrollWidth === clientWidth`,
       **`text-overflow: ellipsis` 看起来"没生效"** —— 其实容器自己先爆了。
   修法:`.home-duo > * { min-width: 0; }`,且 **整条 flex 链**都补
     (`.hd-pane` → `.hd-pane-head` → `.hd-pane-body` → `.dash-list/.tk-list` → `.tk-row`)
     —— 链上任何一层漏了,收缩就断在那一层、下面的 nowrap 全白写。
   配套:任务标题加 `title=` 悬停看全。
   ⚠️ 这是本项目**第二次**踩 flex/grid 的 `min-width:auto`(v53 的 `.mv-grid` 是同类)。
      **以后"文字截断不生效"先量容器有没有被撑破**。

② 名册「分组」→「标签」;打标签的候选要**从设置里读**
   用户原话:「现在名册里面给人打标签的时候,**这个标签数据是从哪读出来的**呀?
   在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候,
   **却不是从这些分组里读出来的**。……应该叫「名册标签」,是不是?
   **应该从这里面把所有标签都读出来**吧?……以后就没有分组的概念了,
   **全部都是标签的概念**。」
   🔴 **用户观察得完全正确,这是两套数据源**:
     · 设置页显示的词表来自 `person_groups`(**分组表**);
     · 打标签的候选来自 `PersonTag::cloud()`(**你已经打过的词**,现算的);
     ⇒ 两边根本不是一个数据源 ⇒ "设置里列着一堆、打标签时却只有几个"。
   修法:**`PersonTag::choices()` = 设置页词表 ∪ 我用过的词**(并集,词表在前)。
     · 词表里的词**还没用过**也能一点就打上(这正是词表的意义);
     · 用过的词补在后面(历史不丢);
     · 名册列表 / 新增 / 编辑 / 挑人 / 导入 **五处同一个函数**。
   界面口径全面改"标签"(**表名仍是 `person_groups`**:改名要背着 `PersonGroup` 类、
   `person_roster.group_id`、以及**另一个会话正在写的 `app/ApiHandler.php`**
   (它引用了 `PersonGroup::orderFor`)一起动,风险远大于收益):
     · `_person_groups.php` 标题「名册标签」、按钮「加一个我自己的标签」;
     · 删除文案改成"只是从候选里去掉这个词,已打在别人身上的不受影响"
       (**语义真的变了**:标签是各自身上的字符串,不是引用);
     · `people/_settings_menu.php`、`people/settings.php`、`settings/me.php`、
       `settings.php`(「公共分组」→「公共标签」)全部同步。
   顺带按"全部都是标签"改另两条入口:
     · `people/pick.php`「加进哪一组」下拉 → **打标签**;
     · `people/import.php` 同上;`ContactImporter::run()` 加 `$tags` 参数。
   🔴 新增 `PersonTag::addOne()`(**追加**一个标签)—— 这两处**绝不能用 `setFor()`**
      (它是"先 DELETE 再 INSERT"的整组替换)⇒ 会把人**原有**的标签清光
      (症状是"用一次导入,别人原有的标签全没了",极难联想)。
   ⚠️ `group_id` 在 pick/import 的**服务端仍收着**(老书签/脚本兼容),只是界面不再展示。

③ 旅程模块加**地图视图**(读 TripLog 移植)
   用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,**可以在地图上显示旅程数据**。
   1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」
   读 TripLog(`templates/activities/index.php`)得到的要点:
     · 用**高德 AMap**(合规白名单内,绝不是境外瓦片);
     · `position` 必须是 **[lng,lat] 数组**(传 `{lng,lat}` 裸对象会让 AMap 抛错、
       整个绘制循环当场中断 —— 它注释里写着"批 11 的光秃秃地图就是它");
     · `setFitView()` 后**上限 11 级**(只有一趟时会被放大到街道级,那是导航视角);
     · `.amap-marker` 整层 `pointer-events:none`(否则包装层挡住别家卡片的点击);
     · **容器必须有固定高度**(0 高度 → 空白地图)。
     它那套"同城环形扇开"是**几百行几何**,本仓数据量没压出这个问题 ⇒ **有意不做**。
   本仓数据更规整:`trip_nodes` 早有 `from_lng/from_lat/to_lng/to_lat`(v17 批)
   + `from_city/to_city` ⇒ 新增 `Trip::attachSpots()`:
     ① 有坐标取**重心**(不是端点 —— 端点会让"青岛→伊春"偏到某一头);
     ② 没坐标退到**城市重心**(`Geo::lookup` 离线城市表,**不发网络请求**:
        列表页几十趟若逐条打高德逆地理,一次开页就是几十个外部请求);
     ③ 都没有就不上图(列表里照常有,模板会说清"有几趟没上图")。
     🔴 城市那里**必须也取重心**:`cities()` 的顺序是**按时间**(出发地在最前),
        取"第一个城市"会让**所有旅程都叠在家上**、地图毫无信息量。
   布局:两个视图用**标签页**(`?view=map` 是**链接**不是纯 JS 按钮 ——
     书签/前进后退/无 JS 都正确;也**有意不**把地图藏在 `display:none` 里,
     高德在 0 尺寸容器里会渲染空白)。
   高德 SDK 加载器重构:原来 `fbLoadAddr()` 顺带拉 `addr_suggest.js`,
   而地图只用 SDK 自带的 `AMap.Map/Marker` ⇒ 抽出 `window.fbAmapSdk()`(只拉 SDK)、
   新增 `fb-amap` meta(不带 plugin),`fb-addr` 那份共用同一加载器。

④ 旅程列表页「新建旅程」**后面**加「设置」按钮
   用户原话:「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。」
   ⇒ 补 `_settings_menu.php`(设置恒排最右),菜单**第一条**加「显示方式」。

⑤ 显示方式:默认列表还是地图(用户自配)
   用户原话:「3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」
   ⇒ 新增 `trips/display`(设置页 + `?set=` 快速设置)与 `trips/display_save`,
     存 **`user_settings.trip_default_view`**(每人一份,**不是**全家设置)。
   ⚠️ 访客也能改(只影响自己看到的默认,不是数据变更)。

验证(新增 2 套件 + 更新 e2e/deploy 断言):
 · _fb_v54_ui.py(**33 条**真机断言)
 · _b54_falsify.py(**11 注入 / 11 全抓**,基线 33/0)
 · e2e **1518/0**;familybook_deploy.sh 加「第54批形态」段(**24 条**)
 · 部署 **0 FAIL**;线上 **20 个文件 md5 逐字节一致**;
   线上实测:词表 24 条、`choices()` 24 条、**`cloud` 15 条全部 ⊆ `choices`**、
   4 趟旅程全部可上图(样例落在济南·青岛重心);诊断页已删复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md):
 · 🔴🔴 **"文字截断不生效"要先量容器有没有被撑破**(第一嫌疑是 flex/grid 子项
   `min-width:auto`,不是 `text-overflow` 写错);
 · **反向断言("不该再出现 X")必须用 `_code_has`(剥注释)** ——
   需求原话正当留在注释里,裸 `grep -F` 会命中自己的说明(本轮部署假红一次);
 · **冗余防线会让反向验证打不红**(两处 `min-width:0` 只去掉一条 ⇒ 探针照旧全绿,
   注入必须一起去掉);
 · **界面多加一个字符就可能打歪既有断言**(我在词表管理页给标签名加了 `#`,
   把 e2e 的 `my_group_order()` 读成 `#家人`)。
2026-10-17 00:15:06 +08:00

1483 lines
73 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);
}
/**
* 🔴 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();
$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]);
}
// ---------------- 行程节点 ----------------
/** 这趟旅程的全部节点(计划 / 实际 / 未成行,按**开始时间正序**排) */
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;
}
}