From 2e51d3f5e476f9e81f8c599ea003eed2638aa5f0 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 5 Oct 2026 13:26:49 +0800 Subject: [PATCH] =?UTF-8?q?v24=20=E7=AC=AC=2016=20=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E6=97=85=E7=A8=8B=20/=20=E8=A1=8C=E7=A8=8B=E4=B8=A4=E7=BA=A7?= =?UTF-8?q?=EF=BC=88=E6=9C=AF=E8=AF=AD=E5=9B=BA=E5=AE=9A=20=C2=B7=20?= =?UTF-8?q?=E9=A2=98=E5=9B=BE16:9=20=C2=B7=20=E4=BA=94=E9=A1=B9=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=20=C2=B7=20=E7=8B=AC=E7=AB=8B=E8=A1=8C=E7=A8=8B?= =?UTF-8?q?=E9=A1=B5=20=C2=B7=20=E8=AE=BE=E7=BD=AE=E4=B8=89=E9=A1=B5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户十条,逐条落地: 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 与线上逐一核对一致。 --- app/Expense.php | 182 +++++++++++++++ app/NodeType.php | 206 ++++++++++++++++ app/Schema.php | 127 +++++++++- app/Trip.php | 236 +++++++++++++++++-- app/Vehicle.php | 198 ++++++++++++++++ assets/css/app.css | 130 ++++++++++- index.php | 286 ++++++++++++++++++++--- templates/trips/_node_form.php | 118 ---------- templates/trips/_settings_menu.php | 53 +++++ templates/trips/form.php | 153 ++++-------- templates/trips/index.php | 30 +-- templates/trips/modes.php | 71 ++++++ templates/trips/node_form.php | 364 +++++++++++++++++++++++++++++ templates/trips/show.php | 359 +++++++++++++++------------- templates/trips/types.php | 78 +++++++ templates/trips/vehicles.php | 130 +++++++++++ 16 files changed, 2265 insertions(+), 456 deletions(-) create mode 100644 app/Expense.php create mode 100644 app/NodeType.php create mode 100644 app/Vehicle.php delete mode 100644 templates/trips/_node_form.php create mode 100644 templates/trips/_settings_menu.php create mode 100644 templates/trips/modes.php create mode 100644 templates/trips/node_form.php create mode 100644 templates/trips/types.php create mode 100644 templates/trips/vehicles.php diff --git a/app/Expense.php b/app/Expense.php new file mode 100644 index 0000000..84662e8 --- /dev/null +++ b/app/Expense.php @@ -0,0 +1,182 @@ + '餐饮', + 'ticket' => '门票', + 'traffic' => '交通', + 'stay' => '住宿', + 'shop' => '购物', + 'other' => '其他', + ]; + + /** 一段行程的花销(按时间正序) */ + public static function ofNode(int $nodeId): array + { + $st = Database::get()->prepare( + 'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color + FROM trip_expenses e + LEFT JOIN persons p ON p.id = e.person_id + WHERE e.node_id = ? ORDER BY e.created_at, e.id' + ); + $st->execute([$nodeId]); + return $st->fetchAll(); + } + + /** + * 一段行程的花销合计(单位:分)。 + * + * ⚠️ COALESCE 不能省:没有任何花销时 SUM 返回 NULL,直接拿去 number_format + * 会在 PHP 8 上报"传 null 给 float 参数"的弃用告警(页面上会冒一行 Warning)。 + */ + public static function sumOfNode(int $nodeId): int + { + $st = Database::get()->prepare( + 'SELECT COALESCE(SUM(amount_cents), 0) FROM trip_expenses WHERE node_id = ?' + ); + $st->execute([$nodeId]); + return (int)$st->fetchColumn(); + } + + /** 一段旅程(活动)的总花销:它下面**所有**行程之和(用户第 2 条) */ + public static function sumOfEvent(int $eventId): int + { + $st = Database::get()->prepare( + 'SELECT COALESCE(SUM(e.amount_cents), 0) + FROM trip_expenses e + JOIN trip_nodes n ON n.id = e.node_id + WHERE n.event_id = ?' + ); + $st->execute([$eventId]); + return (int)$st->fetchColumn(); + } + + /** + * 给一批行程批量补"这一段花了多少"(列表/详情用;N+1 在几十段时很明显)。 + * + * @param array $rows 行程数组(会被就地补上 `spent_cents`) + */ + public static function attachSums(array &$rows): void + { + $ids = []; + foreach ($rows as $r) { + $id = (int)($r['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; + } + } + $sum = []; + foreach ($ids as $id) { + $sum[$id] = 0; + } + if ($ids) { + $in = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT node_id, COALESCE(SUM(amount_cents),0) AS s + FROM trip_expenses WHERE node_id IN ($in) GROUP BY node_id" + ); + $st->execute($ids); + foreach ($st->fetchAll() as $r) { + $sum[(int)$r['node_id']] = (int)$r['s']; + } + } + foreach ($rows as &$r) { + $r['spent_cents'] = $sum[(int)$r['id']] ?? 0; + } + unset($r); + } + + /** @return array{ok:bool,id?:int,error?:string} */ + public static function add(int $familyId, int $nodeId, array $d): array + { + $cents = self::parseAmount((string)($d['amount'] ?? '')); + if ($cents === null) { + return ['ok' => false, 'error' => '金额填得不对(例如 128 或 128.50)']; + } + $title = mb_substr(trim((string)($d['title'] ?? '')), 0, 200, 'UTF-8'); + $cat = isset(self::CATS[(string)($d['category'] ?? '')]) ? (string)$d['category'] : 'other'; + $pid = (int)($d['person_id'] ?? 0); + if ($pid > 0) { + // 只认本家的人(表单可以改,不能凭它把别人家的人在页面上引出来) + $st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?'); + $st->execute([$pid, $familyId]); + if ($st->fetchColumn() === false) { + $pid = 0; + } + } + $db = Database::get(); + $db->prepare( + 'INSERT INTO trip_expenses (family_id, node_id, person_id, amount_cents, category, title) + VALUES (?,?,?,?,?,?)' + )->execute([$familyId, $nodeId, $pid > 0 ? $pid : null, $cents, $cat, + $title !== '' ? $title : null]); + return ['ok' => true, 'id' => (int)$db->lastInsertId()]; + } + + /** 删一笔(校验它确实属于这个家的某段行程) */ + public static function destroy(int $familyId, int $id): bool + { + $st = Database::get()->prepare('SELECT id FROM trip_expenses WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + if ($st->fetchColumn() === false) { + return false; + } + Database::get()->prepare('DELETE FROM trip_expenses WHERE id = ?')->execute([$id]); + return true; + } + + // ---------------- 展示 ---------------- + + /** 分 → 人话(¥1,280 / ¥12.50;金额为零显示 ¥0) */ + public static function money(int $cents): string + { + $neg = $cents < 0; + $v = abs($cents) / 100; + // 整数金额不带小数尾巴(¥1280 比 ¥1,280.00 好读);有角分才显示两位 + $s = (fmod($v, 1.0) === 0.0) ? number_format($v) : number_format($v, 2); + return ($neg ? '-¥' : '¥') . $s; + } + + /** + * 表单里那个金额字符串 → 分。 + * + * ⚠️ 不用 `(float)` 直接乘 100:`128.55 * 100` 在浮点下是 12854.999…, + * `(int)` 一截就变成 12854(差一分钱)。这里走**字符串**拆小数点, + * 整数部分与小数部分分开算,没有浮点参与。 + */ + public static function parseAmount(string $raw): ?int + { + $raw = trim(str_replace([',', ',', '¥', '¥', ' '], '', $raw)); + if ($raw === '') { + return null; + } + if (!preg_match('/^(-?)(\d+)(?:\.(\d{1,2}))?$/', $raw, $m)) { + return null; + } + $yuan = (int)$m[2]; + $cent = isset($m[3]) ? (int)str_pad($m[3], 2, '0') : 0; + $total = $yuan * 100 + $cent; + return $m[1] === '-' ? -$total : $total; + } +} \ No newline at end of file diff --git a/app/NodeType.php b/app/NodeType.php new file mode 100644 index 0000000..31b755a --- /dev/null +++ b/app/NodeType.php @@ -0,0 +1,206 @@ +` 这种形态,与内置的英文 key 不会撞。 + */ + +declare(strict_types=1); + +namespace App; + +final class NodeType +{ + /** 内置类型:key => [名字, 图标](与 Trip::NODE_TYPES 保持一致,是初始值) */ + public const BUILTIN = [ + 'play' => ['游玩', '🎡'], + 'meal' => ['用餐', '🍜'], + 'move' => ['赶路', '🚗'], + 'stay' => ['住宿', '🏨'], + 'other' => ['其他', '📌'], + ]; + + /** + * 一个家的全部类型(表里没有就先把内置的预置进去)。 + * + * ⚠️ 预置放在**读**里而不是只放在安装/升级里:升级步骤是容错的 + * (`tryStep`),万一那一步在某个老库上失败了,这里还能自愈 —— + * 否则"类型"是新建行程的必填项,缺了它整个"添加行程"都用不了。 + */ + public static function all(int $familyId): array + { + self::seedDefaults($familyId); + $st = Database::get()->prepare( + 'SELECT id, name, icon, is_system, position FROM node_types + WHERE family_id = ? ORDER BY position, id' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + /** + * 把内置类型补进表里(幂等:按 (family_id, name) 唯一键查过再插)。 + * + * @return int 补了几条 + */ + public static function seedDefaults(int $familyId): int + { + $db = Database::get(); + // 表可能还不存在(老库升级跑到这一步之前 / 极少数失败情形)→ 直接返回, + // 别让"读列表"这个动作本身抛异常把页面炸掉。 + try { + $n = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = ' + . (int)$familyId)->fetchColumn(); + $has = $n; + } catch (\Throwable $e) { + return 0; + } + if ($has >= count(self::BUILTIN)) { + return 0; // 已经有内置那几条了,不必逐个查(热点路径) + } + $hasAny = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = ' + . (int)$familyId)->fetchColumn(); + // ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个), + // 每次读都补回去会让人永远删不掉系统内置的那几种。 + if ($hasAny > 0) { + return 0; + } + $st = $db->prepare( + 'INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,1,?)' + ); + $pos = 10; + $n = 0; + foreach (self::BUILTIN as $k => $v) { + $st->execute([$familyId, $v[0], $v[1], $pos]); + $pos += 10; + $n++; + } + return $n; + } + + public static function find(int $familyId, int $id): ?array + { + $st = Database::get()->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + $r = $st->fetch(); + return $r ?: null; + } + + /** 同名判重(返回已存在的 id,没有则 0) */ + public static function findByName(int $familyId, string $name): int + { + $st = Database::get()->prepare( + 'SELECT id FROM node_types WHERE family_id = ? AND name = ? LIMIT 1' + ); + $st->execute([$familyId, trim($name)]); + return (int)($st->fetchColumn() ?: 0); + } + + /** @return array{ok:bool,id?:int,error?:string} */ + public static function save(int $familyId, array $d, ?int $id = null): array + { + $name = mb_substr(trim((string)($d['name'] ?? '')), 0, 30, 'UTF-8'); + if ($name === '') { + return ['ok' => false, 'error' => '类型名字不能为空']; + } + $icon = mb_substr(trim((string)($d['icon'] ?? '')), 0, 20, 'UTF-8'); + $db = Database::get(); + $dup = self::findByName($familyId, $name); + if ($dup > 0 && $dup !== $id) { + return ['ok' => false, 'error' => '已经有一个叫「' . $name . '」的类型了']; + } + if ($id !== null && $id > 0) { + $old = self::find($familyId, $id); + if (!$old) { + return ['ok' => false, 'error' => '找不到这个类型']; + } + $oldName = (string)$old['name']; + $db->prepare('UPDATE node_types SET name=?, icon=? WHERE id=? AND family_id=?') + ->execute([$name, $icon !== '' ? $icon : null, $id, $familyId]); + // ⚠️ 改名必须**级联**到已经在用它的行程:`trip_nodes.node_type` 存的是 + // **名字**(不是 id)。只改类型表的话,所有老行程都会变成 + // "一个类型表里不存在的值" → 图标与名称显示双双落回"其他", + // 用户看到的是"我一改名,历史行程的类型全没了"。 + if ($oldName !== $name) { + $db->prepare('UPDATE trip_nodes SET node_type = ? WHERE family_id = ? AND node_type = ?') + ->execute([$name, $familyId, $oldName]); + } + return ['ok' => true, 'id' => $id]; + } + $pos = (int)($db->query('SELECT COALESCE(MAX(position),0)+10 FROM node_types WHERE family_id = ' + . (int)$familyId)->fetchColumn()); + $db->prepare('INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,0,?)') + ->execute([$familyId, $name, $icon !== '' ? $icon : null, $pos]); + return ['ok' => true, 'id' => (int)$db->lastInsertId()]; + } + + /** + * 删一个类型。 + * + * ⚠️ 内置的**不许删**:它们被 `node_type` 的兜底逻辑引用(表空时会重新预置), + * 删了会立刻又冒出来,看起来像"删除没生效"。要停用请改名或忽略。 + * ⚠️ 还在被行程使用的也不许删:行程里存的是**名字**(见 usedCount), + * 删了类型定义,那些行程的显示就会落回"其他"—— 数据没丢,但用户会以为丢了。 + * 所以这里给出"还有 N 段行程在用"的明确理由,而不是静默失败。 + */ + public static function destroy(int $familyId, int $id): array + { + $t = self::find($familyId, $id); + if (!$t) { + return ['ok' => false, 'error' => '找不到这个类型']; + } + if ((int)$t['is_system'] === 1) { + return ['ok' => false, 'error' => '内置类型不能删除(可以改名)']; + } + $n = self::usedCount($familyId, (string)$t['name']); + if ($n > 0) { + return ['ok' => false, 'error' => '还有 ' . $n . ' 段行程在用这个类型,先改掉它们再删']; + } + Database::get()->prepare('DELETE FROM node_types WHERE id = ? AND family_id = ?') + ->execute([$id, $familyId]); + return ['ok' => true]; + } + + /** 有多少段行程在用这个类型名 */ + public static function usedCount(int $familyId, string $name): int + { + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM trip_nodes WHERE family_id = ? AND node_type = ?' + ); + $st->execute([$familyId, $name]); + return (int)$st->fetchColumn(); + } + + /** + * 类型名 → 图标。行程里存的是名字,图标随类型定义走。 + * 认不出(类型被删/老数据是英文 key)时返回内置兜底。 + */ + public static function iconOf(int $familyId, string $name): string + { + static $cache = []; + if (!isset($cache[$familyId])) { + $map = []; + foreach (self::all($familyId) as $t) { + $map[(string)$t['name']] = (string)($t['icon'] ?? ''); + } + $cache[$familyId] = $map; + } + $icon = $cache[$familyId][$name] ?? ''; + if ($icon !== '') { + return $icon; + } + // 老数据用的是英文 key(play/move/…)→ 回落到内置那张表 + return self::BUILTIN[$name][1] ?? '📌'; + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index 95fe25b..9dfcb32 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -80,7 +80,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '20'; + public const VERSION = '21'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -730,6 +730,92 @@ CREATE TABLE IF NOT EXISTS `book_notes` ( CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读书笔记 / 划线 / 书评(按人记)' +SQL, + + // ---- 车辆库(版本 21,用户 2026-10-05 第 16 批第 8 条)---- + // 用户原话:「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。 + // 注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复 + // 添加那里面的车辆,直接读取就可以,但是可以添加其他的车辆, + // 比如租的车,借别人的车。」 + // + // ⚠️ 车辆有**两个来源**,这里刻意**不合并**: + // ① **财务里的车子**(ledger_entries.kind='car' 的账目)—— 那是"资产", + // 记的是这台车值多少钱,不是"出行时可挑的一辆车"(没有车牌、没有车型名)。 + // 这里**读它**、在选车时列出来(只读引用),但绝不复制一份到本表 —— + // 复制就变成同一台车两处维护,改了财务这边不同步。 + // ② **本表** —— "出行时会用到的车",重点是车牌 + 车型名 + 是租的还是借的。 + // 自家车在这里也**可以**录一份(干净、可挑),但不强制: + // 不录也能出行(只是选车时没得挑而已)。 + // 所以"租的车 / 借别人的车"是本表的常客,`vehicle_type` 才需要 rental/borrowed + // 这两种;自家车与财务的对齐靠**车型名 + 车牌**人工对上,不做外键硬绑 + // (账目随时可能被删/改名,硬绑会让车辆跟着消失)。 + <<<'SQL' +CREATE TABLE IF NOT EXISTS `vehicles` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `plate` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '车牌号(借的车/租的车可能没记全,允许空)', + `name` VARCHAR(100) NOT NULL COMMENT '车型名,如「大众朗逸」「丰田卡罗拉」', + `vtype` ENUM('own','rental','borrowed') NOT NULL DEFAULT 'own' + COMMENT 'own=自家的 / rental=租的 / borrowed=借别人的', + `vendor` VARCHAR(100) DEFAULT NULL COMMENT '租车公司(vtype=rental 时)或车主(borrowed 时)', + `order_no` VARCHAR(50) DEFAULT NULL COMMENT '租车订单号', + `ledger_id` INT UNSIGNED DEFAULT NULL COMMENT '关联财务里那条 kind=car 的账目(软关联,可空;账目删了这里只是记不住,不影响车辆)', + `is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '默认车(新建行程选车时默认选中它)', + `note` VARCHAR(500) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_veh_family` (`family_id`, `vtype`), + CONSTRAINT `fk_veh_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出行用的车辆(自家 / 租的 / 借的);财务里的车子是资产,不复制进来' +SQL, + + // ---- 行程节点类型(版本 21,第 16 批第 8 条 + TripLog 同款)---- + // 用户第 7 条要求「照 TripLog 的添加节点功能」,TripLog 的节点类型 + // 就是一张**可自定义**的表(游玩/用餐/赶路/休息/突发…),不是写死的枚举。 + // ⚠️ 与旧代码里那个 `Trip::NODE_TYPES` 常量并存一段:常量当**兜底** + // (老库/新装库都要有初始值),表是"用户改过的真相"。 + // 读取一律 `NodeType::all()`(表空时自动回落到常量并预置进表)。 + <<<'SQL' +CREATE TABLE IF NOT EXISTS `node_types` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(30) NOT NULL COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 休息 / 突发…', + `icon` VARCHAR(20) DEFAULT NULL COMMENT '一个表情符号', + `is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '内置类型(预置的那几种),不允许删', + `position` INT NOT NULL DEFAULT 100, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_nt_family_name` (`family_id`, `name`), + CONSTRAINT `fk_nt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点类型库(可自定义)' +SQL, + + // ---- 行程花销(版本 21,第 16 批第 2/7 条)---- + // 用户第 2 条要"一共花了多少钱",第 7 条要求照 TripLog 的节点功能, + // 而 TripLog 的花销是**挂在节点上**的一笔一笔(谁付的 / 多少 / 什么类别)。 + // ⚠️ 用户明确"行程级花销记录"(本次确认),所以: + // · 一行 = 一段行程里的一笔花销; + // · 旅程的总花销 = 它下面所有行程的花销之和(读时算,不存冗余)。 + // ⚠️ 不做 `expense_splits`(分摊):家庭出行记"一共花了多少"就够, + // 谁欠谁那是财务模块的事,硬塞进旅行里会让记账变成负担。 + <<<'SQL' +CREATE TABLE IF NOT EXISTS `trip_expenses` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `node_id` INT UNSIGNED NOT NULL COMMENT '属于哪一段行程', + `person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁付的钱(可空)', + `amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额,单位**分**(与财务同一口径,避免浮点误差)', + `currency` VARCHAR(10) NOT NULL DEFAULT 'CNY', + `category` VARCHAR(30) DEFAULT NULL COMMENT '餐饮 / 门票 / 交通 / 购物 / 其他', + `title` VARCHAR(200) DEFAULT NULL COMMENT '消费说明', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_te_node` (`node_id`), + KEY `idx_te_family` (`family_id`), + CONSTRAINT `fk_te_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_te_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_te_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)' SQL, ]; } @@ -981,6 +1067,45 @@ SQL, } }); + // ---- v24 第 16 批:行程对齐 TripLog(Schema v21)---- + // 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」 + // TripLog 的节点比 FamilyBook 现有的"行程"多这些: + // 出行方式(走路/开车/高铁/飞机…)+ 关联车辆、车次/航班号、座位/车厢、 + // 登机口/站台、起终点位置(带坐标)、线路名/承运公司。 + // 这些一律**补列**到 trip_nodes 上(不是另建卫星表): + // ① 它们与行程是 1:1(一段行程一种出行方式),卫星表只是多一次 JOIN; + // ② 旅行模块里"这一段怎么到的"是**必看信息**,不是可有可无的附加物。 + self::addColumn($db, 'trip_nodes', 'travel_mode', + "ADD COLUMN `travel_mode` VARCHAR(20) DEFAULT NULL COMMENT '出行方式:walk/drive/bus/subway/train/plane/ship/taxi/bike/other' AFTER `place`"); + self::addColumn($db, 'trip_nodes', 'vehicle_id', + "ADD COLUMN `vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT '关联车辆(仅自驾:自家/租的/借的)' AFTER `travel_mode`"); + self::addColumn($db, 'trip_nodes', 'line_name', + "ADD COLUMN `line_name` VARCHAR(100) DEFAULT NULL COMMENT '线路名,如「京沪高铁」「地铁 3 号线」' AFTER `vehicle_id`"); + self::addColumn($db, 'trip_nodes', 'carrier', + "ADD COLUMN `carrier` VARCHAR(100) DEFAULT NULL COMMENT '承运方,如「中国铁路」「滴滴」' AFTER `line_name`"); + self::addColumn($db, 'trip_nodes', 'trip_no', + "ADD COLUMN `trip_no` VARCHAR(50) DEFAULT NULL COMMENT '车次 / 航班号 / 线路号' AFTER `carrier`"); + self::addColumn($db, 'trip_nodes', 'seat_info', + "ADD COLUMN `seat_info` VARCHAR(100) DEFAULT NULL COMMENT '座位号 / 车厢' AFTER `trip_no`"); + self::addColumn($db, 'trip_nodes', 'gate', + "ADD COLUMN `gate` VARCHAR(20) DEFAULT NULL COMMENT '登机口 / 站台' AFTER `seat_info`"); + self::addColumn($db, 'trip_nodes', 'from_place', + "ADD COLUMN `from_place` VARCHAR(200) DEFAULT NULL COMMENT '出发地(赶路段的起点)' AFTER `gate`"); + self::addColumn($db, 'trip_nodes', 'to_place', + "ADD COLUMN `to_place` VARCHAR(200) DEFAULT NULL COMMENT '到达地(赶路段的终点)' AFTER `from_place`"); + // 车型/车次这类字段只在"赶路"时有意义,但**不做条件列**(列固定、界面按类型显隐), + // 免得"换个出行方式就把填过的车次弄丢"。 + self::addColumn($db, 'trip_node_members', 'role_tag', + "ADD COLUMN `role_tag` VARCHAR(30) DEFAULT NULL COMMENT '角色标签,如「司机」「摄影担当」' AFTER `person_id`"); + // 车辆库三张表在 statements() 里(IF NOT EXISTS 幂等),老库由那条统一建。 + // 但**预置节点类型**要显式跑一次:老库建完表是空的,界面上会一个类型都没有 + // ("类型"是新建行程的必填项 → 直接卡死,人以为功能坏了)。 + self::tryStep('预置行程节点类型', static function () use ($db) { + foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { + \App\NodeType::seedDefaults((int)$fam['id']); + } + }); + // ---- v24 第 13 批:旅程模块重构(Schema v19)---- // ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加) self::addColumn($db, 'trip_nodes', 'stage', diff --git a/app/Trip.php b/app/Trip.php index fbbc8c3..b67f8d7 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -41,6 +41,32 @@ final class Trip 'cancelled' => '已取消', ]; + /** + * 出行方式(v24 第 16 批 第 7 条,照 TripLog 的 travel_modes)。 + * + * ⚠️ 这里存的是**枚举 key**(不是 TripLog 那种另一张可配置的表): + * 出行方式是**固定的物理事实**(走路/开车/高铁/飞机…), + * 不像"节点类型"那样因人而异 —— 没人需要自定义一种"新的交通方式"。 + * 用户第 8 条要的"出行方式设置",实际要设的是**每种方式下的常用项** + * (比如自己常坐的车次/常用航空公司),那是"记忆上次填的"而不是"新增方式", + * 见 Setting 里那几条 last_* 记忆。所以这里用常量,界面那页做的是"提示 + 记忆"。 + */ + public const TRAVEL_MODES = [ + 'walk' => ['步行', '🚶'], + 'bike' => ['骑行', '🚲'], + 'drive' => ['自驾', '🚗'], + 'taxi' => ['打车', '🚕'], + 'bus' => ['公交', '🚌'], + 'subway' => ['地铁', '🚇'], + 'train' => ['火车', '🚄'], + 'plane' => ['飞机', '✈️'], + 'ship' => ['轮船', '🚢'], + 'other' => ['其他', '🧭'], + ]; + + /** 自驾类(这两种才需要选车辆) */ + public const DRIVE_MODES = ['drive', 'taxi']; + /** * 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。 * @@ -452,7 +478,15 @@ final class Trip ]); $id = (int)$db->lastInsertId(); self::saveDetails($id, $d); - self::saveAttendees($id, $d['attendees'] ?? []); + // ⚠️ 只有**真的传了** attendees 才动这份名单。 + // v24 第 16 批 用户第 10 条之后,旅程表单里已经没有"同行的人"字段了 —— + // 如果这里写成 `$d['attendees'] ?? []`,每次编辑旅程都会把 + // **活动级**的老名单整个清空(老数据里很多人的参与人只登记在这一层, + // 那时还没有行程级参与人)→ 用户看到的是"我一编辑,参与人全没了"。 + // 新数据的参与人从行程里长出来(tripPeople 的合集口径),不受影响。 + if (array_key_exists('attendees', $d)) { + self::saveAttendees($id, (array)$d['attendees']); + } return $id; } @@ -471,7 +505,10 @@ final class Trip $id, $familyId, ]); self::saveDetails($id, $d); - self::saveAttendees($id, $d['attendees'] ?? []); + // ⚠️ 同 create:没传就不动(见上面那段说明) + if (array_key_exists('attendees', $d)) { + self::saveAttendees($id, (array)$d['attendees']); + } } private static function saveDetails(int $eventId, array $d): void @@ -561,6 +598,8 @@ final class Trip $n['attachs'] = $attach[(int)$n['id']] ?? []; } unset($n); + // 每段花了多少(用户第 2 条:总花销 = 各段之和;这里是各段的那个"段值") + \App\Expense::attachSums($rows); return $rows; } @@ -572,6 +611,21 @@ final class Trip return $r ?: null; } + /** + * 保存一段行程(新增 / 编辑)。 + * + * v24 第 16 批 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的 + * 添加节点功能,跟那个一样」→ 字段**全量照搬**: + * 类型 / 标题 / 起止时间 / 地点 / 出行方式 + 车辆 / 线路名 / 承运方 / + * 车次航班号 / 座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(+角色)/ 照片。 + * (花销走 Expense 单独增删,不在这张表单里一并提交:一笔一笔加更符合实际, + * 也避免"提交一次行程就要把整串花销重算一遍"。) + * + * ⚠️ 交通类字段(车次/座位/登机口…)**不做条件列**:用户可能先填了高铁车次, + * 后来把出行方式改成自驾 —— 那时按逻辑该清空车次,但"清空"意味着丢数据。 + * 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 + + * 该出行方式相关的),这样换个方式再换回来,填过的东西还在。 + */ public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int { $db = Database::get(); @@ -582,35 +636,79 @@ final class Trip // ⚠️ category 只作**兼容保留**(老代码/导出还会读它),业务一律看 stage。 // 这里同步维护,保证两边不打架:stage=actual → reality,其余 → plan。 $category = $stage === 'actual' ? 'reality' : 'plan'; + // 车辆:只认本家的(负数/0 是"来自财务的引用",那不是本表的行,存不进去) + $vid = (int)($d['vehicle_id'] ?? 0); + if ($vid > 0 && !\App\Vehicle::find($familyId, $vid)) { + $vid = 0; + } $fields = [ self::textOrNull2($d['title'] ?? ''), self::textOrNull($d['detail'] ?? ''), self::textOrNull($d['place'] ?? ''), self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), - in_array($d['node_type'] ?? '', array_keys(self::NODE_TYPES), true) ? $d['node_type'] : 'other', + self::nodeType($familyId, $d['node_type'] ?? ''), in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned', $category, (int)($d['position'] ?? 100), $stage, + // ---- v21 新增:交通与起终点(TripLog 同款)---- + isset(self::TRAVEL_MODES[(string)($d['travel_mode'] ?? '')]) ? (string)$d['travel_mode'] : null, + $vid > 0 ? $vid : null, + self::textOrNull2($d['line_name'] ?? ''), + self::textOrNull2($d['carrier'] ?? ''), + self::textOrNull($d['trip_no'] ?? ''), + self::textOrNull($d['seat_info'] ?? ''), + self::textOrNull($d['gate'] ?? ''), + self::textOrNull2($d['from_place'] ?? ''), + self::textOrNull2($d['to_place'] ?? ''), ]; + $cols = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?, + category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?, + carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?'; if ($id !== null && $id > 0) { - $db->prepare( - 'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?, - node_type=?, status=?, category=?, position=?, stage=? WHERE id=?' - )->execute([...$fields, $id]); + $db->prepare('UPDATE trip_nodes SET ' . $cols . ' WHERE id=?') + ->execute([...$fields, $id]); $nid = $id; } else { $db->prepare( 'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at, - node_type, status, category, position, stage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)' + node_type, status, category, position, stage, travel_mode, vehicle_id, line_name, + carrier, trip_no, seat_info, gate, from_place, to_place) + VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')' )->execute([$familyId, $eventId, ...$fields]); $nid = (int)$db->lastInsertId(); } - self::saveNodeMembers($nid, $d['members'] ?? []); + self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []); return $nid; } + /** + * 行程类型:存**名字**(可自定义的类型表里那个 name),认不出退回 'other'。 + * + * ⚠️ 为什么存名字而不是 id:用户把类型改名时要级联到所有行程 + * (NodeType::save 里做了),存名字让"改名"是一句 UPDATE; + * 存 id 则要在每次显示时 JOIN,而且删除类型会让老行程显示成"未知"。 + * 兼容老数据:英文 key(play/move/…)原样保留 —— NodeType::iconOf 认得它。 + */ + private static function nodeType(int $familyId, string $v): string + { + $v = trim($v); + if ($v === '') { + return 'other'; + } + if (isset(self::NODE_TYPES[$v])) { + return $v; // 老数据的英文 key + } + // 是否是本家定义过的类型名(含刚预置的那几种) + foreach (\App\NodeType::all($familyId) as $t) { + if ((string)$t['name'] === $v) { + return $v; + } + } + return 'other'; + } + // ---------------- 计划行程的三个动作(v24 第 13 批) ---------------- // // 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作」 @@ -780,7 +878,8 @@ final class Trip public static function nodeMembersFor(int $nodeId, int $viewerId): array { $st = Database::get()->prepare( - 'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + 'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname, + m.role_tag FROM trip_node_members m JOIN persons p ON p.id = m.person_id LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE m.node_id = ? ORDER BY p.id' @@ -789,17 +888,79 @@ final class Trip return $st->fetchAll(); } - public static function saveNodeMembers(int $nodeId, array $personIds): void + /** + * 存一段行程的参与人(整组替换)。 + * + * @param array $roles 可选:person_id => 角色标签(TripLog 的"司机/摄影担当"); + * 不传就是没有角色,与老调用完全兼容。 + */ + public static function saveNodeMembers(int $nodeId, array $personIds, array $roles = []): void { $db = Database::get(); $db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]); - $ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id) VALUES (?,?)'); + $ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag) + VALUES (?,?,?)'); foreach ($personIds as $pid) { $pid = (int)$pid; - if ($pid > 0) { - $ins->execute([$nodeId, $pid]); + if ($pid <= 0) { + continue; + } + $role = mb_substr(trim((string)($roles[$pid] ?? $roles[(string)$pid] ?? '')), 0, 30, 'UTF-8'); + $ins->execute([$nodeId, $pid, $role !== '' ? $role : null]); + } + } + + /** + * 一段旅程(活动)的**汇总信息**(v24 第 16 批 用户第 2 条)。 + * + * 用户原话:「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、 + * 一共几段行程,一共花了多少钱,这些信息,都从该活动下的所有行程 + * 统计总结。」 + * + * 五项全是从行程**算出来**的(与"时间/城市派生"同一条原则,不存冗余): + * · people —— 各段行程参与人的并集(就是 tripPeople,已有口径) + * · cities —— 各段行程去过的地点去重(未成行段不算) + * · days —— 从最早一段的开始 到 最晚一段的结束,共几天(含首尾; + * 跨天算天,同一天往返算 1 天) + * · nodes —— 行程段数(**只数会显示的**:计划 + 实际;未成行的单独给一个数) + * · cents —— 各段行程花销之和 + * + * ⚠️ days 的算法要说清楚,否则"3 天"到底是几天会各人一套算法: + * 按**日历天**算(8/1 14:00 出发、8/3 10:00 回来 = 3 天), + * 不是按 24 小时整除(那样会算成 2 天,与人的直觉不符)。 + */ + public static function summary(int $eventId, int $viewerId = 0): array + { + $st = Database::get()->prepare( + "SELECT MIN(starts_at) AS start_at, + MAX(COALESCE(ends_at, starts_at)) AS end_at, + SUM(CASE WHEN stage IN ('plan','actual') THEN 1 ELSE 0 END) AS shown_n, + SUM(CASE WHEN stage = 'skipped' THEN 1 ELSE 0 END) AS skipped_n + FROM trip_nodes WHERE event_id = ?" + ); + $st->execute([$eventId]); + $r = $st->fetch() ?: []; + + $days = 0; + $start = (string)($r['start_at'] ?? ''); + $end = (string)($r['end_at'] ?? ''); + if ($start !== '') { + $a = strtotime(substr($start, 0, 10)); + $b = strtotime(substr($end !== '' ? $end : $start, 0, 10)); + if ($a !== false && $b !== false && $b >= $a) { + // 含首尾:8/1 → 8/3 是 3 天(floor(差/86400) + 1) + $days = (int)floor(($b - $a) / 86400) + 1; } } + return [ + 'people' => self::tripPeople($eventId, $viewerId), + 'cities' => self::cities($eventId), + 'days' => $days, + 'nodes' => (int)($r['shown_n'] ?? 0), + 'skipped' => (int)($r['skipped_n'] ?? 0), + 'cents' => \App\Expense::sumOfEvent($eventId), + 'spent' => \App\Expense::money(\App\Expense::sumOfEvent($eventId)), + ]; } // ---------------- 展示 ---------------- @@ -844,16 +1005,57 @@ final class Trip return $s . ' – ' . date('Y年n月j日', $te); } - public static function typeWord(string $key): string + /** + * 行程类型的**名字**。 + * + * ⚠️ 两种取值来源,顺序不能反: + * ① 用户在设置里定义的类型表(`NodeType::all()`,按名字存进 trip_nodes)—— + * 这是"用户改过的真相",优先级最高; + * ② 老数据的英文 key(play/move/…)→ 内置常量表。 + * 少了 ① 就会把用户自定义的类型名显示成"其他"(看起来像类型丢了)。 + * + * @param int $familyId 传 0 表示"只按内置表解析"(没有家庭上下文时用) + */ + public static function typeWord(string $key, int $familyId = 0): string { - return self::NODE_TYPES[$key][0] ?? '其他'; + if (isset(self::NODE_TYPES[$key])) { + return self::NODE_TYPES[$key][0]; + } + if ($familyId > 0) { + foreach (\App\NodeType::all($familyId) as $t) { + if ((string)$t['name'] === $key) { + return $key; + } + } + } + return $key !== '' ? $key : '其他'; } - public static function typeIcon(string $key): string + public static function typeIcon(string $key, int $familyId = 0): string { + if ($familyId > 0) { + return \App\NodeType::iconOf($familyId, $key); + } return self::NODE_TYPES[$key][1] ?? '📌'; } + /** 出行方式的名字 / 图标 */ + public static function modeWord(string $key): string + { + return self::TRAVEL_MODES[$key][0] ?? ''; + } + + public static function modeIcon(string $key): string + { + return self::TRAVEL_MODES[$key][1] ?? '🧭'; + } + + /** 这个出行方式要不要选车辆 */ + public static function modeNeedsVehicle(string $key): bool + { + return in_array($key, self::DRIVE_MODES, true); + } + public static function statusWord(string $key): string { return self::NODE_STATUS[$key] ?? '计划中'; diff --git a/app/Vehicle.php b/app/Vehicle.php new file mode 100644 index 0000000..cadf4a3 --- /dev/null +++ b/app/Vehicle.php @@ -0,0 +1,198 @@ + '自家的', + 'rental' => '租的', + 'borrowed' => '借的', + ]; + + /** 一个家的车(vehicles 表里的) */ + public static function all(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT * FROM vehicles WHERE family_id = ? + ORDER BY is_default DESC, vtype, id' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + /** + * 财务里已登记的车子(**只读引用**,不复制进 vehicles)。 + * + * 取的是所有账本里 kind='car' 的账目 —— 用户说"直接读取就可以"。 + * 返回结构与自有车辆**同形**(id 用负数区分:`-账目id`,`from` 标来源), + * 这样模板里画下拉、画配置列表都能用同一套代码。 + */ + public static function fromLedger(int $familyId): array + { + // ⚠️ 表可能还不存在(极老库 / 升级中途)→ 宽容返回空,别把"选车"整块炸掉 + try { + $st = Database::get()->prepare( + "SELECT e.id, e.title, e.note FROM ledger_entries e + JOIN ledgers l ON l.id = e.ledger_id + WHERE l.family_id = ? AND e.kind = 'car' + ORDER BY e.id" + ); + $st->execute([$familyId]); + } catch (\Throwable $ex) { + return []; + } + $out = []; + foreach ($st->fetchAll() as $r) { + $out[] = [ + // 负数 id:与 vehicles 表的正数 id 天然不撞;选车表单提交负数即"来自财务" + 'id' => -((int)$r['id']), + 'ledger_id' => (int)$r['id'], + 'name' => (string)$r['title'], + 'plate' => '', + 'vtype' => 'own', + 'vendor' => null, + 'order_no' => null, + 'is_default'=> 0, + 'note' => (string)($r['note'] ?? ''), + 'from' => 'ledger', // 模板据此标「来自财务」并且不给改/删 + ]; + } + return $out; + } + + /** + * 选车下拉用的合并列表:财务的车子在前(用户已经录好了),自家车辆在后。 + * + * @return array 每项都带 `from` = 'ledger' | 'local' + */ + public static function pickable(int $familyId): array + { + $out = []; + foreach (self::fromLedger($familyId) as $v) { + $out[] = $v; + } + foreach (self::all($familyId) as $v) { + $v['from'] = 'local'; + $out[] = $v; + } + return $out; + } + + public static function find(int $familyId, int $id): ?array + { + if ($id <= 0) { + return null; // 负数/0 是"来自财务的引用",不是本表的行 + } + $st = Database::get()->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + $r = $st->fetch(); + return $r ?: null; + } + + /** 「走到哪都显示得出来」的一行字:车牌 + 车型 */ + public static function label(array $v): string + { + $plate = trim((string)($v['plate'] ?? '')); + $name = trim((string)($v['name'] ?? '')); + if ($plate !== '' && $name !== '') { + return $plate . ' ' . $name; + } + return $plate !== '' ? $plate : $name; + } + + /** @return array{ok:bool,id?:int,error?:string} */ + public static function save(int $familyId, array $d, ?int $id = null): array + { + $name = mb_substr(trim((string)($d['name'] ?? '')), 0, 100, 'UTF-8'); + if ($name === '') { + return ['ok' => false, 'error' => '车型名不能为空']; + } + $vtype = isset(self::TYPES[(string)($d['vtype'] ?? '')]) ? (string)$d['vtype'] : 'own'; + $row = [ + mb_substr(trim((string)($d['plate'] ?? '')), 0, 20, 'UTF-8'), + $name, + $vtype, + self::txt($d['vendor'] ?? '', 100), + self::txt($d['order_no'] ?? '', 50), + (int)($d['is_default'] ?? 0) > 0 ? 1 : 0, + self::txt($d['note'] ?? '', 500), + ]; + $db = Database::get(); + // ⚠️ 默认车**只能有一台**:把别的都清掉再设,否则"默认"就失去意义 + // (新建行程时默认选中的是哪一台,取决于 SQL 的排序运气)。 + if ($row[5] === 1) { + $db->prepare('UPDATE vehicles SET is_default = 0 WHERE family_id = ?')->execute([$familyId]); + } + if ($id !== null && $id > 0) { + if (!self::find($familyId, $id)) { + return ['ok' => false, 'error' => '找不到这辆车']; + } + $db->prepare('UPDATE vehicles SET plate=?, name=?, vtype=?, vendor=?, order_no=?, + is_default=?, note=? WHERE id=? AND family_id=?') + ->execute([...$row, $id, $familyId]); + return ['ok' => true, 'id' => $id]; + } + $db->prepare('INSERT INTO vehicles (family_id, plate, name, vtype, vendor, order_no, + is_default, note) VALUES (?,?,?,?,?,?,?,?)') + ->execute([$familyId, ...$row]); + return ['ok' => true, 'id' => (int)$db->lastInsertId()]; + } + + /** + * 删一辆车。 + * + * ⚠️ 只用**本表**的行能被删(负数 id 是财务的引用,删它等于动财务数据 —— 不干)。 + * ⚠️ 已被行程引用的车不许删:删了行程上的 `vehicle_id` 就成了悬空值 + * (外键是 SET NULL,数据不丢,但用户会看到"我明明选过车,现在没了")。 + * 给一句"还有 N 段行程在用",比静默清空好。 + */ + public static function destroy(int $familyId, int $id): array + { + if ($id <= 0) { + return ['ok' => false, 'error' => '来自财务的车子请到财务里改']; + } + if (!self::find($familyId, $id)) { + return ['ok' => false, 'error' => '找不到这辆车']; + } + $st = Database::get()->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?'); + $st->execute([$id]); + $n = (int)$st->fetchColumn(); + if ($n > 0) { + return ['ok' => false, 'error' => '还有 ' . $n . ' 段行程在用这辆车,先改掉它们再删']; + } + Database::get()->prepare('DELETE FROM vehicles WHERE id = ? AND family_id = ?') + ->execute([$id, $familyId]); + return ['ok' => true]; + } + + private static function txt($v, int $max): ?string + { + $v = mb_substr(trim((string)$v), 0, $max, 'UTF-8'); + return $v !== '' ? $v : null; + } +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 5cf0ab4..2dc8801 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -2345,8 +2345,12 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .trip-people { display: flex; flex-wrap: wrap; gap: 4px 8px; } .trip-hero { display: flex; gap: 20px; flex-wrap: wrap; } +/* ⚠️ v24 第 16 批 用户第 1 条:「题图应该按照 16:9 显示,因为添加的时候, + 是按照这个比例裁剪的,现在的显示比例不对」—— 原来是 4:3 把 16:9 的图裁掉了两边。 + 这个数字必须与 ImageSlot 的 'cover' 槽位比例**一致**(部署断言专门比对两处)。 */ .trip-hero-cover { - width: 260px; max-width: 100%; aspect-ratio: 4 / 3; flex: 0 0 auto; border-radius: var(--radius-s); + width: 420px; max-width: 100%; aspect-ratio: 16 / 9; flex: 0 0 auto; + border-radius: var(--radius-s); overflow: hidden; background: #F1E7D6; display: flex; align-items: center; justify-content: center; } .trip-hero-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } @@ -3207,3 +3211,127 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .more-set .form-grid { padding: 14px; margin: 0; } /* 折叠区里的字段少一点内边距,看起来是"附带的"而不是主流程 */ .more-set .field { margin-bottom: 0; } + +/* ============================================================ + v24 第 16 批:旅程(容器)/ 行程(单元)两级 + 术语固定:旅程包含行程(用户第 9 条)。 + 新增:题图 16:9、五项统计、行程独立页、设置三页、花销。 + ============================================================ */ + +/* ---- 题图右侧的信息区:时间 + 城市 ---- */ +.trip-when { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; + font-size: 14px; color: var(--muted); +} +.trip-when-date { font-weight: 600; color: var(--ink); } +.trip-when-cities { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 5px; } +.city-chip { + padding: 1px 9px; border-radius: 999px; + background: var(--line-2); color: var(--brand-ink); font-size: 12.5px; +} +.city-sep { color: var(--faint); } + +/* ---- 五项统计(用户第 2 条)---- */ +.trip-stats { + display: flex; flex-wrap: wrap; gap: 10px 26px; + padding: 10px 0; border-top: 1px solid var(--line-2); border-bottom: 1px solid var(--line-2); +} +.ts-item { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; } +.ts-n { + font-size: 19px; font-weight: 700; color: var(--brand); + font-variant-numeric: tabular-nums; line-height: 1.2; +} +.ts-l { font-size: 12px; color: var(--muted); } +.trip-sum { margin: 0; } + +/* ---- 行程行上的新元素:出行方式 / 交通细节 / 起终点 / 花销 / 角色 ---- */ +.node-mode { font-size: 12px; color: var(--muted); white-space: nowrap; } +.node-traffic { display: flex; flex-wrap: wrap; gap: 5px; } +.node-traffic .ntag { + font-size: 11.5px; padding: 1px 8px; border-radius: 5px; + background: var(--line-2); color: var(--muted); +} +.node-route-line { font-size: 12.5px; color: var(--muted); } +.node-route-line .arrow { color: var(--faint); margin: 0 3px; } +.node-spent { font-size: 12.5px; color: var(--brand-ink); } +.node-member { display: inline-flex; align-items: center; gap: 4px; } +.node-role { + font-size: 11px; padding: 0 6px; border-radius: 999px; + background: rgba(168, 74, 40, .12); color: var(--brand-ink); +} + +/* ---- 行程表单:参与人角色 ---- */ +.role-set { margin-top: 6px; } +.role-grid { display: grid; gap: 6px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); margin-top: 8px; } +.role-row { display: flex; align-items: center; gap: 8px; } +.role-who { flex: 0 0 72px; font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.role-row .input { flex: 1 1 auto; min-width: 0; padding: 6px 9px; font-size: 13px; } + +/* ---- 花销 ---- */ +.exp-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.exp-row { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 7px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s); + background: #FDFBF7; font-size: 13.5px; +} +.exp-cat { + font-size: 11.5px; padding: 1px 8px; border-radius: 999px; + background: var(--line-2); color: var(--brand-ink); +} +.exp-title { flex: 1 1 140px; min-width: 0; } +.exp-amt { font-weight: 600; font-variant-numeric: tabular-nums; } +.exp-total { margin: 0 0 12px; font-size: 14px; } +.exp-form-row { display: flex; flex-wrap: wrap; gap: 8px; } +.exp-form-row .input { flex: 1 1 130px; min-width: 0; } +.exp-form-row .select { flex: 0 0 auto; width: auto; min-width: 96px; } + +/* ---- 设置三页:车辆 / 出行方式 / 节点类型 ---- */ +.set-sub { font-size: 14px; font-weight: 600; margin: 16px 0 8px; } +.veh-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.veh-row { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 8px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s); + background: #FDFBF7; +} +.veh-ro { background: var(--line-2); } +.veh-ico { font-size: 17px; } +.veh-name { font-size: 14px; font-weight: 600; } +.veh-tag { font-size: 11.5px; padding: 1px 8px; border-radius: 999px; background: var(--surface); color: var(--muted); border: 1px solid var(--line); } +.veh-form { flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; } +.veh-form .input { flex: 1 1 110px; min-width: 0; padding: 6px 9px; font-size: 13px; } +.veh-form .select { flex: 0 0 auto; width: auto; padding: 6px 9px; font-size: 13px; } +.veh-def { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); white-space: nowrap; } +.veh-add { margin-top: 4px; } + +.mode-grid { display: flex; flex-direction: column; gap: 8px; } +.mode-row { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 8px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s); + background: #FDFBF7; +} +.mode-ico { font-size: 17px; } +.mode-name { flex: 0 0 64px; font-size: 14px; } +.mode-row .input { flex: 1 1 220px; min-width: 0; padding: 6px 9px; font-size: 13px; } +.mode-none { flex: 1 1 auto; } + +.type-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.type-row { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 8px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s); + background: #FDFBF7; +} +.type-form { flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; } +.type-form .input { flex: 1 1 140px; min-width: 0; padding: 6px 9px; font-size: 13px; } +.type-ico { flex: 0 0 52px !important; text-align: center; font-size: 16px !important; } +.type-tag { font-size: 11.5px; padding: 1px 8px; border-radius: 999px; background: var(--surface); color: var(--muted); border: 1px solid var(--line); } +.type-tag-custom { background: rgba(168, 74, 40, .1); color: var(--brand-ink); border-color: rgba(168, 74, 40, .25); } +.type-used { flex: 0 0 auto; } +.type-add { margin-top: 4px; } + +/* ---- 窄屏 ---- */ +@media (max-width: 860px) { + /* 题图在窄屏铺满一行(信息区折到下面)—— 16:9 的比例保持不变 */ + .trip-hero-cover { width: 100%; } + .trip-stats { gap: 8px 18px; } + .ts-n { font-size: 17px; } +} diff --git a/index.php b/index.php index 54b4445..c67f898 100644 --- a/index.php +++ b/index.php @@ -24,6 +24,7 @@ use App\ContactImporter; use App\Crypto; use App\Database; use App\Douban; +use App\Expense; use App\Family; use App\Geo; use App\Home; @@ -36,6 +37,7 @@ use App\Movie; use App\MovieReview; use App\Module; use App\MovieStats; +use App\NodeType; use App\Nickname; use App\Person; use App\PersonGroup; @@ -53,6 +55,7 @@ use App\Task; use App\Trip; use App\Upload; use App\UserSetting; +use App\Vehicle; use App\View; if (!Database::hasTables()) { @@ -2764,7 +2767,7 @@ switch ($r) { case 'trips/new': Auth::requireWrite(); View::render('trips/form', [ - 'title' => '记一次活动', + 'title' => '新建旅程', 't' => null, // 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下 'presetAttendees' => [(int)Auth::personId()], @@ -2778,7 +2781,7 @@ switch ($r) { $familyId = (int)Auth::familyId(); $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); if (!$t) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } View::render('trips/form', [ @@ -2798,7 +2801,7 @@ switch ($r) { $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $t = $isEdit ? Trip::find($familyId, $id) : null; if ($isEdit && !$t) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } $d = [ @@ -2820,14 +2823,19 @@ switch ($r) { // ③ 勾选 → '1'。 // 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开, // 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。 - 'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId), + // ⚠️ v24 第 16 批 用户第 10 条:建/编辑旅程的表单里**没有**同行的人了 + // ("这些都是从行程里面总结出来的")。所以这里**不再收** attendees。 + // ⚠️ 但**不能**就此把活动上那份名单清空:老数据里很多人的参与人 + // 只登记在这一层(那时还没有行程级参与人),清掉就"人没了"。 + // 所以"键不存在"时保持原样(Trip::saveAttendees 收到空数组会全删, + // 这里刻意不传这个键)。新数据的参与人一律从行程里长出来。 ]; if (array_key_exists('is_public', $_POST)) { $d['is_public'] = (string)$_POST['is_public']; } $errors = []; if ($d['title'] === '') { - $errors['title'] = '活动名字不能为空'; + $errors['title'] = '旅程名字不能为空'; } // 「回来不能早于出发」的校验一并删掉:时间不再由这里填, // 而是从各段旅程算 max/min —— 二者反了也不可能(min ≤ max 恒成立)。 @@ -2851,7 +2859,7 @@ switch ($r) { if ($errors) { set_old($_POST); View::render('trips/form', [ - 'title' => $isEdit ? '编辑活动' : '记一次活动', + 'title' => $isEdit ? '编辑旅程' : '新建旅程', 't' => $isEdit ? array_merge($t, $d) : null, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => $errors, @@ -2866,7 +2874,7 @@ switch ($r) { } $newId = Trip::create($familyId, (int)Auth::personId(), $d); clear_old(); - flash('ok', '这次活动记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); + flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('trips/show', ['id' => $newId]); break; @@ -2875,29 +2883,214 @@ switch ($r) { $meId = (int)Auth::personId(); $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId); if (!$t) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } View::render('trips/show', [ 'title' => (string)$t['title'], 't' => $t, - 'photos' => $t['photos'], 'nodes' => Trip::nodes((int)$t['id'], $meId), - 'people' => Person::roster($familyId, $meId, 'member', ''), + // 用户第 2 条:题图右侧那五项统计(参与人/城市/天数/段数/花销) + 'sm' => Trip::summary((int)$t['id'], $meId), ], 'trips'); break; + // ---------------- 行程:新增 / 编辑(独立页面,v24 第 16 批 第 5/6/7 条)---------------- + // + // ⚠️ 用户第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」 + // 以前用的是详情页里的
内联表单 —— 每个行程一份, + // TripLog 那种十几组字段的表单内联一开就把页面撑到几屏长, + // 而且 N 份表单的元素 id 互相撞、密码管理器逐个扫描还会卡。 + // + // 表单字段照 TripLog 的 nodes/form.php(用户第 7 条)。 + + case 'trips/node_new': + case 'trips/node_edit': + Auth::requireWrite(); + $familyId = (int)Auth::familyId(); + $isEdit = $r === 'trips/node_edit'; + $t = null; + $nd = null; + $stage = 'plan'; + if ($isEdit) { + $nd = Trip::node((int)($_GET['id'] ?? 0)); + if (!$nd) { + flash('err', '找不到这段行程。'); + redirect('trips'); + } + // ⚠️ 必须校验这段行程确实属于**本家的**某个旅程: + // 否则知道 id 就能改别人家的行程(走的还是 Auth::requireWrite) + $t = Trip::find($familyId, (int)$nd['event_id'], (int)Auth::personId()); + if (!$t) { + flash('err', '找不到这段行程。'); + redirect('trips'); + } + $stage = (string)($nd['stage'] ?? 'plan'); + } else { + $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); + if (!$t) { + flash('err', '找不到这个旅程。'); + redirect('trips'); + } + $stage = (string)($_GET['stage'] ?? 'plan'); + if (!isset(Trip::NODE_STAGES[$stage]) || $stage === 'skipped') { + $stage = 'plan'; // "未成行"是标记出来的,不是新建出来的 + } + } + View::render('trips/node_form', [ + 'title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title'], + 'ev' => $t, + 'n' => $nd, + 'stage' => $stage, + 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), + 'types' => NodeType::all($familyId), + 'vehicles' => Vehicle::pickable($familyId), + 'expenses' => $nd ? Expense::ofNode((int)$nd['id']) : [], + 'errors' => [], + ], 'trips'); + break; + + // ---------------- 花销(行程级,v24 第 16 批 第 2 条)---------------- + + case 'trips/expense_add': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $nodeId = (int)($_POST['node_id'] ?? 0); + // 校验这段行程确实属于本家(顺着 nodes → events 查一遍) + $st = Database::get()->prepare( + "SELECT n.id FROM trip_nodes n JOIN events e ON e.id = n.event_id + WHERE n.id = ? AND n.family_id = ? AND e.family_id = ?" + ); + $st->execute([$nodeId, $familyId, $familyId]); + if ($st->fetchColumn() === false) { + flash('err', '找不到这段行程。'); + redirect('trips'); + } + $res = Expense::add($familyId, $nodeId, [ + 'amount' => (string)($_POST['amount'] ?? ''), + 'category' => (string)($_POST['category'] ?? ''), + 'title' => (string)($_POST['title'] ?? ''), + 'person_id' => (int)($_POST['person_id'] ?? 0), + ]); + flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '花销记下了。' : (string)$res['error']); + redirect('trips/node_edit', ['id' => $nodeId]); + break; + + case 'trips/expense_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $nodeId = (int)($_POST['node_id'] ?? 0); + Expense::destroy($familyId, (int)($_POST['expense_id'] ?? 0)); + flash('ok', '这笔花销删掉了。'); + redirect('trips/node_edit', ['id' => $nodeId]); + break; + + // ---------------- 旅程设置:车辆 / 出行方式 / 节点类型(用户第 8 条)---------------- + + case 'trips/vehicles': + $familyId = (int)Auth::familyId(); + View::render('trips/vehicles', [ + 'title' => '车辆管理', + 'ledger' => Vehicle::fromLedger($familyId), + 'mine' => Vehicle::all($familyId), + ], 'trips'); + break; + + case 'trips/vehicles_save': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $res = Vehicle::save($familyId, $_POST, $id > 0 ? $id : null); + flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已保存。' : (string)$res['error']); + redirect('trips/vehicles'); + break; + + case 'trips/vehicles_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $res = Vehicle::destroy($familyId, (int)($_POST['id'] ?? 0)); + flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已删除。' : (string)$res['error']); + redirect('trips/vehicles'); + break; + + case 'trips/modes': + $familyId = (int)Auth::familyId(); + // "常用信息"的记忆:每种出行方式下用户常填的值(不是新增交通方式) + View::render('trips/modes', [ + 'title' => '出行方式', + 'modes' => Trip::TRAVEL_MODES, + 'memo' => json_decode((string)Setting::get($familyId, 'trip_mode_memo', '{}'), true) ?: [], + ], 'trips'); + break; + + case 'trips/modes_save': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $raw = (array)($_POST['memo'] ?? []); + $out = []; + foreach ($raw as $k => $v) { + // 只认确实存在的出行方式,值限长 —— 表单可以改,不能凭它往设置里塞任意东西 + if (!isset(Trip::TRAVEL_MODES[(string)$k])) { + continue; + } + $v = mb_substr(trim((string)$v), 0, 60, 'UTF-8'); + if ($v !== '') { + $out[(string)$k] = $v; + } + } + Setting::set($familyId, 'trip_mode_memo', json_encode($out, JSON_UNESCAPED_UNICODE)); + flash('ok', '出行方式设置已保存。'); + redirect('trips/modes'); + break; + + case 'trips/types': + $familyId = (int)Auth::familyId(); + $types = NodeType::all($familyId); + foreach ($types as &$ty) { + $ty['used'] = NodeType::usedCount($familyId, (string)$ty['name']); + } + unset($ty); + View::render('trips/types', [ + 'title' => '节点类型', + 'types' => $types, + ], 'trips'); + break; + + case 'trips/types_save': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $res = NodeType::save($familyId, $_POST, $id > 0 ? $id : null); + flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已保存。' : (string)$res['error']); + redirect('trips/types'); + break; + + case 'trips/types_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $res = NodeType::destroy($familyId, (int)($_POST['id'] ?? 0)); + flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已删除。' : (string)$res['error']); + redirect('trips/types'); + break; + case 'trips/destroy': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Trip::find($familyId, $id)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::destroy($familyId, $id); - flash('ok', '这次活动已删除。'); + flash('ok', '这个旅程已删除。'); redirect('trips'); break; @@ -2907,7 +3100,7 @@ switch ($r) { csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Trip::find($familyId, $id)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } $warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId()); @@ -2925,7 +3118,7 @@ switch ($r) { csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Trip::find($familyId, $id)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } \App\Attachment::remove((int)($_POST['photo_id'] ?? 0)); @@ -2943,34 +3136,51 @@ switch ($r) { $tripId = (int)($_POST['trip_id'] ?? 0); $t = Trip::find($familyId, $tripId); if (!$t) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } $isEdit = $r === 'trips/node_update'; $nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0; + // v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。 + // 参与人的角色标签走 roles[person_id] => 角色(TripLog 的 role_tag)。 + $roles = []; + foreach ((array)($_POST['roles'] ?? []) as $pid => $role) { + $roles[(int)$pid] = (string)$role; + } $d = [ - 'title' => trim((string)($_POST['title'] ?? '')), - 'detail' => trim((string)($_POST['detail'] ?? '')), - 'place' => trim((string)($_POST['place'] ?? '')), - 'starts_at' => (string)(read_dt_input('starts_at') ?? ''), - 'ends_at' => (string)(read_dt_input('ends_at') ?? ''), - '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), + 'title' => trim((string)($_POST['title'] ?? '')), + 'detail' => trim((string)($_POST['detail'] ?? '')), + 'place' => trim((string)($_POST['place'] ?? '')), + 'starts_at' => (string)(read_dt_input('starts_at') ?? ''), + 'ends_at' => (string)(read_dt_input('ends_at') ?? ''), + 'node_type' => (string)($_POST['node_type'] ?? 'other'), + 'status' => (string)($_POST['status'] ?? 'planned'), + 'category' => (string)($_POST['category'] ?? 'plan'), + // 新建时用户在表单里选的「计划 / 实际」;编辑时表单带的是隐藏 stage + 'stage' => (string)($_POST['stage_pick'] ?? $_POST['stage'] ?? 'plan'), + 'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId), + 'roles' => $roles, + // ---- v21:交通与起终点(TripLog 同款)---- + 'travel_mode' => (string)($_POST['travel_mode'] ?? ''), + 'vehicle_id' => (int)($_POST['vehicle_id'] ?? 0), + 'line_name' => trim((string)($_POST['line_name'] ?? '')), + 'carrier' => trim((string)($_POST['carrier'] ?? '')), + 'trip_no' => trim((string)($_POST['trip_no'] ?? '')), + 'seat_info' => trim((string)($_POST['seat_info'] ?? '')), + 'gate' => trim((string)($_POST['gate'] ?? '')), + 'from_place' => trim((string)($_POST['from_place'] ?? '')), + 'to_place' => trim((string)($_POST['to_place'] ?? '')), ]; if ($d['title'] === '') { - flash('err', '这一段得有个名字。'); + flash('err', '这段行程得有个名字。'); redirect('trips/show', ['id' => $tripId]); } $newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null); - $warns = attach_save_files($familyId, 'trip_node', $newId, 'attach', (int)Auth::personId()); - flash('ok', $isEdit ? '这一段已保存。' : '已加一段。' + $warns = attach_save_files($familyId, 'trip_node', $newId, 'photo', (int)Auth::personId()); + flash('ok', ($isEdit ? '这段行程已保存。' : '行程已加进来。') . ($warns ? '(' . implode(';', $warns) . ')' : '')); - redirect('trips/show', ['id' => $tripId]); + // ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上) + redirect('trips/show', ['id' => $tripId], 'node-' . $newId); break; case 'trips/node_del': @@ -2979,7 +3189,7 @@ switch ($r) { csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::deleteNode((int)($_POST['id'] ?? 0)); @@ -3002,7 +3212,7 @@ switch ($r) { csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } $nid = (int)($_POST['id'] ?? 0); @@ -3025,7 +3235,7 @@ switch ($r) { csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::markActual((int)($_POST['id'] ?? 0)); @@ -3040,7 +3250,7 @@ switch ($r) { csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::completeNode((int)($_POST['id'] ?? 0)); @@ -3055,11 +3265,11 @@ switch ($r) { csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::skipNode((int)($_POST['id'] ?? 0)); - flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这段旅程里仍然看得到。'); + flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这个旅程里仍然看得到。'); redirect('trips/show', ['id' => $tripId]); break; @@ -3070,7 +3280,7 @@ switch ($r) { csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { - flash('err', '找不到这次活动。'); + flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::restoreNode((int)($_POST['id'] ?? 0)); diff --git a/templates/trips/_node_form.php b/templates/trips/_node_form.php deleted file mode 100644 index 15936aa..0000000 --- a/templates/trips/_node_form.php +++ /dev/null @@ -1,118 +0,0 @@ - -
- - - - - - - - -
-
- - -
-
- - -
- -
- - - 计划走完后可用「修正 / 完成」把它转成实际。 -
- -
- -
-
- -
- - -
-
- - -
- -
- - - - 结束时间可不填。 -
-
- - -
- -
- -
- - - -
-
- -
-
- -
-
diff --git a/templates/trips/_settings_menu.php b/templates/trips/_settings_menu.php new file mode 100644 index 0000000..c94d379 --- /dev/null +++ b/templates/trips/_settings_menu.php @@ -0,0 +1,53 @@ + '' + . '' + . '', + // 出行方式:一个方向箭头 + 'modes' => '', + // 节点类型:一排小方块 + 'types' => '' + . '' + . '' + . '', + ]; + $d = $paths[$name] ?? ''; + if ($d === '') { + return ''; + } + return ''; +}; +?> + + 里、只是视觉上折叠 —— 少了哪个字段都会静默丢数据。 - * - * ⚠️ 同行的人:活动上登记的这份名单**仍然有用**(参与人 = 活动登记 ∪ 各段旅程成员)。 - * 但既然活动现在"没有时间也没有地点",多数人会直接在每段旅程里填成员; - * 所以它退到折叠区,默认不占地方。 + * @param array|null $t 编辑时的旅程;null = 新建 + * @param array $errors 校验错误 */ $isEdit = !empty($t); $canWrite = \App\Auth::canWrite(); -$selIds = []; -if ($isEdit) { - foreach ((array)($t['attendees'] ?? []) as $a) { - $selIds[] = (int)$a['id']; - } -} else { - $selIds = array_map('intval', (array)($presetAttendees ?? [])); -} $cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900, 'label' => '题图', 'hint' => '']; $hasCover = $isEdit && !empty($t['cover_key']); -// 折叠区里"有没有填过东西" —— 有就默认展开,别让改过的人还要再点一次 -$moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== '' - || (string)($t['is_public'] ?? '1') === '0' - || $selIds); ?>
-

- 活动是容器;里面一段一段的旅程才是日程 +

+ 旅程里装行程;参与人、城市、天数、花销都从行程里汇总
返回 @@ -59,16 +42,15 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
- +
- +
@@ -88,8 +70,7 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== '' 点左边那块选题图,选完弹裁剪框。
- 题图是 16:9 的宽幅横图,多大都能传,保存时自动缩成 - ×。 + 题图是 16:9 的宽幅横图,详情页也按 16:9 显示。 不换图就不用管这里。
@@ -102,79 +83,41 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== '' placeholder="这趟玩得怎么样、为什么想记下来">
- - -

- 时间与去过的城市不用填:加进一段段旅程之后,会自动取最早的一段、 - 最晚的一段和各段去的地方算出来。 -

- + + +
+ + + 不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。 +
+ +

- 当前时间: - · 去过: -
这两样都是从下面各段旅程算出来的,改这里没用;要改就去改对应的那段旅程。 + 当前: · 去过 + 0): ?> · 人参与 +
这些都是从下面各段行程算出来的,这里改不了 —— 要改就去改对应的那段行程。 +

+ +

+ 参与人、城市、天数、花销都不用填:加进一段段行程之后会自动汇总。

- -
> - 更多设置(备注 / 公开 / 同行的人) - -
-
- - -
- - - -
- - - 不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。 -
- -
- - -

名册里还没有人。

- - - 这里登记的是"整趟活动都在的人"。只在某一段旅程里的人, - 到那段旅程里勾就行 —— 活动页上的名单是两边合起来算的。 - - - -
-
-
-
- + 取消
diff --git a/templates/trips/index.php b/templates/trips/index.php index e674859..d0efd1a 100644 --- a/templates/trips/index.php +++ b/templates/trips/index.php @@ -1,26 +1,26 @@
-

活动

+

旅程

- 共 次 · 每次活动里可以有若干段旅程 + 共 个 · 每个旅程里可以有若干段行程
- 记一次活动 + 新建旅程 只读账号 @@ -29,7 +29,7 @@ $canWrite = \App\Auth::canWrite(); + + +
+

这些方式在哪用

+

+ 在「编辑行程 / 添加行程」里选出行方式。 + 选了「自驾」还会多出一个车辆下拉 —— 里面既有财务里登记过的车, + 也有「车辆管理」里自己加的车(租的、借的)。 +

+
\ No newline at end of file diff --git a/templates/trips/node_form.php b/templates/trips/node_form.php new file mode 100644 index 0000000..c723be2 --- /dev/null +++ b/templates/trips/node_form.php @@ -0,0 +1,364 @@ +` 内联): + * TripLog 的节点表单有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/ + * 参与人/照片/状态),内联在主页面里会把整个详情页撑得极长; + * 更要命的是**编辑**时每个行程内联一份表单 → 页面上 N 份表单互相干扰 + * (元素 id 撞车、密码管理器逐个扫描变卡)。这正是用户说的"把页面撑破"。 + * + * 字段顺序照 TripLog 的 nodes/form.php: + * 类型 → 起止时间 → 标题 → 出行方式(+车辆+交通细节)→ 起点 → 终点 + * → 内容 → 参与人 → 照片 →(编辑时)状态与花销。 + * + * 变量(require 前设好): + * $ev 所属旅程(events 行,带 title) + * $n 被编辑的行程;null = 新增 + * $stage 'plan'|'actual'(新增时由按钮决定;编辑时取行程自己的) + * $people 名册 + * $types 节点类型(NodeType::all) + * $vehicles 可挑的车(Vehicle::pickable,含"来自财务"的只读候选) + * $expenses 已有花销(编辑时) + * $errors 校验错误 + */ +$isEdit = !empty($n); +$canWrite = \App\Auth::canWrite(); +$fid = (int)\App\Auth::familyId(); +$people = $people ?? []; +$types = $types ?? []; +$vehicles = $vehicles ?? []; +$expenses = $expenses ?? []; +$errors = $errors ?? []; + +$stage = (string)($stage ?? ($n['stage'] ?? 'plan')); +if (!isset(\App\Trip::NODE_STAGES[$stage])) { $stage = 'plan'; } +$curType = (string)($n['node_type'] ?? ''); +$curMode = (string)($n['travel_mode'] ?? ''); +$picked = array_map('intval', array_column((array)($n['members'] ?? []), 'id')); + +// 参与人角色:person_id => role +$roles = []; +foreach ((array)($n['members'] ?? []) as $m) { + if (!empty($m['role_tag'])) { + $roles[(int)$m['id']] = (string)$m['role_tag']; + } +} +$back = url('trips/show', ['id' => (int)$ev['id']]); +?> +
+

+ +

+ + + · + + +
+ +
+
+ + + + + + + + +
+ +
+ + + 在右上角「设置 → 节点类型」里可以增删自定义。 +
+ +
+ +
+
+ + +
+ + + +
+ + +
+ + + + 结束时间可不填。 +
+ + +
+ + +
+ + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + + 旅程页上的"去了几个城市"就是按这里统计的。 +
+ +
+ + +
+ +
+ + +
+ + +
+ + +

名册里还没有人。

+ +
+ + + + +
+
+ 给参与的人标个角色(可选) +
+ + +
+ + +
+ +
+ 不填也没关系;填了会显示在那个人名字旁边。 +
+ +
+ + +
+ + +
+ +
+ + + + + + + + + +
+ +
+ + + 可多选;图片、视频都会存到这段行程下面。 +
+
+ +
+ + 取消 +
+ +
+ + + +
+

+ 花销( 笔) + 这一段花了多少钱;旅程页的"总花销"是各段之和 +

+ + +

还没记花销。

+ +
    + +
  • + + + + 付 + + +
    + + + + +
    +
  • + +
+

合计

+ + +
+ + +
+ + + )。 + 裸下拉的判据在部署断言里,写完空跑会直接红。 */ ?> +
+ +
+ + +
+
+
+ \ No newline at end of file diff --git a/templates/trips/show.php b/templates/trips/show.php index 471db38..c9cbab7 100644 --- a/templates/trips/show.php +++ b/templates/trips/show.php @@ -1,53 +1,81 @@ [], 'actual' => [], 'skipped' => []]; foreach ((array)($nodes ?? []) as $n) { - $sg = (string)($n['stage'] ?? (($n['category'] ?? 'plan') === 'reality' ? 'actual' : 'plan')); + $sg = (string)($n['stage'] ?? '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')); +$groupMeta = [ + 'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'], + 'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'], + 'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'], +]; ?>

- - +
+ + + + 计划行程 + + + + 实际行程 + 编辑 -
+ + + - +
+ + 只读账号 - 返回活动
+
@@ -56,145 +84,173 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i 🧳
+
- - -

- $city): ?> - 0 ? '·' : '📍' ?> - - -

- - -

- - - + +
+ + + + $c): ?> + 0 ? '·' : '' ?> + + + +
+ + +
+
+ + 参与人 +
+
+ + 个城市 +
+
+ 0 ? $days : '—' ?> + 天 +
+
+ + 段行程 +
+
+ + 总花销 +
+
+ + +
参与的人(各段行程的合集)
- + (int)$a['id']])) ?>
+ + + +

+ +

- -
-

照片

- -

还没有照片。

- -
- -
- - - <?= e((string)($ph['name'] ?? '')) ?> - - -
- - - - -
- -
- -
- - -
- - - - -
- -
- - + ['计划的旅程', '还没走。每段下面的「修正 / 完成 / 标记未成行」都能用'], - 'actual' => ['走过的旅程', '已走过 —— 会出现在日历与首页「往年今日」'], - 'skipped' => ['没成行的', '这次没去成;不再出现在日程与「往年今日」,但在这里保留可查'], -]; -foreach (['plan', 'actual', 'skipped'] as $sg): - $list = $bysStage[$sg]; +$shown = 0; +foreach ($bysStage as $lst) { $shown += count($lst); } ?> -
-

- - 段 -

-

- - -

- -

+ +
+ 还没有行程 + + 点右上角「+ 计划行程」把打算走的地方排进来,或者「+ 实际行程」直接记已经走过的。 + 参与人、城市、天数、花销都会从行程里自动汇总到这里。 + 家人还没有记录行程。 + +
+ + + +
+

+ + 段 +

+

+
    +
  1. - - + id="node-"> + +
    - + 0): ?> - 已修正 次 + 已修正 次 📍 + + +
    - +
    实际
    + + + + +
    + +
    + + + +
    + → +
    + +

    + + +
    💰 这一段花了
    + +
    - + + + + + + + +
    + - +
    看修正记录( 条) @@ -229,7 +285,10 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
    - + + 编辑 +
    修正 @@ -237,7 +296,7 @@ foreach (['plan', 'actual', 'skipped'] as $sg):

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

    -
    - +
    -
    + - +
    0): ?> -
    + - +
    -
    + - +
    -
    - 编辑 - -
    -
    + - + - +
- - - -
- - + 加一段旅程() - -
- -
- - +
+ + \ No newline at end of file diff --git a/templates/trips/types.php b/templates/trips/types.php new file mode 100644 index 0000000..79de07f --- /dev/null +++ b/templates/trips/types.php @@ -0,0 +1,78 @@ + +
+

节点类型

+ 行程分几类,你自己定 +
+ 返回旅程 + +
+
+ + +

只读账号不能改设置。

+ +
+

现有类型

+
    + +
  • +
    + + + + + + 内置 + + 自定义 + + + 段在用 + + +
    + +
    + + + +
    + +
  • + +
+

+ 内置的几种不能删(可以改名)。已经有行程在用的类型也不能删 —— + 先到那些行程里把它们改成别的类型。 +

+
+ +
+

加一个类型

+
+ + + + +
+

图标填一个表情符号就行(如 🎣 🏖 🍜)。

+
+ \ No newline at end of file diff --git a/templates/trips/vehicles.php b/templates/trips/vehicles.php new file mode 100644 index 0000000..2cb4987 --- /dev/null +++ b/templates/trips/vehicles.php @@ -0,0 +1,130 @@ + +
+

车辆管理

+ 出行时能挑的车:自家的、租的、借别人的 +
+ 返回旅程 + +
+
+ + +
+

+ 财务里已登记的车 + 只读 —— 要改请到财务模块 +

+ +

+ 财务里还没有登记车子。到「财务 → 记一笔」里把车子作为一项资产记下来, + 这里就会自动出现(不必在这里重复添加)。 +

+ +
    + +
  • + 🚗 + + 来自财务 +
  • + +
+

+ 这些是财务里的**资产记录**(有一台车值多少钱),不一定有车牌与车型。 + 出行要选车时它们也会出现在下拉里,标着「来自财务」。 +

+ +
+ + +
+

+ 出行用的车 + 自家车可以录一台方便选;租的车、借的车录在这里 +

+ + +
    + +
  • + + +
    + + + + + + + + +
    +
    + + + +
    + + + + +
  • + +
+ +

还没有。自家车录一台,出行时能直接挑。

+ + + +

加一辆车

+
+ + + + + + + + +
+ +
\ No newline at end of file