diff --git a/app/Calendar.php b/app/Calendar.php index 3dd7c16..c397869 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -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) : '', // 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径) diff --git a/app/Schema.php b/app/Schema.php index 76c41c6..d1eaeff 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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 = 资产 − 负债`, diff --git a/app/Trip.php b/app/Trip.php index 07fff83..a42e8ad 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -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) { diff --git a/assets/css/app.css b/assets/css/app.css index 5e471c4..ac8a782 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; +} diff --git a/index.php b/index.php index 8833b8a..1fd21f3 100644 --- a/index.php +++ b/index.php @@ -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 实时汇总, // 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。 diff --git a/templates/trips/_node_form.php b/templates/trips/_node_form.php index b96faca..0850d27 100644 --- a/templates/trips/_node_form.php +++ b/templates/trips/_node_form.php @@ -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'); ?>
- + + @@ -39,6 +49,21 @@ $nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'rea + +
+ + + 计划行程走完后可用「修正 / 完成」转成实际行程。 +
+ +
+ +
+
+
+ + + + + +
+ +
+ + + + 默认是计划的时间,改成实际发生的即可。 +
+
+ + +
+
+ + +
+ +
+ +
+ + + +
+
+ +
+
+ + 0): ?> + 之前已记过 次;原计划数据都留着。 + +
+ + \ No newline at end of file diff --git a/templates/trips/show.php b/templates/trips/show.php index 9ced128..9873113 100644 --- a/templates/trips/show.php +++ b/templates/trips/show.php @@ -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')); ?>
@@ -103,32 +110,59 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
- - -
-

段

+ + ['计划行程', '还没走,下面的「修正 / 完成 / 标记未成行」都能用'], + 'actual' => ['实际行程', '已走过 —— 会出现在日历与首页「往年今日」'], + 'skipped' => ['未成行', '这次计划没成行;不再出现在日程与「往年今日」,但在这里保留可查'], +]; +foreach (['plan', 'actual', 'skipped'] as $sg): + $list = $bysStage[$sg]; +?> +
+

+ + 段 +

+

-

+

+ +

    - 过来的,这里给个锚点好定位。 */ ?> -
  1. +
  2. - + + 0): ?> + 已修正 次 + 📍
    -
    +
    + + 实际 +

    @@ -141,29 +175,101 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i + + + +
    + 看修正记录( 条) + + + + + + + + + + + + + + +
    #时间地点说明
    +
    +
    +
    + + +
    + 修正 +
    +

    + 记录**实际**执行情况。这仍与上面那条计划是同一条记录, + 原来的计划数据会保留在「修正记录」里,方便日后对比。 + 点完这里行程仍是计划;确认无误后再点「标记为实际」。 +

    + +
    +
    +
    + + + + +
    +
    + + + + +
    + 0): ?> +
    + + + + +
    + + +
    + + + + +
    + +
    编辑
    + onsubmit="return confirm('**彻底删除**这一段?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达"这次没去成",请用「标记未成行」。')"> - +
    @@ -172,11 +278,11 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
- +
- + 加一段 + + 加一段 @@ -184,3 +290,4 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
+