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 与源库对齐。
This commit is contained in:
2026-10-05 16:45:10 +08:00
parent 2e51d3f5e4
commit c30ca99d22
13 changed files with 1158 additions and 235 deletions
+233 -41
View File
@@ -64,8 +64,32 @@ final class Trip
'other' => ['其他', '🧭'],
];
/** 自驾类(这两种才需要选车辆) */
public const DRIVE_MODES = ['drive', 'taxi'];
/**
* 自驾类(**只有自驾**才需要选车辆)。
*
* ⚠️ 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 —— 本次旅程重构的核心)。
@@ -155,31 +179,54 @@ final class Trip
}
/**
* 一条活动去过的城市(各段旅程 place 去重,按**最早出现的时间**排)。
* 一条旅程去过的**城市**(用户第 17 批 第 1/4 条)。
*
* ⚠️ 顺序按时间排而不是按字母/插入顺序:
* "青岛 · 威海" 让人一眼看出先后,排成字母序就完全没有这层信息了。
* ⚠️ skipped(未成行)的**不算去过** —— 计划去但没去成的地方不该出现在
* "去过哪几个城市"里(这与"不上日程、不进往年今日"是同一条口径)。
* 用户原话:
* · 第 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(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]);
// `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['place']);
$p = trim((string)$r['city']);
if ($p !== '' && !in_array($p, $out, true)) {
$out[] = mb_substr($p, 0, 60, 'UTF-8');
}
@@ -235,19 +282,29 @@ final class Trip
'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')),
];
}
// ⚠️ 同 cities():ORDER BY 只写聚合函数,不写别名
// ⚠️ 同 cities():只认 from_city / to_city(第 17 批第 1 条的城市聚合),
// ORDER BY 只写聚合函数、不写别名。原来读的是 place —— 那是**地点名**
// (「溪水国家森林公园」),拿它当城市正是这批要改掉的。
$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"
"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($ids);
$st2->execute(array_merge($ids, $ids));
foreach ($st2->fetchAll() as $r) {
$id = (int)$r['event_id'];
$p = mb_substr(trim((string)$r['place']), 0, 60, 'UTF-8');
$p = mb_substr(trim((string)$r['city']), 0, 60, 'UTF-8');
if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) {
$city[$id][] = $p;
}
@@ -641,6 +698,31 @@ final class Trip
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'] ?? ''),
@@ -658,31 +740,70 @@ final class Trip
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($d['from_place'] ?? ''),
self::textOrNull2($d['to_place'] ?? ''),
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=?';
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) {
$db->prepare('UPDATE trip_nodes SET ' . $cols . ' WHERE id=?')
->execute([...$fields, $id]);
// ⚠️ `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)
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();
}
self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []);
// ⚠️ 第 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'。
*
@@ -897,14 +1018,29 @@ final class Trip
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 (?,?,?)');
// ⚠️ v24 第 17 批:**不再整表 DELETE 再插**,改成"差集增删"。
// 原因:用户第 3 条把角色标注功能去掉了(表单不再提交 roles),
// 如果这里照旧先删光再插,`role_tag` 就会被写成 null ——
// TripLog 迁来的角色数据(虽然界面不再显示)会被**静默抹掉**。
// 现在只做两件事:删掉"本次没选的人",插入"新选的人";
// 已存在且仍被选中的,**原样不动**(role_tag 保住)。
$keep = [];
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
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]);
}
@@ -1005,6 +1141,62 @@ final class Trip
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 : '');
}
/**
* 行程类型的**名字**。
*