用户十条,逐条落地:
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 与线上逐一核对一致。
1098 lines
50 KiB
PHP
1098 lines
50 KiB
PHP
<?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' => ['其他', '🧭'],
|
||
];
|
||
|
||
/** 自驾类(这两种才需要选车辆) */
|
||
public const DRIVE_MODES = ['drive', 'taxi'];
|
||
|
||
/**
|
||
* 行程的**状态机**(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),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 一条活动去过的城市(各段旅程 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]);
|
||
$out = [];
|
||
foreach ($st->fetchAll() as $r) {
|
||
$p = trim((string)$r['place']);
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 给**一批**活动补上派生时间与城市(列表页用;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():ORDER BY 只写聚合函数,不写别名
|
||
$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"
|
||
);
|
||
$st2->execute($ids);
|
||
foreach ($st2->fetchAll() as $r) {
|
||
$id = (int)$r['event_id'];
|
||
$p = mb_substr(trim((string)$r['place']), 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]);
|
||
}
|
||
|
||
// ---------------- 行程节点 ----------------
|
||
|
||
/** 这趟旅程的全部节点(plan / reality 两轨,按 position 排) */
|
||
public static function nodes(int $eventId, int $viewerId = 0): array
|
||
{
|
||
$st = Database::get()->prepare(
|
||
'SELECT * FROM trip_nodes WHERE event_id = ? ORDER BY category, position, starts_at, id'
|
||
);
|
||
$st->execute([$eventId]);
|
||
$rows = $st->fetchAll();
|
||
$ids = array_map('intval', array_column($rows, 'id'));
|
||
// 一次性把全部节点的附件取出来(逐节点查会让"十几段的旅程"变成几十次查询)
|
||
$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;
|
||
}
|
||
|
||
public static function node(int $id): ?array
|
||
{
|
||
$st = Database::get()->prepare('SELECT * FROM trip_nodes WHERE id = ?');
|
||
$st->execute([$id]);
|
||
$r = $st->fetch();
|
||
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();
|
||
// 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;
|
||
}
|
||
$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'] ?? ''),
|
||
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 ' . $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, 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'] ?? [], $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 批) ----------------
|
||
//
|
||
// 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作」
|
||
// · 标记未成行(用户原话叫"删除",但明确"不要真的删除,只是做一个标记")
|
||
// · 修正(记录实际执行情况,与计划是**同一条记录**,可无限次)
|
||
// · 完成(实际与计划完全相同,直接变实际行程)
|
||
|
||
/**
|
||
* 「修正」:记下一次实际执行情况,**行程本身仍是计划**(用户确认的语义:
|
||
* 修正后要再点一次「标记为实际」才正式转为实际行程)。
|
||
*
|
||
* 🔴 关键:这是**同一条记录**,不是新建第二条。所以做法是
|
||
* ① 把这次填的实际值**快照**进 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();
|
||
$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 ($personIds as $pid) {
|
||
$pid = (int)$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)),
|
||
];
|
||
}
|
||
|
||
// ---------------- 展示 ----------------
|
||
|
||
/** 起止日期的人话展示(与 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);
|
||
}
|
||
|
||
/**
|
||
* 行程类型的**名字**。
|
||
*
|
||
* ⚠️ 两种取值来源,顺序不能反:
|
||
* ① 用户在设置里定义的类型表(`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;
|
||
}
|
||
}
|