2026-10-02 09:28:07 +08:00
|
|
|
|
<?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' => '已取消',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-04 20:57:40 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 行程的**状态机**(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-02 09:28:07 +08:00
|
|
|
|
/** 全部旅程(按出发时间倒序) */
|
|
|
|
|
|
public static function all(int $familyId, string $keyword = ''): array
|
|
|
|
|
|
{
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。
|
|
|
|
|
|
// 用 LEFT JOIN + COALESCE 而不是 INNER JOIN:
|
|
|
|
|
|
// 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程
|
|
|
|
|
|
// 从列表里消失("行程丢了"比"多显示一条"严重得多),
|
|
|
|
|
|
// COALESCE(...,1) 表示"没有这行就算公开"。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note,
|
2026-10-03 11:30:48 +08:00
|
|
|
|
d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
(SELECT COUNT(*) FROM event_attendees a WHERE a.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
|
2026-10-03 11:30:48 +08:00
|
|
|
|
WHERE e.family_id = ? AND e.kind = ? AND COALESCE(d.is_public, 1) = 1';
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$bind = [$familyId, 'trip'];
|
|
|
|
|
|
$kw = trim($keyword);
|
|
|
|
|
|
if ($kw !== '') {
|
|
|
|
|
|
$sql .= ' AND (e.title LIKE ? OR e.location LIKE ? OR e.note LIKE ?)';
|
|
|
|
|
|
$like = '%' . $kw . '%';
|
|
|
|
|
|
array_push($bind, $like, $like, $like);
|
|
|
|
|
|
}
|
|
|
|
|
|
$sql .= ' ORDER BY e.starts_at DESC, e.id DESC';
|
|
|
|
|
|
$st = Database::get()->prepare($sql);
|
|
|
|
|
|
$st->execute($bind);
|
|
|
|
|
|
return $st->fetchAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 给一批旅程补上「同行的人」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。
|
|
|
|
|
|
* 一次查询拿全部 event_attendees,再按 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), '?'));
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT a.event_id, 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 IN (' . $in . ')
|
|
|
|
|
|
ORDER BY a.position, p.id'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute(array_merge([$viewerId], $ids));
|
|
|
|
|
|
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(
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// ⚠️ 单条详情**不按 is_public 拦**:勾了"不公开"只代表不进列表/日历,
|
|
|
|
|
|
// 直接拿到链接的人(或行程成员)仍应该能打开它 —— 否则非遗漏一次就找不回来。
|
|
|
|
|
|
"SELECT e.*, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
$row['attendees'] = self::attendees($id, $viewerId);
|
|
|
|
|
|
$row['photos'] = \App\Attachment::list('trip', $id);
|
|
|
|
|
|
return $row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 同行的人(带「我叫他什么」与主色,页面按人画标签) */
|
|
|
|
|
|
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();
|
|
|
|
|
|
$db->prepare(
|
|
|
|
|
|
"INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
|
|
|
|
|
|
VALUES (?, ?, 'trip', ?, ?, ?, ?, ?, ?)"
|
|
|
|
|
|
)->execute([
|
|
|
|
|
|
$familyId, $creatorId, (string)$d['title'],
|
|
|
|
|
|
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
|
|
|
|
|
|
self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
|
|
|
|
|
|
$d['source_ref'] ?? null,
|
|
|
|
|
|
]);
|
|
|
|
|
|
$id = (int)$db->lastInsertId();
|
|
|
|
|
|
self::saveDetails($id, $d);
|
|
|
|
|
|
self::saveAttendees($id, $d['attendees'] ?? []);
|
|
|
|
|
|
return $id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function update(int $familyId, int $id, array $d): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$db->prepare(
|
|
|
|
|
|
"UPDATE events SET title=?, starts_at=?, ends_at=?, location=?, note=?
|
|
|
|
|
|
WHERE id=? AND family_id=? AND kind='trip'"
|
|
|
|
|
|
)->execute([
|
|
|
|
|
|
(string)$d['title'],
|
|
|
|
|
|
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
|
|
|
|
|
|
self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
|
|
|
|
|
|
$id, $familyId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
self::saveDetails($id, $d);
|
|
|
|
|
|
self::saveAttendees($id, $d['attendees'] ?? []);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function saveDetails(int $eventId, array $d): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$row = [
|
|
|
|
|
|
self::keyOrNull($d['cover_key'] ?? ''),
|
|
|
|
|
|
self::textOrNull($d['summary'] ?? ''),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 用户第 4 条:是否公开。
|
|
|
|
|
|
// ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」:
|
|
|
|
|
|
// · 没传(程序化创建 / 迁移 / 老调用)→ **按公开(1)**,
|
|
|
|
|
|
// 与建表默认值一致;默认 0 会让数据**静默消失**在列表里,
|
|
|
|
|
|
// 那是最坏的一种失败(用户只会看到"我的行程不见了")。
|
|
|
|
|
|
// · 传了 0(表单里取消勾选)→ 真的不公开。
|
|
|
|
|
|
array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
|
|
|
|
|
$has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?');
|
|
|
|
|
|
$has->execute([$eventId]);
|
|
|
|
|
|
if ((int)$has->fetchColumn() > 0) {
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$db->prepare('UPDATE trip_details SET cover_key=?, summary=?, is_public=? WHERE event_id=?')
|
2026-10-02 09:28:07 +08:00
|
|
|
|
->execute([...$row, $eventId]);
|
|
|
|
|
|
} else {
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$db->prepare('INSERT INTO trip_details (cover_key, summary, is_public, event_id) VALUES (?,?,?,?)')
|
2026-10-02 09:28:07 +08:00
|
|
|
|
->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);
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
2026-10-04 20:57:40 +08:00
|
|
|
|
// 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';
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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',
|
|
|
|
|
|
in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned',
|
2026-10-04 20:57:40 +08:00
|
|
|
|
$category,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
(int)($d['position'] ?? 100),
|
2026-10-04 20:57:40 +08:00
|
|
|
|
$stage,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
|
|
|
|
|
if ($id !== null && $id > 0) {
|
|
|
|
|
|
$db->prepare(
|
|
|
|
|
|
'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?,
|
2026-10-04 20:57:40 +08:00
|
|
|
|
node_type=?, status=?, category=?, position=?, stage=? WHERE id=?'
|
2026-10-02 09:28:07 +08:00
|
|
|
|
)->execute([...$fields, $id]);
|
|
|
|
|
|
$nid = $id;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$db->prepare(
|
|
|
|
|
|
'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at,
|
2026-10-04 20:57:40 +08:00
|
|
|
|
node_type, status, category, position, stage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
|
2026-10-02 09:28:07 +08:00
|
|
|
|
)->execute([$familyId, $eventId, ...$fields]);
|
|
|
|
|
|
$nid = (int)$db->lastInsertId();
|
|
|
|
|
|
}
|
|
|
|
|
|
self::saveNodeMembers($nid, $d['members'] ?? []);
|
|
|
|
|
|
return $nid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 20:57:40 +08:00
|
|
|
|
// ---------------- 计划行程的三个动作(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')));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function saveNodeMembers(int $nodeId, array $personIds): 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 (?,?)');
|
|
|
|
|
|
foreach ($personIds as $pid) {
|
|
|
|
|
|
$pid = (int)$pid;
|
|
|
|
|
|
if ($pid > 0) {
|
|
|
|
|
|
$ins->execute([$nodeId, $pid]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- 展示 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
/** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function typeWord(string $key): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::NODE_TYPES[$key][0] ?? '其他';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function typeIcon(string $key): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::NODE_TYPES[$key][1] ?? '📌';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|