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
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* 行程花销(v24 第 16 批 用户第 2 / 7 条)。
*
* 用户第 2 条要旅程页显示"一共花了多少钱",第 7 条要求照 TripLog 的节点功能,
* TripLog 的花销就是**挂在节点上**的一笔一笔。本次确认:「行程级花销记录」。
*
* 设计取舍:
* · 一行 = 一段行程里的一笔花销(谁付的 / 多少 / 什么类别 / 说明);
* · **旅程的总花销 = 它下面所有行程的花销之和** —— 读时算,不存冗余字段。
* 存了就要在每次增删改花销后同步维护,"漏同步"是迟早的事(这个项目里
* 同一类错误已经犯过好几次:参与人、时间、城市都是这么改成派生值的)。
* · **不做分摊**(TripLog 有 expense_splits):家庭出行记"一共花了多少"就够,
* 谁欠谁那是财务模块的事,塞进旅行里会让记账变成负担。
* · 金额存**分**(与财务 ledger_entries 同一口径):浮点数做加法迟早差一分钱。
*/
declare(strict_types=1);
namespace App;
final class Expense
{
/** 花销类别(家庭出游够用;TripLog 的类别也差不多是这几种) */
public const CATS = [
'meal' => '餐饮',
'ticket' => '门票',
'traffic' => '交通',
'stay' => '住宿',
'shop' => '购物',
'other' => '其他',
];
/** 一段行程的花销(按时间正序) */
public static function ofNode(int $nodeId): array
{
$st = Database::get()->prepare(
'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color
FROM trip_expenses e
LEFT JOIN persons p ON p.id = e.person_id
WHERE e.node_id = ? ORDER BY e.created_at, e.id'
);
$st->execute([$nodeId]);
return $st->fetchAll();
}
/**
* 一段行程的花销合计(单位:分)。
*
* ⚠️ COALESCE 不能省:没有任何花销时 SUM 返回 NULL,直接拿去 number_format
* 会在 PHP 8 上报"传 null 给 float 参数"的弃用告警(页面上会冒一行 Warning)。
*/
public static function sumOfNode(int $nodeId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(SUM(amount_cents), 0) FROM trip_expenses WHERE node_id = ?'
);
$st->execute([$nodeId]);
return (int)$st->fetchColumn();
}
/** 一段旅程(活动)的总花销:它下面**所有**行程之和(用户第 2 条) */
public static function sumOfEvent(int $eventId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(SUM(e.amount_cents), 0)
FROM trip_expenses e
JOIN trip_nodes n ON n.id = e.node_id
WHERE n.event_id = ?'
);
$st->execute([$eventId]);
return (int)$st->fetchColumn();
}
/**
* 给一批行程批量补"这一段花了多少"(列表/详情用;N+1 在几十段时很明显)。
*
* @param array $rows 行程数组(会被就地补上 `spent_cents`)
*/
public static function attachSums(array &$rows): void
{
$ids = [];
foreach ($rows as $r) {
$id = (int)($r['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
$sum = [];
foreach ($ids as $id) {
$sum[$id] = 0;
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT node_id, COALESCE(SUM(amount_cents),0) AS s
FROM trip_expenses WHERE node_id IN ($in) GROUP BY node_id"
);
$st->execute($ids);
foreach ($st->fetchAll() as $r) {
$sum[(int)$r['node_id']] = (int)$r['s'];
}
}
foreach ($rows as &$r) {
$r['spent_cents'] = $sum[(int)$r['id']] ?? 0;
}
unset($r);
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function add(int $familyId, int $nodeId, array $d): array
{
$cents = self::parseAmount((string)($d['amount'] ?? ''));
if ($cents === null) {
return ['ok' => false, 'error' => '金额填得不对(例如 128 或 128.50)'];
}
$title = mb_substr(trim((string)($d['title'] ?? '')), 0, 200, 'UTF-8');
$cat = isset(self::CATS[(string)($d['category'] ?? '')]) ? (string)$d['category'] : 'other';
$pid = (int)($d['person_id'] ?? 0);
if ($pid > 0) {
// 只认本家的人(表单可以改,不能凭它把别人家的人在页面上引出来)
$st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?');
$st->execute([$pid, $familyId]);
if ($st->fetchColumn() === false) {
$pid = 0;
}
}
$db = Database::get();
$db->prepare(
'INSERT INTO trip_expenses (family_id, node_id, person_id, amount_cents, category, title)
VALUES (?,?,?,?,?,?)'
)->execute([$familyId, $nodeId, $pid > 0 ? $pid : null, $cents, $cat,
$title !== '' ? $title : null]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/** 删一笔(校验它确实属于这个家的某段行程) */
public static function destroy(int $familyId, int $id): bool
{
$st = Database::get()->prepare('SELECT id FROM trip_expenses WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
if ($st->fetchColumn() === false) {
return false;
}
Database::get()->prepare('DELETE FROM trip_expenses WHERE id = ?')->execute([$id]);
return true;
}
// ---------------- 展示 ----------------
/** 分 → 人话(¥1,280 / ¥12.50;金额为零显示 ¥0) */
public static function money(int $cents): string
{
$neg = $cents < 0;
$v = abs($cents) / 100;
// 整数金额不带小数尾巴(¥1280 比 ¥1,280.00 好读);有角分才显示两位
$s = (fmod($v, 1.0) === 0.0) ? number_format($v) : number_format($v, 2);
return ($neg ? '-¥' : '¥') . $s;
}
/**
* 表单里那个金额字符串 → 分。
*
* ⚠️ 不用 `(float)` 直接乘 100:`128.55 * 100` 在浮点下是 12854.999…,
* `(int)` 一截就变成 12854(差一分钱)。这里走**字符串**拆小数点,
* 整数部分与小数部分分开算,没有浮点参与。
*/
public static function parseAmount(string $raw): ?int
{
$raw = trim(str_replace([',', ',', '¥', '¥', ' '], '', $raw));
if ($raw === '') {
return null;
}
if (!preg_match('/^(-?)(\d+)(?:\.(\d{1,2}))?$/', $raw, $m)) {
return null;
}
$yuan = (int)$m[2];
$cent = isset($m[3]) ? (int)str_pad($m[3], 2, '0') : 0;
$total = $yuan * 100 + $cent;
return $m[1] === '-' ? -$total : $total;
}
}