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:
+219
-17
@@ -41,6 +41,32 @@ final class Trip
|
||||
'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 —— 本次旅程重构的核心)。
|
||||
*
|
||||
@@ -452,7 +478,15 @@ final class Trip
|
||||
]);
|
||||
$id = (int)$db->lastInsertId();
|
||||
self::saveDetails($id, $d);
|
||||
self::saveAttendees($id, $d['attendees'] ?? []);
|
||||
// ⚠️ 只有**真的传了** attendees 才动这份名单。
|
||||
// v24 第 16 批 用户第 10 条之后,旅程表单里已经没有"同行的人"字段了 ——
|
||||
// 如果这里写成 `$d['attendees'] ?? []`,每次编辑旅程都会把
|
||||
// **活动级**的老名单整个清空(老数据里很多人的参与人只登记在这一层,
|
||||
// 那时还没有行程级参与人)→ 用户看到的是"我一编辑,参与人全没了"。
|
||||
// 新数据的参与人从行程里长出来(tripPeople 的合集口径),不受影响。
|
||||
if (array_key_exists('attendees', $d)) {
|
||||
self::saveAttendees($id, (array)$d['attendees']);
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
@@ -471,7 +505,10 @@ final class Trip
|
||||
$id, $familyId,
|
||||
]);
|
||||
self::saveDetails($id, $d);
|
||||
self::saveAttendees($id, $d['attendees'] ?? []);
|
||||
// ⚠️ 同 create:没传就不动(见上面那段说明)
|
||||
if (array_key_exists('attendees', $d)) {
|
||||
self::saveAttendees($id, (array)$d['attendees']);
|
||||
}
|
||||
}
|
||||
|
||||
private static function saveDetails(int $eventId, array $d): void
|
||||
@@ -561,6 +598,8 @@ final class Trip
|
||||
$n['attachs'] = $attach[(int)$n['id']] ?? [];
|
||||
}
|
||||
unset($n);
|
||||
// 每段花了多少(用户第 2 条:总花销 = 各段之和;这里是各段的那个"段值")
|
||||
\App\Expense::attachSums($rows);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
@@ -572,6 +611,21 @@ final class Trip
|
||||
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();
|
||||
@@ -582,35 +636,79 @@ final class Trip
|
||||
// ⚠️ 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'] ?? ''),
|
||||
in_array($d['node_type'] ?? '', array_keys(self::NODE_TYPES), true) ? $d['node_type'] : 'other',
|
||||
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 title=?, detail=?, place=?, starts_at=?, ends_at=?,
|
||||
node_type=?, status=?, category=?, position=?, stage=? WHERE id=?'
|
||||
)->execute([...$fields, $id]);
|
||||
$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) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||
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'] ?? []);
|
||||
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 批) ----------------
|
||||
//
|
||||
// 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作」
|
||||
@@ -780,7 +878,8 @@ final class Trip
|
||||
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
|
||||
'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'
|
||||
@@ -789,17 +888,79 @@ final class Trip
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
public static function saveNodeMembers(int $nodeId, array $personIds): void
|
||||
/**
|
||||
* 存一段行程的参与人(整组替换)。
|
||||
*
|
||||
* @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) VALUES (?,?)');
|
||||
$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) {
|
||||
$ins->execute([$nodeId, $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)),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------- 展示 ----------------
|
||||
@@ -844,16 +1005,57 @@ final class Trip
|
||||
return $s . ' – ' . date('Y年n月j日', $te);
|
||||
}
|
||||
|
||||
public static function typeWord(string $key): string
|
||||
/**
|
||||
* 行程类型的**名字**。
|
||||
*
|
||||
* ⚠️ 两种取值来源,顺序不能反:
|
||||
* ① 用户在设置里定义的类型表(`NodeType::all()`,按名字存进 trip_nodes)——
|
||||
* 这是"用户改过的真相",优先级最高;
|
||||
* ② 老数据的英文 key(play/move/…)→ 内置常量表。
|
||||
* 少了 ① 就会把用户自定义的类型名显示成"其他"(看起来像类型丢了)。
|
||||
*
|
||||
* @param int $familyId 传 0 表示"只按内置表解析"(没有家庭上下文时用)
|
||||
*/
|
||||
public static function typeWord(string $key, int $familyId = 0): string
|
||||
{
|
||||
return self::NODE_TYPES[$key][0] ?? '其他';
|
||||
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): string
|
||||
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] ?? '计划中';
|
||||
|
||||
Reference in New Issue
Block a user