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 与线上逐一核对一致。
This commit is contained in:
2026-10-05 13:26:49 +08:00
parent 7138314dc7
commit 2e51d3f5e4
16 changed files with 2265 additions and 456 deletions
+126 -1
View File
@@ -80,7 +80,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '20';
public const VERSION = '21';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -730,6 +730,92 @@ CREATE TABLE IF NOT EXISTS `book_notes` (
CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读书笔记 / 划线 / 书评(按人记)'
SQL,
// ---- 车辆库(版本 21,用户 2026-10-05 第 16 批第 8 条)----
// 用户原话:「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
// 注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复
// 添加那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
// 比如租的车,借别人的车。」
//
// ⚠️ 车辆有**两个来源**,这里刻意**不合并**:
// ① **财务里的车子**(ledger_entries.kind='car' 的账目)—— 那是"资产",
// 记的是这台车值多少钱,不是"出行时可挑的一辆车"(没有车牌、没有车型名)。
// 这里**读它**、在选车时列出来(只读引用),但绝不复制一份到本表 ——
// 复制就变成同一台车两处维护,改了财务这边不同步。
// ② **本表** —— "出行时会用到的车",重点是车牌 + 车型名 + 是租的还是借的。
// 自家车在这里也**可以**录一份(干净、可挑),但不强制:
// 不录也能出行(只是选车时没得挑而已)。
// 所以"租的车 / 借别人的车"是本表的常客,`vehicle_type` 才需要 rental/borrowed
// 这两种;自家车与财务的对齐靠**车型名 + 车牌**人工对上,不做外键硬绑
// (账目随时可能被删/改名,硬绑会让车辆跟着消失)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `vehicles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`plate` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '车牌号(借的车/租的车可能没记全,允许空)',
`name` VARCHAR(100) NOT NULL COMMENT '车型名,如「大众朗逸」「丰田卡罗拉」',
`vtype` ENUM('own','rental','borrowed') NOT NULL DEFAULT 'own'
COMMENT 'own=自家的 / rental=租的 / borrowed=借别人的',
`vendor` VARCHAR(100) DEFAULT NULL COMMENT '租车公司(vtype=rental 时)或车主(borrowed 时)',
`order_no` VARCHAR(50) DEFAULT NULL COMMENT '租车订单号',
`ledger_id` INT UNSIGNED DEFAULT NULL COMMENT '关联财务里那条 kind=car 的账目(软关联,可空;账目删了这里只是记不住,不影响车辆)',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '默认车(新建行程选车时默认选中它)',
`note` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_veh_family` (`family_id`, `vtype`),
CONSTRAINT `fk_veh_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出行用的车辆(自家 / 租的 / 借的);财务里的车子是资产,不复制进来'
SQL,
// ---- 行程节点类型(版本 21,第 16 批第 8 条 + TripLog 同款)----
// 用户第 7 条要求「照 TripLog 的添加节点功能」,TripLog 的节点类型
// 就是一张**可自定义**的表(游玩/用餐/赶路/休息/突发…),不是写死的枚举。
// ⚠️ 与旧代码里那个 `Trip::NODE_TYPES` 常量并存一段:常量当**兜底**
// (老库/新装库都要有初始值),表是"用户改过的真相"。
// 读取一律 `NodeType::all()`(表空时自动回落到常量并预置进表)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `node_types` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(30) NOT NULL COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 休息 / 突发…',
`icon` VARCHAR(20) DEFAULT NULL COMMENT '一个表情符号',
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '内置类型(预置的那几种),不允许删',
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_nt_family_name` (`family_id`, `name`),
CONSTRAINT `fk_nt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点类型库(可自定义)'
SQL,
// ---- 行程花销(版本 21,第 16 批第 2/7 条)----
// 用户第 2 条要"一共花了多少钱",第 7 条要求照 TripLog 的节点功能,
// 而 TripLog 的花销是**挂在节点上**的一笔一笔(谁付的 / 多少 / 什么类别)。
// ⚠️ 用户明确"行程级花销记录"(本次确认),所以:
// · 一行 = 一段行程里的一笔花销;
// · 旅程的总花销 = 它下面所有行程的花销之和(读时算,不存冗余)。
// ⚠️ 不做 `expense_splits`(分摊):家庭出行记"一共花了多少"就够,
// 谁欠谁那是财务模块的事,硬塞进旅行里会让记账变成负担。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_expenses` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`node_id` INT UNSIGNED NOT NULL COMMENT '属于哪一段行程',
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁付的钱(可空)',
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额,单位**分**(与财务同一口径,避免浮点误差)',
`currency` VARCHAR(10) NOT NULL DEFAULT 'CNY',
`category` VARCHAR(30) DEFAULT NULL COMMENT '餐饮 / 门票 / 交通 / 购物 / 其他',
`title` VARCHAR(200) DEFAULT NULL COMMENT '消费说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_te_node` (`node_id`),
KEY `idx_te_family` (`family_id`),
CONSTRAINT `fk_te_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)'
SQL,
];
}
@@ -981,6 +1067,45 @@ SQL,
}
});
// ---- v24 第 16 批:行程对齐 TripLog(Schema v21)----
// 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
// TripLog 的节点比 FamilyBook 现有的"行程"多这些:
// 出行方式(走路/开车/高铁/飞机…)+ 关联车辆、车次/航班号、座位/车厢、
// 登机口/站台、起终点位置(带坐标)、线路名/承运公司。
// 这些一律**补列**到 trip_nodes 上(不是另建卫星表):
// ① 它们与行程是 1:1(一段行程一种出行方式),卫星表只是多一次 JOIN;
// ② 旅行模块里"这一段怎么到的"是**必看信息**,不是可有可无的附加物。
self::addColumn($db, 'trip_nodes', 'travel_mode',
"ADD COLUMN `travel_mode` VARCHAR(20) DEFAULT NULL COMMENT '出行方式:walk/drive/bus/subway/train/plane/ship/taxi/bike/other' AFTER `place`");
self::addColumn($db, 'trip_nodes', 'vehicle_id',
"ADD COLUMN `vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT '关联车辆(仅自驾:自家/租的/借的)' AFTER `travel_mode`");
self::addColumn($db, 'trip_nodes', 'line_name',
"ADD COLUMN `line_name` VARCHAR(100) DEFAULT NULL COMMENT '线路名,如「京沪高铁」「地铁 3 号线」' AFTER `vehicle_id`");
self::addColumn($db, 'trip_nodes', 'carrier',
"ADD COLUMN `carrier` VARCHAR(100) DEFAULT NULL COMMENT '承运方,如「中国铁路」「滴滴」' AFTER `line_name`");
self::addColumn($db, 'trip_nodes', 'trip_no',
"ADD COLUMN `trip_no` VARCHAR(50) DEFAULT NULL COMMENT '车次 / 航班号 / 线路号' AFTER `carrier`");
self::addColumn($db, 'trip_nodes', 'seat_info',
"ADD COLUMN `seat_info` VARCHAR(100) DEFAULT NULL COMMENT '座位号 / 车厢' AFTER `trip_no`");
self::addColumn($db, 'trip_nodes', 'gate',
"ADD COLUMN `gate` VARCHAR(20) DEFAULT NULL COMMENT '登机口 / 站台' AFTER `seat_info`");
self::addColumn($db, 'trip_nodes', 'from_place',
"ADD COLUMN `from_place` VARCHAR(200) DEFAULT NULL COMMENT '出发地(赶路段的起点)' AFTER `gate`");
self::addColumn($db, 'trip_nodes', 'to_place',
"ADD COLUMN `to_place` VARCHAR(200) DEFAULT NULL COMMENT '到达地(赶路段的终点)' AFTER `from_place`");
// 车型/车次这类字段只在"赶路"时有意义,但**不做条件列**(列固定、界面按类型显隐),
// 免得"换个出行方式就把填过的车次弄丢"。
self::addColumn($db, 'trip_node_members', 'role_tag',
"ADD COLUMN `role_tag` VARCHAR(30) DEFAULT NULL COMMENT '角色标签,如「司机」「摄影担当」' AFTER `person_id`");
// 车辆库三张表在 statements() 里(IF NOT EXISTS 幂等),老库由那条统一建。
// 但**预置节点类型**要显式跑一次:老库建完表是空的,界面上会一个类型都没有
// ("类型"是新建行程的必填项 → 直接卡死,人以为功能坏了)。
self::tryStep('预置行程节点类型', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\NodeType::seedDefaults((int)$fam['id']);
}
});
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
self::addColumn($db, 'trip_nodes', 'stage',