v24 第 13 批:旅程模块重构(双轨 → 一条记录 + 状态机)

用户原话:「旅程这个模块,我准备大改…如果按照计划行程和实际行程来分别管理,
会变的很复杂…根据我的想法,重构这个模块。」
—— 这不是加需求,是把「双轨」架构换成「一条记录 + 状态机」。

## 用户确认的三个决定
1. 两层:**活动**(顶层容器)→ **行程**(挂其下,字段沿用原节点)
2. **修正不立即转实际**:修正只是记录,行程仍标"计划(已修正 N 次)",
   要再点一次「标记为实际」才转实际
3. 老数据**按现有轨道自动迁移**(category=reality → stage=actual)

## 状态机(本次重构的心脏)
trip_nodes.stage:
  · plan    计划行程 —— 唯一以「计划时间」上日程的状态,三个动作都能用
  · actual  实际行程 —— 上日程(实际时间)+ 进「往年今日」
  · skipped 未成行   —— **不上日程、不进往年今日**,但旅程里仍显示并标注
                          (用户:"还是要显示,只不过要标记上")
旧的 category(plan/reality) 保留作兼容,业务一律看 stage(saveNode 同步维护两者)。

## 三个动作(Trip.php)
· reviseNode —— 记一次修正。🔴 **第一次修正前先把原计划存成 seq=1 快照**,
  否则用户要的"之前的计划行程数据要保留,为了以后做对比"就落空。**不动 stage**。
  可无限次(seq 递增)。
· completeNode —— 实际=计划,直接转 actual,**不产生修正历史**(没有变化就没有历史)。
· markActual —— 把(已修正过的)计划正式转 actual。
· skipNode —— **不是真删**,只置 stage='skipped'(用户明确"不要真的删除,只是做一个标记")。
· restoreNode —— 撤销未成行(点错了能改回来)。
界面上的「彻底删除」与「标记未成行」是两个并列动作,弹窗文案里写明区别。

## 修正历史(Schema v19,新表 trip_node_revisions)
每次修正一行:实际起止 / 实际地点 / 说明 / 参与人快照。
第 1 条 = 原计划快照,第 2..n 条 = 各次修正 —— 详情页用表格展示,可直接对比。

## 日历与往年今日(用户点名的联动)
· 日历:`n.stage IN ('plan','actual')` —— 去掉旧的 `status<>'cancelled'`
  (那是双轨口径;现在"未成行"是 stage)。实际行程标题加 ✓ 前缀便于区分。
· **活动本身绝不上日程**(用户:"活动因为可能跨越的时间比较长")。
· 往年今日:旅程部分从「整趟 events」改成**实际行程**(stage='actual'),
  标题带活动名前缀;未成行的不出现。

## 验证
新增 _trip_e2e12.py **30/0**:建活动 → 加行程(可选计划/实际)→ 修正(仍计划 /
历史保留原计划 09:00 / 可无限次)→ 标记为实际 → 完成(无历史)→ 标记未成行(未真删)
→ 日历四种情形(已转实际✓ 新建实际✓ 完成✓ 未成行✗ 活动✗)→ 往年今日只看实际。
回归:e2e 787/0、升级 129/0(含 v19 路径)、batch_e 91/0、batch10 46/0、batch9 38/0、
picker 28/0 + 11/0、crop 17/0、formbloat 9/0、semi 27/0、geom 5/5、mobile 688/0、
interact 92/0、drag 5/0。
e2e 两条旧断言("双轨两块/两轨各一条")按重构语义更新为"三组渲染 / stage 各一条
+ category 与 stage 同步"。
部署断言新增 1 大组,含**反退化防线**:skipNode 若被改成 DELETE 立即变红。
线上自检 19/19;线上真实账号验收 16/0。
线上老数据迁移实测:#222 计划3+实际1、#221 实际4、#220 实际13 —— 一条没丢。
This commit is contained in:
2026-10-04 20:57:40 +08:00
parent ecf121772f
commit 89b956205d
8 changed files with 708 additions and 40 deletions
+13 -3
View File
@@ -744,15 +744,21 @@ final class Calendar
// ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串,
// 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉,
// 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
// v24 第 13 批(用户 2026-10-05):旅程重构后,**按 stage 过滤**。
// · plan → 上日程(用**计划**时间)
// · actual → 上日程(时间已经是实际时间,因为"修正"会把实际值写回行程)
// · skipped → **不上日程**(用户:"就权当没有这个日程")
// ⚠️ 不能再按 status<>'cancelled' 过滤 —— 那是旧双轨的口径;
// 现在"未成行"是 stage='skipped',而 status 可能仍是 planned。
$st = Database::get()->prepare(
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
n.node_type, n.status AS node_status,
n.node_type, n.status AS node_status, n.stage AS node_stage,
e.id AS event_id, e.title AS trip_title
FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.starts_at IS NOT NULL
AND n.status <> 'cancelled'
AND n.stage IN ('plan','actual')
AND COALESCE(d.is_public, 1) = 1
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
ORDER BY n.starts_at"
@@ -772,7 +778,11 @@ final class Calendar
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => Trip::typeIcon((string)$n['node_type']) . ' ' . (string)$n['node_title'],
// 用户要"一眼看出这是计划还是实际":实际行程加个 ✓ 前缀。
// 计划行程保持原样(日历里大多数是它,加前缀反而吵)。
'title' => Trip::typeIcon((string)$n['node_type'])
. ((string)($n['node_stage'] ?? 'plan') === 'actual' ? ' ✓ ' : ' ')
. (string)$n['node_title'],
'date' => $day,
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
+70 -2
View File
@@ -64,6 +64,15 @@ final class Schema
* + task_recur(灵活循环);
* ⑤ 统一附件 attachments(旅程/节点/资源/任务/评论共用一张表);
* ⑥ 模块导航与主题色走 user_settings / settings,不新建表。
* 19 → **旅程模块重构(用户 2026-10-05)**:活动/行程两层语义收敛。
* `trip_nodes` 的**双轨 category(plan/reality) 改为状态机 stage**:
* plan(计划)/ actual(实际)/ skipped(标记未成行,不真删)。
* 理由:用户明确「实际的行程和计划行程,是一条记录,不是两条」,
* 而旧的双轨是**两条独立记录**。
* 新增 `trip_node_revisions`:每次「修正」存一份快照
* (实际起止 / 参与人 / 备注),可无限次,供日后对比。
* 迁移:category='reality' → stage='actual','plan' → stage='plan'
* (按现有轨道自动迁,用户已确认)。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
@@ -71,7 +80,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '18';
public const VERSION = '19';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -447,7 +456,10 @@ CREATE TABLE IF NOT EXISTS `trip_nodes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`event_id` INT UNSIGNED NOT NULL COMMENT '属于哪趟旅程',
`category` ENUM('plan','reality') NOT NULL DEFAULT 'plan' COMMENT 'plan=计划 / reality=实际走过的',
`category` ENUM('plan','reality') NOT NULL DEFAULT 'plan' COMMENT '【v19 起仅作兼容保留,业务改用 stage】plan=计划 / reality=实际走过的',
`stage` ENUM('plan','actual','skipped') NOT NULL DEFAULT 'plan' COMMENT 'v19 行程状态机:plan=计划 / actual=实际行程 / skipped=标记未成行(不真删)',
`revised_at` DATETIME DEFAULT NULL COMMENT '最后一次「修正」的时间(NULL=从未修正)',
`revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数(可用于显示"已修正 N 次")',
`node_type` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 其他',
`title` VARCHAR(200) NOT NULL,
`detail` TEXT DEFAULT NULL COMMENT '这一段的文字记录',
@@ -464,6 +476,23 @@ CREATE TABLE IF NOT EXISTS `trip_nodes` (
CONSTRAINT `fk_tn_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tn_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点:一趟旅程里的每一段(计划/执行双轨,TripLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_revisions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`node_id` INT UNSIGNED NOT NULL COMMENT '被修正的行程(trip_nodes.id)',
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几次修正(从 1 开始)',
`starts_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**开始',
`ends_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**结束',
`place` VARCHAR(200) DEFAULT NULL COMMENT '实际地点',
`detail` TEXT DEFAULT NULL COMMENT '这次修正的说明(为什么改了)',
`members` TEXT DEFAULT NULL COMMENT '实际参与人 id 列表(逗号分隔的快照)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tnr_node` (`node_id`, `seq`),
CONSTRAINT `fk_tnr_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程的修正历史(每次「修正」一条,可无限次,供日后对比)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_members` (
@@ -868,6 +897,45 @@ SQL,
}
});
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
self::addColumn($db, 'trip_nodes', 'stage',
"ADD COLUMN `stage` ENUM('plan','actual','skipped') NOT NULL DEFAULT 'plan' "
. "COMMENT 'v19 行程状态机:plan/actual/skipped' AFTER `category`");
self::addColumn($db, 'trip_nodes', 'revised_at',
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
self::addColumn($db, 'trip_nodes', 'revision_n',
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
// ② 建修正历史表(老库没有)
self::tryStep('建行程修正历史表', static function () use ($db) {
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_revisions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`node_id` INT UNSIGNED NOT NULL COMMENT '被修正的行程(trip_nodes.id)',
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几次修正(从 1 开始)',
`starts_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**开始',
`ends_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**结束',
`place` VARCHAR(200) DEFAULT NULL COMMENT '实际地点',
`detail` TEXT DEFAULT NULL COMMENT '这次修正的说明(为什么改了)',
`members` TEXT DEFAULT NULL COMMENT '实际参与人 id 列表(逗号分隔的快照)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tnr_node` (`node_id`, `seq`),
CONSTRAINT `fk_tnr_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='行程的修正历史(每次「修正」一条,可无限次,供日后对比)'
SQL);
});
// ③ 按现有轨道迁移 stage(用户 2026-10-05 确认:"按现有轨道自动迁移")
// category='reality' 的那条 → 已经是实际行程;'plan' → 计划行程。
// ⚠️ 只迁**还是默认值**的行(stage='plan' 且 revision_n=0),
// 避免把用户在新版里已经改过的状态又刷回去(重复跑升级是常态)。
self::tryStep('按现有轨道迁移行程状态(plan/reality → stage)', static function () use ($db) {
$db->exec("UPDATE `trip_nodes` SET `stage` = 'actual'
WHERE `category` = 'reality' AND `revision_n` = 0 AND `stage` = 'plan'");
});
// ---- v24 第 10 批 #2:负债类账目一律存正数 ----
// 用户原话:「类别,增加一个欠款,用来记录别人的钱,属于负债,是负的。」
// 也就是说用户会把欠款填成 **-500**。旧的 summary() 用 `net = 资产 − 负债`,
+207 -3
View File
@@ -41,6 +41,50 @@ final class Trip
'cancelled' => '已取消',
];
/**
* 行程的**状态机**(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);
}
/** 全部旅程(按出发时间倒序) */
public static function all(int $familyId, string $keyword = ''): array
{
@@ -278,6 +322,13 @@ final class Trip
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';
$fields = [
self::textOrNull2($d['title'] ?? ''),
self::textOrNull($d['detail'] ?? ''),
@@ -286,19 +337,20 @@ final class Trip
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',
($d['category'] ?? '') === 'reality' ? 'reality' : 'plan',
$category,
(int)($d['position'] ?? 100),
$stage,
];
if ($id !== null && $id > 0) {
$db->prepare(
'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?,
node_type=?, status=?, category=?, position=? WHERE id=?'
node_type=?, status=?, category=?, position=?, stage=? 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) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
node_type, status, category, position, stage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$familyId, $eventId, ...$fields]);
$nid = (int)$db->lastInsertId();
}
@@ -306,6 +358,158 @@ final class Trip
return $nid;
}
// ---------------- 计划行程的三个动作(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) {
+46
View File
@@ -2914,3 +2914,49 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
@media (max-width: 560px) {
.addr-cell-wide { grid-column: span 1; }
}
/* ==================== 旅程:行程状态机(v24 第 13 批)====================
分三组:计划 / 实际 / 未成行。未成行用灰调(它"不算数"但要看得到)。 */
.trip-stage-card { margin-bottom: 18px; }
.stage-hint { margin: -6px 0 12px; font-size: 12.5px; color: var(--muted); }
.stage-skipped { opacity: .82; }
.stage-skipped .node-row { background: var(--line-2); }
.node-badge.nb-plan { background: #EDE7DC; color: #6B5B45; }
.node-badge.nb-actual { background: #DCEFE2; color: #2C6B45; }
.node-badge.nb-skipped { background: #EFEDEA; color: #6B6560; }
.rev-badge {
display: inline-block; padding: 1px 7px; border-radius: 999px;
background: #F1E6F5; color: #6B3F7A; font-size: 11.5px;
}
.actual-tag {
display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 999px;
background: #DCEFE2; color: #2C6B45; font-size: 11px;
}
/* 修正面板(在行程行里就地展开) */
.rev-open > summary { cursor: pointer; list-style: none; }
.rev-open > summary::-webkit-details-marker { display: none; }
.rev-panel {
margin-top: 10px; padding: 12px 14px;
background: var(--surface-2, #FAF7F1);
border: 1px solid var(--line); border-radius: 10px;
}
.rev-tip { margin: 0 0 10px; font-size: 12.5px; line-height: 1.7; color: var(--muted); }
.rev-form .btn-row { margin-top: 10px; }
/* 修正历史对比表 */
.rev-hist { margin-top: 8px; }
.rev-hist > summary { cursor: pointer; font-size: 12.5px; }
.rev-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 12.5px; }
.rev-table th, .rev-table td {
padding: 5px 8px; border-bottom: 1px solid var(--line-2); text-align: left;
}
.rev-table th { color: var(--muted); font-weight: 500; }
.rev-table tr:first-child td { color: var(--ink); } /* 原计划一行稍醒目 */
/* 行程表单里的"当前状态"只读展示 */
.node-stage-ro {
padding: 9px 11px; border: 1px dashed var(--line); border-radius: var(--radius-s);
background: var(--line-2); font-size: 13px;
}
+136 -4
View File
@@ -504,19 +504,57 @@ function home_memories(int $familyId, int $limit = 6): array
{
$today = date('Y-m-d');
$year = (int)date('Y');
$md = substr($today, 5, 5);
$rows = [];
// ① 观影:照旧(events kind=movie)
$sql = "SELECT e.id, e.title, e.starts_at, e.kind,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n
FROM events e
WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL
WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL
AND YEAR(e.starts_at) < ?
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
$st = Database::get()->prepare($sql);
$st->execute([$familyId, $year, substr($today, 5, 5)]);
$rows = $st->fetchAll();
$st->execute([$familyId, $year, $md]);
foreach ($st->fetchAll() as $r) {
$rows[] = $r;
}
// ② 旅程:改成看**实际行程**(trip_nodes stage='actual'),不是整趟旅程。
// 用户 2026-10-05:「实际行程,都会出现在日历和首页的往年今日中」,
// 而"标记未成行 / 仍是计划"的都不该出现("就权当没有这个日程")。
// ⚠️ 用 starts_at 而不是 events.starts_at —— 实际行程的时间才是"那天真的走了"。
$sql2 = "SELECT n.id, n.title, n.starts_at, 'trip_node' AS kind,
(SELECT COUNT(*) FROM trip_node_members m WHERE m.node_id = n.id) AS n,
e.id AS trip_id, e.title AS trip_title, n.node_type
FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.stage = 'actual' AND n.starts_at IS NOT NULL
AND COALESCE(d.is_public, 1) = 1
AND YEAR(n.starts_at) < ?
AND DATE_FORMAT(n.starts_at, '%m-%d') = ?
ORDER BY n.starts_at DESC LIMIT " . (int)$limit;
$st2 = Database::get()->prepare($sql2);
$st2->execute([$familyId, $year, $md]);
foreach ($st2->fetchAll() as $r) {
// 标题带上"是哪趟旅程的哪一段",否则只看到一个"栈桥看海"不知道是何时的
$r['title'] = (string)$r['trip_title'] . ' · ' . (string)$r['title'];
$rows[] = $r;
}
// 合并后按时间倒序,取前 N
usort($rows, static fn(array $a, array $b): int => strcmp((string)$b['starts_at'], (string)$a['starts_at']));
$rows = array_slice($rows, 0, $limit);
foreach ($rows as &$r) {
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
if ($r['kind'] === 'trip_node') {
$r['href'] = url('trips/show', ['id' => (int)$r['trip_id']]) . '#node-' . (int)$r['id'];
} else {
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
}
}
unset($r);
return ['rows' => $rows, 'exact' => true];
@@ -2907,6 +2945,9 @@ switch ($r) {
'node_type' => (string)($_POST['node_type'] ?? 'other'),
'status' => (string)($_POST['status'] ?? 'planned'),
'category' => (string)($_POST['category'] ?? 'plan'),
// v24 第 13 批:新建时用户在表单里选的「计划 / 实际」。
// 编辑时表单带的是隐藏 stage(保持原状态),也一并读。
'stage' => (string)($_POST['stage_pick'] ?? $_POST['stage'] ?? 'plan'),
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
];
if ($d['title'] === '') {
@@ -2934,6 +2975,97 @@ switch ($r) {
redirect('trips/show', ['id' => $tripId]);
break;
// ---- 计划行程的三个动作(v24 第 13 批)----
// 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作,
// 一个是删除,如果是标记为删除,不要真的删除,只是做一个标记…
// 另外一个按钮,用来记录实际的情况,按钮名字就叫修正…
// 还有一个按钮,就是完成…这个行程直接就变成了实际行程。」
//
// ⚠️ 三个动作都**只改状态、不真删** —— 这是用户明确要求的语义。
/** 「修正」:记一次实际执行情况(可无限次;行程仍是计划,要再点「标记为实际」) */
case 'trips/node_revise':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
$nid = (int)($_POST['id'] ?? 0);
$seq = Trip::reviseNode($nid, [
'starts_at' => (string)(read_dt_input('rev_starts_at') ?? ''),
'ends_at' => (string)(read_dt_input('rev_ends_at') ?? ''),
'place' => trim((string)($_POST['place'] ?? '')),
'detail' => trim((string)($_POST['detail'] ?? '')),
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
], (int)Auth::personId());
flash('ok', $seq > 0 ? '已记录第 ' . $seq . ' 次修正。它仍是**计划行程**;'
. '确认无误后点「标记为实际」即可转为实际行程。' : '修正没成功。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 「标记为实际」:把(已经修正过的)计划行程正式转为实际行程 */
case 'trips/node_actual':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::markActual((int)($_POST['id'] ?? 0));
flash('ok', '已转为**实际行程** —— 它会出现在日历与首页的「往年今日」。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 「完成」:实际与计划完全相同 → 直接变实际行程(不产生修正历史) */
case 'trips/node_done':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::completeNode((int)($_POST['id'] ?? 0));
flash('ok', '这一段已按计划完成,转为**实际行程**。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 「标记未成行」:不真删,只打标记(不上日程、不进往年今日,但旅程里仍显示) */
case 'trips/node_skip':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::skipNode((int)($_POST['id'] ?? 0));
flash('ok', '已标记为**未成行**。它不再出现在日程与往年今日,但在这趟旅程里仍然看得到。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 撤销「未成行」→ 退回计划行程(点错了要能改回来) */
case 'trips/node_restore':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::restoreNode((int)($_POST['id'] ?? 0));
flash('ok', '已恢复为计划行程,它会重新出现在日程里。');
redirect('trips/show', ['id' => $tripId]);
break;
// ==================== 日程 / 日历(v12) ====================
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
+30 -5
View File
@@ -2,23 +2,33 @@
/**
* 行程节点表单(新增 / 编辑共用;被 show.php 循环 require)。
*
* 计划与执行是**双轨**:同一段行程可以先在 plan 里排一遍,走完再在 reality 里记一遍。
* 所以表单里带一个隐藏的 category —— 加在哪一轨就进哪一轨。
* v24 第 13 批:旅程重构后,**行程不再分两条轨**,而是**一条记录 + 状态机**。
* 用户原话:「这个实际的行程和计划行程,是一条记录,不是两条」。
* 所以这里加一个「这是哪一类」的下拉:计划行程 / 实际行程
* (新建时由用户选;编辑时是当前状态)。
* 「修正」「标记为实际」「完成」「标记未成行」是**另外三个按钮**(在 show.php 上),
* 不走这张表单 —— 它们的语义是"对既有行程的操作",不是"填一遍字段"。
*
* 变量(require 前设好):
* $nfNode 被编辑的节点数组,或 null(新增)
* $nfTripId 旅程 id(events.id)
* $nfCategory 'plan' | 'reality'
* $nfCategory 【兼容保留】'plan' | 'reality'(新版只用来定默认值)
* $nfPeople 可选人名册(选参与人用),为空就不显示成员多选
* $nfMembers 已选成员 id 列表
*/
$nfIsEdit = !empty($nfNode);
$nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'reality' ? 'r' : 'p'));
$nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : 'nn';
// 默认状态:编辑时用它的 stage;新建时按调用方给的 $nfCategory 映射(兼容旧调用)
$nfStage = $nfIsEdit
? (string)($nfNode['stage'] ?? (($nfNode['category'] ?? 'plan') === 'reality' ? 'actual' : 'plan'))
: ($nfCategory === 'reality' ? 'actual' : 'plan');
?>
<form class="node-form" method="post" action="<?= e(url($nfIsEdit ? 'trips/node_update' : 'trips/node_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="trip_id" value="<?= (int)$nfTripId ?>">
<input type="hidden" name="category" value="<?= e($nfCategory) ?>">
<?php /* v24 第 13 批:新建时选「计划 / 实际」——用户原话第 2 条明确要求。
一旦创建后要改,走行程上那三个按钮(修正/完成/标记未成行),不在这张表里。 */ ?>
<input type="hidden" name="stage" value="<?= e($nfStage) ?>">
<?php if ($nfIsEdit): ?>
<input type="hidden" name="id" value="<?= (int)$nfNode['id'] ?>">
<?php endif; ?>
@@ -39,6 +49,21 @@ $nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'rea
<?php endforeach; ?>
</select>
</div>
<?php if (!$nfIsEdit): ?>
<div class="field">
<label for="<?= e($nfPrefix) ?>-stage">这是哪一类 *</label>
<select class="select" id="<?= e($nfPrefix) ?>-stage" name="stage_pick">
<option value="plan"<?= $nfStage === 'plan' ? ' selected' : '' ?>>计划行程(还没走)</option>
<option value="actual"<?= $nfStage === 'actual' ? ' selected' : '' ?>>实际行程(已经走过)</option>
</select>
<span class="tip">计划行程走完后可用「修正 / 完成」转成实际行程。</span>
</div>
<?php else: ?>
<div class="field">
<label>当前状态</label>
<div class="node-stage-ro"><?= e(\App\Trip::stageWord($nfStage)) ?></div>
</div>
<?php endif; ?>
<div class="field">
<label for="<?= e($nfPrefix) ?>-place">地点</label>
<input class="input" id="<?= e($nfPrefix) ?>-place" name="place"
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* 「修正」表单(v24 第 13 批):记录一条计划行程的**实际**执行情况。
*
* 用户原话:「另外一个按钮,用来记录实际的情况,按钮名字就叫修正,但是记住,
* 这个实际的行程和计划行程,是一条记录,不是两条,点击这个按钮,
* 则让用户选择实际的执行时间,包括开始和结束时间,实际的参与人等信息,
* 就相当于是修改了一下这个行程,只不过,之前的计划行程数据要保留,
* 为了以后做对比。修正,是无限次数的。」
*
* ⚠️ 与「编辑」的区别:
* · 编辑 = 改**计划**本身(排错了计划就改它);
* · 修正 = 记**实际**发生了什么(计划不动,实际值进历史,供对比)。
* 两者都作用在同一条 trip_nodes 记录上(用户明确"是一条记录,不是两条")。
*
* 变量(require 前设好):
* $rvNode 被修正的行程
* $rvTripId 旅程 id
* $rvPeople 人名册(选实际参与人)
* $rvMembers 当前参与人 id
*/
$rvPrefix = 'rv' . (int)$rvNode['id'];
$rvN = (int)($rvNode['revision_n'] ?? 0);
?>
<form class="rev-form" method="post" action="<?= e(url('trips/node_revise')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="trip_id" value="<?= (int)$rvTripId ?>">
<input type="hidden" name="id" value="<?= (int)$rvNode['id'] ?>">
<div class="nf-grid">
<?php /* 实际的起止时间:默认填计划值,方便"只改时间"这种最常见的情况 */ ?>
<div class="field dt-range full">
<label>实际的开始 / 结束</label>
<?php $dtName = 'rev_starts_at'; $dtValue = $rvNode['starts_at'] ?? '';
$dtId = $rvPrefix . '-s'; $dtSeconds = true; $dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'rev_ends_at'; $dtValue = $rvNode['ends_at'] ?? '';
$dtId = $rvPrefix . '-e'; $dtSeconds = true; $dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">默认是计划的时间,改成实际发生的即可。</span>
</div>
<div class="field">
<label for="<?= e($rvPrefix) ?>-place">实际地点</label>
<input class="input" id="<?= e($rvPrefix) ?>-place" name="place"
value="<?= e($rvNode['place'] ?? '') ?>" placeholder="没变就留空">
</div>
<div class="field full">
<label for="<?= e($rvPrefix) ?>-detail">这次修正的说明</label>
<input class="input" id="<?= e($rvPrefix) ?>-detail" name="detail"
placeholder="如:航班延误,改到下午才到">
</div>
<?php if (!empty($rvPeople)): ?>
<div class="field full">
<label>实际参与的人</label>
<div class="nf-members">
<?php foreach ($rvPeople as $p): ?>
<label class="check-item">
<input type="checkbox" name="members[]" value="<?= (int)$p['id'] ?>"
<?= in_array((int)$p['id'], $rvMembers, true) ? 'checked' : '' ?>>
<span><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">
<?= $rvN > 0 ? '再记一次修正' : '记录这次修正' ?>
</button>
<?php if ($rvN > 0): ?>
<span class="tip" style="align-self:center">之前已记过 <?= $rvN ?> 次;原计划数据都留着。</span>
<?php endif; ?>
</div>
</form>
<?php unset($rvPrefix, $rvN); ?>
+130 -23
View File
@@ -6,15 +6,22 @@
* 两轨并存才能回看"本来想去哪、后来去了哪"—— 这是 TripLog 最有价值的一点,搬过来时保留了。
*/
$canWrite = \App\Auth::canWrite();
$plan = [];
$reality = [];
// v24 第 13 批:不再按 category 双轨分,按**状态机 stage** 分三组展示。
// 计划行程 / 实际行程 / 未成行(未成行的也要显示,用户明确要求"只不过要标记上")。
$bysStage = ['plan' => [], 'actual' => [], 'skipped' => []];
foreach ((array)($nodes ?? []) as $n) {
if (($n['category'] ?? 'plan') === 'reality') {
$reality[] = $n;
} else {
$plan[] = $n;
}
$sg = (string)($n['stage'] ?? (($n['category'] ?? 'plan') === 'reality' ? 'actual' : 'plan'));
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
$bysStage[$sg][] = $n;
}
// 每条行程带上它的修正历史(模板里画"原计划 → 实际"的对比)
foreach ($bysStage as $sg => &$lst) {
foreach ($lst as &$nd) {
$nd['revisions'] = \App\Trip::revisions((int)$nd['id']);
}
unset($nd);
}
unset($lst);
$memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'id'));
?>
<div class="page-head">
@@ -103,32 +110,59 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<?php endif; ?>
</div>
<?php /* ---- 行程节点(双轨)---- */ ?>
<?php foreach ([['plan', '计划行程', $plan], ['reality', '实际走过', $reality]] as [$cat, $catName, $list]): ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= e($catName) ?> <span class="group-n"><?= count($list) ?> 段</span></h2>
<?php /* ==================== 行程(v24 第 13 批重构)====================
用户 2026-10-05 原话要点:
· 刚开始添加时选「计划行程 / 实际行程」
· 计划行程有三个按钮:删除(**标记未成行,不真删**)、修正(记实际执行,可无限次)、
完成(实际=计划,直接变实际)
· 未成行的仍显示、但带标记;不上日程、不进往年今日
· 实际行程上日程 + 往年今日
分三组渲染:计划 / 实际 / 未成行(未成行放最后,用灰调)。 */ ?>
<?php
$stageMeta = [
'plan' => ['计划行程', '还没走,下面的「修正 / 完成 / 标记未成行」都能用'],
'actual' => ['实际行程', '已走过 —— 会出现在日历与首页「往年今日」'],
'skipped' => ['未成行', '这次计划没成行;不再出现在日程与「往年今日」,但在这里保留可查'],
];
foreach (['plan', 'actual', 'skipped'] as $sg):
$list = $bysStage[$sg];
?>
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
<h2 class="cinema-h2">
<?= e($stageMeta[$sg][0]) ?>
<span class="group-n"><?= count($list) ?> 段</span>
</h2>
<p class="stage-hint"><?= e($stageMeta[$sg][1]) ?></p>
<?php if (!$list): ?>
<p class="empty-hint"><?= $cat === 'plan' ? '还没有排计划。' : '还没有记录实际行程。' ?></p>
<p class="empty-hint">
<?= $sg === 'plan' ? '还没有排计划。' : ($sg === 'actual' ? '还没有走过的行程。' : '没有未成行的计划。') ?>
</p>
<?php else: ?>
<ol class="node-list">
<?php foreach ($list as $n): ?>
<?php /* v24:日历里点「行程节点」是跳 #node-<id> 过来的,这里给个锚点好定位。 */ ?>
<li class="node-row status-<?= e((string)$n['status']) ?>" id="node-<?= (int)$n['id'] ?>">
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
id="node-<?= (int)$n['id'] ?>">
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'])) ?>">
<?= e(\App\Trip::typeIcon((string)$n['node_type'])) ?>
</span>
<div class="node-body">
<div class="node-head">
<b><?= e((string)$n['title']) ?></b>
<span class="node-badge"><?= e(\App\Trip::statusWord((string)$n['status'])) ?></span>
<span class="node-badge nb-<?= e($sg) ?>"><?= e(\App\Trip::stageWord($sg)) ?></span>
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<span class="rev-badge" title="已修正过多次,点下方「看修正记录」查看">已修正 <?= (int)$n['revision_n'] ?> 次</span>
<?php endif; ?>
<?php if (!empty($n['place'])): ?>
<span class="node-place">📍 <?= e((string)$n['place']) ?></span>
<?php endif; ?>
</div>
<?php $span = \App\Trip::formatSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null); ?>
<?php if ($span !== '日期待补'): ?>
<div class="node-time"><?= e($span) ?></div>
<div class="node-time">
<?= e($span) ?>
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($n['detail'])): ?>
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
@@ -141,29 +175,101 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<?php if (!empty($n['attachs'])): ?>
<?php
$attRows = $n['attachs'];
$attCanEdit = false; // 节点附件不单独给删除入口(防越权:得先判整趟旅程)
$attCanEdit = false;
$attDelUrl = '';
require __DIR__ . '/../_attachments.php';
?>
<?php endif; ?>
<?php /* ---- 修正记录:原计划 → 每次修正的对比 ---- */ ?>
<?php if (!empty($n['revisions'])): ?>
<details class="rev-hist">
<summary class="link-quiet">看修正记录(<?= count($n['revisions']) ?> 条)</summary>
<table class="rev-table">
<thead>
<tr><th>#</th><th>时间</th><th>地点</th><th>说明</th></tr>
</thead>
<tbody>
<?php foreach ($n['revisions'] as $rv): ?>
<tr>
<td><?= (int)$rv['seq'] === 1 ? '原计划' : '第 ' . ((int)$rv['seq'] - 1) . ' 次修正' ?></td>
<td class="small"><?= e(\App\Trip::formatSpan($rv['starts_at'] ?? null, $rv['ends_at'] ?? null)) ?></td>
<td><?= e((string)($rv['place'] ?? '')) ?></td>
<td class="small"><?= e((string)($rv['detail'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</details>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="node-ops">
<?php /* ---- 计划行程:三个动作(用户点名的三个按钮)---- */ ?>
<?php if ($sg === 'plan'): ?>
<details class="rev-open">
<summary class="btn btn-ghost btn-sm">修正</summary>
<div class="rev-panel">
<p class="rev-tip">
记录**实际**执行情况。这仍与上面那条计划是<b>同一条记录</b>,
原来的计划数据会保留在「修正记录」里,方便日后对比。
点完这里<b>行程仍是计划</b>;确认无误后再点「标记为实际」。
</p>
<?php
$rvNode = $n; $rvTripId = (int)$t['id'];
$rvPeople = $people ?? [];
$rvMembers = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
require __DIR__ . '/_revise_form.php';
?>
</div>
</details>
<form method="post" action="<?= e(url('trips/node_done')) ?>"
onsubmit="return confirm('实际执行与计划完全相同,直接转为实际行程?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-sm" type="submit">完成</button>
</form>
<form method="post" action="<?= e(url('trips/node_skip')) ?>"
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这趟旅程里仍然看得到。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">标记未成行</button>
</form>
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<form method="post" action="<?= e(url('trips/node_actual')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-sm btn-primary" type="submit">标记为实际</button>
</form>
<?php endif; ?>
<?php elseif ($sg === 'skipped'): ?>
<form method="post" action="<?= e(url('trips/node_restore')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">恢复为计划</button>
</form>
<?php endif; ?>
<details class="node-edit">
<summary class="link-quiet">编辑</summary>
<?php
$nfNode = $n; $nfTripId = (int)$t['id']; $nfCategory = $cat;
$nfNode = $n; $nfTripId = (int)$t['id']; $nfCategory = $sg;
$nfPeople = $people ?? [];
$nfMembers = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
require __DIR__ . '/_node_form.php';
?>
</details>
<form method="post" action="<?= e(url('trips/node_del')) ?>"
onsubmit="return confirm('删掉这一段?')">
onsubmit="return confirm('**彻底删除**这一段?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达"这次没去成",请用「标记未成行」。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">删除</button>
<button class="btn btn-ghost btn-sm" type="submit">彻底删除</button>
</form>
</div>
<?php endif; ?>
@@ -172,11 +278,11 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
</ol>
<?php endif; ?>
<?php if ($canWrite): ?>
<?php if ($canWrite && $sg !== 'skipped'): ?>
<details class="node-add">
<summary class="link-quiet">+ 加一段<?= e($catName) ?></summary>
<summary class="link-quiet">+ 加一段<?= e($stageMeta[$sg][0]) ?></summary>
<?php
$nfNode = null; $nfTripId = (int)$t['id']; $nfCategory = $cat;
$nfNode = null; $nfTripId = (int)$t['id']; $nfCategory = $sg;
$nfPeople = $people ?? []; $nfMembers = $memberIds;
require __DIR__ . '/_node_form.php';
?>
@@ -184,3 +290,4 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<?php endif; ?>
</div>
<?php endforeach; ?>