From c30ca99d22a33206133da0a9f451d30ad0ebf957 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 5 Oct 2026 16:45:10 +0800 Subject: [PATCH] =?UTF-8?q?v24=20=E7=AC=AC=2017=20=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E5=9F=8E=E5=B8=82=E8=81=9A=E5=90=88=20/=20=E9=AB=98=E5=BE=B7?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=20/=20=E8=A1=A8=E5=8D=95=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=EF=BC=88=E7=94=A8=E6=88=B7=2010=20=E6=9D=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 10 条原话要点与落地: - ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程, 都有青岛作为出发地或者目的地,那么就只记一次」 → trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成 「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。 详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市, 而不是终点地址」)。 - ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」 → NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。 - ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车, 只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive']; Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。 - ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」 → seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢; saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。 - ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」 +「给参与人标注角色这个功能去掉」 → 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 + 导航链接,没配 key 也能手输);参与人改用全局弹框选人组件; 角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。 - ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」 → 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、 车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。 - ④「行程中,时间明明具体到了时分秒,怎么不显示?」 → Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、 两头都没时分就不写 00:00)。 其他: - App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。 中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、 离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。 - config.example.php 加 amap 段(key / security_js / nav_src)。 验证: - e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)· 城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)· 桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。 - 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 / 打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。 - 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。 --- app/Geo.php | 325 +++++++++++++++++++++++++++++++ app/NodeType.php | 81 +++++++- app/Schema.php | 58 +++++- app/Trip.php | 274 ++++++++++++++++++++++---- app/Vehicle.php | 29 ++- assets/css/app.css | 43 ++++ config.example.php | 20 ++ index.php | 29 ++- templates/trips/_loc_field.php | 79 ++++++++ templates/trips/_revise_form.php | 29 +-- templates/trips/node_form.php | 322 ++++++++++++++++++++---------- templates/trips/show.php | 51 +++-- templates/trips/vehicles.php | 53 ++--- 13 files changed, 1158 insertions(+), 235 deletions(-) create mode 100644 templates/trips/_loc_field.php diff --git a/app/Geo.php b/app/Geo.php index 82eb9a7..1d76d5e 100644 --- a/app/Geo.php +++ b/app/Geo.php @@ -331,4 +331,329 @@ final class Geo '海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省', '台湾省', ]; + + // ================================================================ + // 以下为 v24 第 17 批新增:**地址 → 城市**(用于旅程的城市聚合统计) + // ================================================================ + + /** + * 高德 Web 服务的逆地理端点 / 超时。 + * + * ⚠️ 与上面天气那套(离线表)是**两条独立路径**,互不影响: + * 天气要"城市 → 坐标"(离线表够用), + * 旅程要"坐标/地址 → 城市"(必须联网问高德,因为地址千奇百怪)。 + */ + private const REGEO_URL = 'https://restapi.amap.com/v3/geocode/regeo'; + private const HTTP_TIMEOUT = 3; + + /** 直辖市(高德对它们的 `city` 字段返回空数组,要认 `province`) */ + private const MUNICIPALITIES = ['北京市', '上海市', '天津市', '重庆市']; + + /** + * 从「经纬度 + 地址文本」解析**城市名**(如「青岛市」)。 + * + * 三级回落,每一级都可用(用户第 1 条要的就是"城市",不能因为接口挂了就统计不出来): + * ① **高德逆地理**(有坐标时):`addressComponent.city` 是权威答案; + * ② 离线城市表 `CITIES` 做**地址包含匹配**(「黑龙江省伊春市…」里 + * 能认出已知的城市名;不联网、不受接口抖动影响); + * ③ 从地址里提「XX市」(最朴素的一条,但中文地址里几乎总有)。 + * + * ⚠️ 三级都拿不到就返回**空串**,不猜:`place` 里填的是 + * 「溪水国家森林公园」「青岛胶东国际机场」这类**地点名**, + * 从里面猜城市只会猜错(用户宁可少一个城市,也不要一个假城市名)。 + * + * ⚠️ 绝不抛异常:这是"锦上添花",它挂了不能让保存行程失败。 + */ + public static function cityFromPoint($lng, $lat, string $fallback = ''): string + { + if (is_numeric($lng) && is_numeric($lat)) { + $ac = self::regeo((string)$lng, (string)$lat); + if ($ac !== null) { + $c = self::cityOfComponents($ac); + if ($c !== '') { + return $c; + } + } + } + return self::cityFromText($fallback); + } + + /** + * 调高德逆地理,返回 `regeocode.addressComponent`(失败返回 null)。 + * + * ⚠️ 用 cURL 而不是 `file_get_contents`:后者在 `allow_url_fopen=0` + * 的主机上不可用(宝塔默认可能就是关的),而且**没有超时控制** + * —— 高德慢一点,用户保存行程就会一直转圈。 + * ⚠️ 成败看 body 里的 `status`(字符串 "1"),**不是** HTTP 状态码: + * 配额用完时高德返回 200 + status="0",只看 HTTP 会误判成功。 + */ + public static function regeo(string $lng, string $lat): ?array + { + $key = self::apiKey(); + if ($key === '' || !function_exists('curl_init')) { + return null; + } + $url = self::REGEO_URL . '?' . http_build_query([ + 'key' => $key, + 'location' => $lng . ',' . $lat, // 高德要「经度,纬度」 + 'extensions' => 'base', + 'output' => 'JSON', + ]); + $ch = curl_init($url); + if ($ch === false) { + return null; + } + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => self::HTTP_TIMEOUT, + CURLOPT_CONNECTTIMEOUT => self::HTTP_TIMEOUT, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_USERAGENT => 'FamilyBook/1.0', + ]); + $body = curl_exec($ch); + $err = curl_errno($ch); + curl_close($ch); + if ($err !== 0 || !is_string($body) || $body === '') { + return null; + } + $j = json_decode($body, true); + if (!is_array($j) || (string)($j['status'] ?? '') !== '1') { + return null; + } + $ac = $j['regeocode']['addressComponent'] ?? null; + return is_array($ac) ? $ac : null; + } + + /** + * 从高德的 addressComponent 里取出"城市"。 + * + * ⚠️ 直辖市(北京/上海/天津/重庆)的 `city` 字段是**空数组或空串**, + * 它们本身就是城市一级。只读 `city` 会让这四地的行程一个城市都统计不出来 + * (用户会觉得"北京那次怎么不算")。 + */ + public static function cityOfComponents(array $ac): string + { + $pick = static function ($v): string { + // ⚠️ 高德在没有值时返回**空数组**(不是空串),is_array 这层不能省 + if (is_array($v)) { + $v = $v['name'] ?? ''; + } + return trim((string)$v); + }; + $city = $pick($ac['city'] ?? ''); + $prov = $pick($ac['province'] ?? ''); + if ($city !== '') { + return $city; + } + foreach (self::MUNICIPALITIES as $m) { + if ($prov === $m) { + return $m; + } + } + return ''; + } + + /** + * 从**地址文本**里提城市(不联网;②③ 两级都走这里)。 + * + * 顺序(可靠 → 不可靠): + * ① 先提「XX市」:中文地址里城市几乎总以「XX市」出现, + * 而且地址是 **省 → 市 → 区** 的层级顺序,所以取**第一个**「XX市」 + * 就是地级市那级。 + * ⚠️ 不能取"最后一个":「黑龙江省黑河市五大连池市五大连池镇」取最后 + * 会得到县级市「五大连池市」,再往前回溯窗口一截就成「大连市」了 + * (实测踩到);而且「山东省青岛市市南区」的"市南区"里还有个"市"字。 + * ② 提不出来时才查离线表 `CITIES` 做**包含匹配**(最长优先)。 + * ⚠️ 只在 ① 失败时用:「长春市朝阳区」包含「朝阳」(辽宁朝阳市在表里), + * 先走离线表会返回「朝阳市」—— 离了大谱(实测踩到)。 + * 「青岛胶东国际机场」这种**没有"市"字的地点名**才是这一级要救的。 + * ③ 直辖市(不带"市"字也可能写:「北京朝阳区」)。 + * + * ⚠️ 不猜地点名:`place` 字段里全是「溪水国家森林公园」这种, + * 从里面猜城市只会猜错(宁可不计,不要算错)。 + * 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有), + * 不是靠猜。 + */ + public static function cityFromText(string $text): string + { + $t = trim($text); + if ($t === '') { + return ''; + } + + // ---- ⓪ 直辖市**先认**(必须在剥省份前缀之前)---- + // ⚠️ 顺序错了会出大错(实测踩到):「北京市朝阳区建国路」如果先剥掉 + // 「北京市」(它在 PROVINCE_WORDS 里),剩下「朝阳区建国路」, + // 接着在离线表里匹配到「朝阳」→ 返回**辽宁朝阳市**。离了大谱。 + // 所以直辖市要在最前面单独认掉。 + foreach (self::MUNICIPALITIES as $m) { + if (mb_strpos($t, $m, 0, 'UTF-8') !== false) { + return $m; + } + // 也可能是短名(「北京朝阳区」) + if (mb_strpos($t, mb_substr($m, 0, 2, 'UTF-8'), 0, 'UTF-8') !== false) { + return $m; + } + } + + // ---- ① 提「XX市」---- + // ⚠️ 三步的顺序与量词都很讲究,每一条都是实测踩出来的: + // + // a) **先去省份前缀**。不去的话「内蒙古自治区呼和浩特市」用惰性匹配 + // 最多看 7 个字,`{2,7}?` 够不到「呼和浩特市」(省名占了 6 个字) + // → 提不出城市。 + // b) **惰性量词 `{2,7}?`**,不是贪婪。贪婪会先试最长的, + // 「山东省青岛市市南区」会匹配到 `青岛市市`(把市南区的"市"当成后缀) + // → 得出「青岛市市」。惰性从最短开始试,先命中「青岛市」。 + // c) **取第一个命中的**。地址是"省 → 市 → 区"的层级顺序, + // 第一个「XX市」就是地级市那级。 + // ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市五大连池镇」取最后 + // 会拿到县级市「五大连池市」,再回溯窗口一截就成了「大连市」。 + $body = $t; + foreach (self::PROVINCE_WORDS as $pw) { + if ($pw !== '' && mb_strpos($body, $pw, 0, 'UTF-8') === 0) { + $body = (string)mb_substr($body, mb_strlen($pw, 'UTF-8'), null, 'UTF-8'); + break; + } + } + // 排除"市场 / 市民 / 市区"这类:它们后面那个字会暴露自己 + $badNext = ['场', '民', '集', '政', '长', '面', '内', '外', '郊', '容']; + if (preg_match('/([\x{4e00}-\x{9fa5}]{2,7}?)市([\x{4e00}-\x{9fa5}])?/u', + $body, $m)) { + $cand = $m[1]; + $nxt = (string)($m[2] ?? ''); + if (!($nxt !== '' && in_array($nxt, $badNext, true))) { + // 兜一层:万一身体里还残留「省」(「XX省XX市」连着写、没被上面剥掉) + $sp = mb_strpos($cand, '省', 0, 'UTF-8'); + if ($sp !== false) { + $cand = (string)mb_substr($cand, $sp + 1, null, 'UTF-8'); + } + if (mb_strlen($cand, 'UTF-8') >= 2) { + return $cand . '市'; + } + } + } + + // ---- ② 离线表包含匹配(最长优先)---- + // 只有 ① 提不出来才走这里(见上面的说明:先走会被「朝阳」这类误命中)。 + // + // 🔴 这一级**很危险**(实盘 dry-run 抓到):「五大连池风景区-龙门石寨景区」 + // 里包含「大连」二字 → 被判成**辽宁大连市**(离了大谱)。 + // 收紧两条: + // a) 匹配到的城市名**前面那一个字不能是汉字**(字符串开头除外)—— + // 「五大连池」的"大连"前面是"五",被排除;「青岛胶东国际机场」的 + // "青岛"前面是开头,保留。 + // b) 匹配位置**后面**那一个字也不能是"字"这类能拼成新词的 + // (「张家口」的"张家"后面是"口")—— 其实 a) 已够,b) 作补充。 + // 老实说这一级只该救「XX机场」「XX站」这种**没有市字的地点名**, + // 真正的地址(带省市)都会在 ① 就被提出来。 + $best = ''; + $bestPos = -1; + foreach (self::CITIES as $name => $_xy) { + if ($name === '') { + continue; + } + // 直辖市在表里存的是短名,这一级不处理(交给 ③,且已经在前面的 ⓪ 认过了) + $isMuni = false; + foreach (self::MUNICIPALITIES as $mu) { + if (mb_substr($mu, 0, 2, 'UTF-8') === $name) { $isMuni = true; break; } + } + if ($isMuni) { + continue; + } + $p = mb_strpos($t, $name, 0, 'UTF-8'); + if ($p === false) { + continue; + } + // (a) 前面那个字若是汉字 → 说明这个"城市名"是更长地名的一部分,跳过 + if ($p > 0) { + $prev = mb_substr($t, $p - 1, 1, 'UTF-8'); + if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $prev)) { + continue; + } + } + // 最长优先(并列时取靠前的) + if (mb_strlen($name, 'UTF-8') > mb_strlen($best, 'UTF-8')) { + $best = $name; + $bestPos = $p; + } + } + if ($best !== '') { + return $best . '市'; + } + + // ---- ③ 直辖市(不带"市")---- + foreach (self::MUNICIPALITIES as $m) { + $short = mb_substr($m, 0, 2, 'UTF-8'); + if (mb_strpos($t, $short, 0, 'UTF-8') !== false) { + return $m; + } + } + return ''; + } + + /** + * 一段行程的**城市集合**(出发地 + 到达地,去重)。 + * + * 用户第 1 条:「有多段行程,都有青岛作为出发地或者目的地,那么就只记一次」 + * —— 正是这里:出发地与到达地**都算**,然后去重。 + * + * @param array $n trip_nodes 行(读 from_city / to_city) + * @return string[] 城市名(出发地在前) + */ + public static function nodeCities(array $n): array + { + $out = []; + foreach (['from_city', 'to_city'] as $k) { + $c = trim((string)($n[$k] ?? '')); + if ($c !== '' && !in_array($c, $out, true)) { + $out[] = $c; + } + } + return $out; + } + + /** + * 高德导航链接(TripLog 同款;用户第 3 条要"记录详细地址"就是为了能导航过去)。 + * + * ⚠️ 有坐标走 `navigation`(精确到点),没坐标只能走 `search`(按名字搜)。 + */ + public static function navUrl($lng, $lat, string $name = '', string $mode = 'car'): string + { + $a = fb_config('amap'); + $src = (is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '') ?: 'family.example.com'; + if (is_numeric($lng) && is_numeric($lat)) { + $to = (string)$lng . ',' . (string)$lat . ($name !== '' ? ',' . rawurlencode($name) : ''); + return 'https://uri.amap.com/navigation?to=' . $to + . '&mode=' . rawurlencode($mode) + . '&policy=1&src=' . rawurlencode($src) . '&coordinate=gaode&callnative=1'; + } + $kw = trim($name); + if ($kw === '') { + return ''; + } + return 'https://uri.amap.com/search?keyword=' . rawurlencode($kw) . '&src=' . rawurlencode($src); + } + + /** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */ + public static function apiKey(): string + { + $a = fb_config('amap'); + return is_array($a) ? trim((string)($a['key'] ?? '')) : ''; + } + + /** + * 前端地址联想需要的配置(**JS API** key + 安全密钥)。 + * + * @return array{key:string, security:string, nav_src:string} + */ + public static function jsConfig(): array + { + $a = fb_config('amap'); + return [ + 'key' => is_array($a) ? trim((string)($a['key'] ?? '')) : '', + 'security' => is_array($a) ? trim((string)($a['security_js'] ?? '')) : '', + 'nav_src' => is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '', + ]; + } } diff --git a/app/NodeType.php b/app/NodeType.php index 31b755a..9747bb9 100644 --- a/app/NodeType.php +++ b/app/NodeType.php @@ -31,6 +31,29 @@ final class NodeType 'other' => ['其他', '📌'], ]; + /** + * TripLog(trip.yangyq.net)实际用过的类型 —— 用户第 17 批 第 2 条要求 + * 「你看看 triplog 里面都有哪些,都拿过来」。 + * + * ⚠️ 这份清单是**照着线上库抄的**(7 种,见 Schema 里那句注释)。 + * 与 BUILTIN 的差别就两种: + * · 这里**没有「住宿」** —— 本地有,保留(家庭出游住宿是刚需, + * TripLog 当时用「休息」兼职了这件事,不代表我们也要那样); + * · 这里**多「休息/突发/购物/拜访亲友」** —— 本地要补上。 + * 图标也照 TripLog 抄(🍽️ 😴 ⚠️ 🛍️ 🏠),保持两边认得出是同一类。 + * + * @var array 名字 => 图标 + */ + public const TRIPLOG_TYPES = [ + '赶路' => '🚗', + '游玩' => '🎡', + '用餐' => '🍽️', + '休息' => '😴', + '突发' => '⚠️', + '购物' => '🛍️', + '拜访亲友' => '🏠', + ]; + /** * 一个家的全部类型(表里没有就先把内置的预置进去)。 * @@ -41,6 +64,13 @@ final class NodeType public static function all(int $familyId): array { self::seedDefaults($familyId); + // ⚠️ TripLog 那几种(休息/突发/购物/拜访亲友)也要在**读时**自愈地补。 + // 只放在升级步骤里不够:升级是**一次性**的(靠 Schema 指纹触发), + // 而"重装/清库后重建"不会重跑升级 —— 那时表里只有 seedDefaults 的 5 种, + // 用户第 17 批 第 2 条要的那 4 种永远进不来(e2e 实测就是这个症状: + // 库重建后类型只剩「游玩,用餐,赶路,住宿,其他,赶海」)。 + // seedFromTripLog 内部是 INSERT IGNORE,重复调用零成本。 + self::seedFromTripLog($familyId); $st = Database::get()->prepare( 'SELECT id, name, icon, is_system, position FROM node_types WHERE family_id = ? ORDER BY position, id' @@ -69,8 +99,7 @@ final class NodeType if ($has >= count(self::BUILTIN)) { return 0; // 已经有内置那几条了,不必逐个查(热点路径) } - $hasAny = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = ' - . (int)$familyId)->fetchColumn(); + $hasAny = $n; // 上面已经查过一次了,直接用 // ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个), // 每次读都补回去会让人永远删不掉系统内置的那几种。 if ($hasAny > 0) { @@ -89,6 +118,54 @@ final class NodeType return $n; } + /** + * 补齐 **TripLog 实际用过的**那一批类型(v24 第 17 批 用户第 2 条)。 + * + * 用户原话:「添加行程那里,类型不够丰富,你看看 triplog 里面都有哪些,都拿过来。」 + * + * TripLog 线上(trip.yangyq.net)实际有 7 种,其中 5 种是它自己的内置、 + * 2 种是本人后来自己加的(`is_system=0`): + * 赶路🚗 / 游玩🎡 / 用餐🍽️ / 休息😴 / 突发⚠️ / 购物🛍️ / 拜访亲友🏠 + * + * 与本地原有 5 种(游玩/用餐/赶路/住宿/其他)相比: + * · 差的是 **休息、突发、购物、拜访亲友**(本地没有); + * · 「住宿」是本地有而 TripLog 没有的 —— **保留**(用户没说要删,而且 + * 家庭出游住宿是刚需,TripLog 当时是用「休息」兼职了这件事)。 + * + * ⚠️ 与 `seedDefaults` 的关键区别:那个只在**表全空**时预置(保护用户的删除), + * 这个要**按名字逐个补**(只补缺的那几个)—— 因为用户明确要求"都拿过来", + * 而线上库已经有 5 条了,不补的话差的 4 种永远进不来。 + * ⚠️ 用 `INSERT IGNORE`(靠 uk_nt_family_name 唯一键)而不是先查后插: + * 并发/重跑都安全,少一轮 N+1 查询。 + * ⚠️ 标 `is_system=1`:这几个是"系统预置"(与 TripLog 同源),不该被删掉 + * —— 它们在 TripLog 那边也是内置的。 + * + * @return int 补了几条 + */ + public static function seedFromTripLog(int $familyId): int + { + $db = Database::get(); + try { + // 表还没建(极老的库,升级跑到这一步之前)→ 静默返回,别把页面炸掉 + $db->query('SELECT 1 FROM node_types LIMIT 1'); + } catch (\Throwable $e) { + return 0; + } + $st = $db->prepare( + 'INSERT IGNORE INTO node_types (family_id, name, icon, is_system, position) + VALUES (?,?,?,1,?)' + ); + $pos = (int)($db->query('SELECT COALESCE(MAX(position),0) FROM node_types WHERE family_id = ' + . (int)$familyId)->fetchColumn()); + $n = 0; + foreach (self::TRIPLOG_TYPES as $name => $icon) { + $pos += 10; + $st->execute([$familyId, $name, $icon, $pos]); + $n += $st->rowCount(); + } + return $n; + } + public static function find(int $familyId, int $id): ?array { $st = Database::get()->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?'); diff --git a/app/Schema.php b/app/Schema.php index 9dfcb32..05d7797 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -73,6 +73,15 @@ final class Schema * (实际起止 / 参与人 / 备注),可无限次,供日后对比。 * 迁移:category='reality' → stage='actual','plan' → stage='plan' * (按现有轨道自动迁,用户已确认)。 + * 22 → **城市的聚合与地址(用户 2026-10-05 第 17 批)**: + * 用户第 1 条:「统计出来的不是目的地,也不是车站,而是城市…城市要聚合, + * 比如说有多段行程,都有青岛作为出发地或者目的地,那么就只记一次。」 + * → 只靠 `trip_nodes.place` 一个自由文本字段是算不出"城市"的 + * (那里填的是「青岛胶东国际机场」「溪水国家森林公园」这类**地点**)。 + * 给 trip_nodes 补两列 `from_city` / `to_city`(出发地/到达地所属城市), + * 在**保存行程时**由 `App\Geo` 解析好存下来,读的时候直接去重。 + * 为什么不每次读时现算:解析要调高德接口(有配额、有网络), + * 而城市是"写一次看很多次"的字段 —— 现算会让列表页变慢且可能超配额。 */ /** * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 @@ -80,7 +89,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '21'; + public const VERSION = '22'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -1106,6 +1115,53 @@ SQL, } }); + // ---- v24 第 17 批:城市的聚合与地址(Schema v22)---- + // 用户第 1 条要的是**城市**("经过了几个城市"),而 `place` 里填的是 + // 「青岛胶东国际机场」这种**地点名**,直接拿它去重会得到一堆机场/车站名。 + // 用户第 4 条又点明「图片右侧显示的是聚合的城市,而不是终点地址」—— + // 所以必须在**出发地 / 到达地**上各解析出一个城市,去重后再展示。 + // + // ⚠️ 解析结果**落库**而不是每次读时现算: + // · 解析要调高德逆地理接口(有配额、可能失败), + // · 而城市是"写一次、看很多次"的字段(列表页、日历、首页都要用), + // 现算会让每次列表刷新都打好几次接口 —— 既慢又可能把配额烧光。 + // 高德拿不到时 `App\Geo` 会从地址文本里提「XX市」兜底,实在提不出就留空 + // (留空只是这个城市不计入,不影响页面)。 + self::addColumn($db, 'trip_nodes', 'from_city', + "ADD COLUMN `from_city` VARCHAR(60) DEFAULT NULL COMMENT '出发地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `to_place`"); + self::addColumn($db, 'trip_nodes', 'to_city', + "ADD COLUMN `to_city` VARCHAR(60) DEFAULT NULL COMMENT '到达地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `from_city`"); + // 地址的**详细地址**也一并存下来(TripLog 同款):用户第 3 条要 + // 「出发地和到达地,都用高德地图的接口,记录详细地址」—— + // 光有城市不够,导航要用的是详细地址(或坐标)。 + self::addColumn($db, 'trip_nodes', 'from_addr', + "ADD COLUMN `from_addr` VARCHAR(300) DEFAULT NULL COMMENT '出发地详细地址(高德返回)' AFTER `to_city`"); + self::addColumn($db, 'trip_nodes', 'to_addr', + "ADD COLUMN `to_addr` VARCHAR(300) DEFAULT NULL COMMENT '到达地详细地址(高德返回)' AFTER `from_addr`"); + self::addColumn($db, 'trip_nodes', 'from_lng', + "ADD COLUMN `from_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地经度(高德返回,用于导航链接)' AFTER `to_addr`"); + self::addColumn($db, 'trip_nodes', 'from_lat', + "ADD COLUMN `from_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地纬度' AFTER `from_lng`"); + self::addColumn($db, 'trip_nodes', 'to_lng', + "ADD COLUMN `to_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地经度' AFTER `from_lat`"); + self::addColumn($db, 'trip_nodes', 'to_lat', + "ADD COLUMN `to_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地纬度' AFTER `to_lng`"); + // ⚠️ `seat_info` / `gate` **保留列、不再使用**(用户第 3 条: + // 「飞机、铁路,只记录车次、航班号就可以,其他的没法记录, + // 因为可能是多个人同行,座位什么的不记录了」)。 + // 不删列的理由:删列不可逆,而 TripLog 迁来的老数据里这两个字段 + // 是有真实内容的(本人当时逐条填过);留着它们在库里至少可查。 + // 界面上彻底不再出现 —— 见 node_form.php / show.php。 + + // 类型扩充(用户第 2 条:「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」)。 + // TripLog 线上实际有 7 种(含两个自定义的),本地只有 5 种内置,差的就是这些。 + // ⚠️ 只**补**不删:已有的(用户可能改过名)一律保留。 + self::tryStep('补齐 TripLog 那几种节点类型', static function () use ($db) { + foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { + \App\NodeType::seedFromTripLog((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 b67f8d7..1383421 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -64,8 +64,32 @@ final class Trip 'other' => ['其他', '🧭'], ]; - /** 自驾类(这两种才需要选车辆) */ - public const DRIVE_MODES = ['drive', 'taxi']; + /** + * 自驾类(**只有自驾**才需要选车辆)。 + * + * ⚠️ v24 第 17 批 用户第 3 条把范围收窄了: + * 原话「出行方式,只有选择**自驾**,才让选车辆」。 + * 上一批是按 TripLog 的 `DRIVE_MODES` 把「打车」也算进来 + * (打车时"坐的是哪辆车"确实可填),但用户明确不要 —— + * 打车是"叫一辆车",车牌没有记忆价值,选了反而逼人去车库里挑一台不相干的车。 + */ + public const DRIVE_MODES = ['drive']; + + /** + * 只需要填「车次 / 航班号」的出行方式(用户第 17 批 第 3 条)。 + * + * 用户原话:「飞机、铁路,只记录车次、航班号就可以,其他的没法记录, + * 因为可能是多个人同行,座位什么的不记录了。」 + * + * ⚠️ 于是这两个方式下**不再出现**「座位 / 车厢」「登机口 / 站台」 + * —— 这两个字段对"一家人出行"毫无意义(一次订五六张票,座位号不在一起, + * 而登机口在出发当天才知道)。表单与列表都彻底不显示它们。 + * `trip_nodes.seat_info` / `gate` 两列**保留不删**(老数据不丢,见 Schema)。 + * + * ⚠️ 名单外的出行方式(公交/地铁/轮船等)连车次都不用填: + * 地铁没有"车次"可言,轮船的船名填在标题里就够。 + */ + public const TICKET_MODES = ['train', 'plane']; /** * 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。 @@ -155,31 +179,54 @@ final class Trip } /** - * 一条活动去过的城市(各段旅程 place 去重,按**最早出现的时间**排)。 + * 一条旅程去过的**城市**(用户第 17 批 第 1/4 条)。 * - * ⚠️ 顺序按时间排而不是按字母/插入顺序: - * "青岛 · 威海" 让人一眼看出先后,排成字母序就完全没有这层信息了。 - * ⚠️ skipped(未成行)的**不算去过** —— 计划去但没去成的地方不该出现在 - * "去过哪几个城市"里(这与"不上日程、不进往年今日"是同一条口径)。 + * 用户原话: + * · 第 1 条:「统计出来的不是目的地,也不是车站,而是**城市**… + * 城市要聚合,比如说有多段行程,都有青岛作为出发地或者目的地, + * 那么就只记一次。」 + * · 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,而不是终点地址。」 + * + * 做法:把每条行程的 `from_city`(出发地城市)与 `to_city`(到达地城市) + * 都算进来,**去重** —— 出发地与到达地各算一票,同一城市只记一次。 + * + * ⚠️ 这两个列是**保存行程时**由 `App\Geo` 解析好写进去的,不是读时现算: + * 解析要打高德接口(有配额、可能超时),而城市是"写一次看很多次"的字段。 + * 老数据由补迁脚本回填(见 `_fb_b17_migrate.php`)。 + * ⚠️ 两个列都空时**不回落**去读 `place`:`place` 里是「溪水国家森林公园」 + * 这种**地点名**(用户明确说"不是目的地,也不是车站"), + * 拿它当城市正是这一批要改掉的东西。宁可暂时少一个城市,也不要显示错的。 + * ⚠️ 排序按**最早出现的时间**:先后顺序本身是信息 + * ("青岛 → 伊春 → 黑河"能看出路线,字母序看不出任何东西)。 + * 同一时刻(老数据没时间的)用城市名兜底,保证顺序稳定可复现。 + * ⚠️ skipped(未成行)**不算去过**:计划去但没去成的地方不该出现在 + * "去过哪几个城市"里(与"不上日程、不进往年今日"是同一条口径)。 */ public static function cities(int $eventId): array { // ⚠️ ORDER BY 里**不能**写聚合别名(first_at):MySQL/MariaDB 会报 -// `Reference 'first_at' not supported (reference to group function)` —— -// 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。 -// 写成 `ORDER BY MIN(n.starts_at), n.place` 就没问题(聚合函数本身可用)。 - $st = Database::get()->prepare( - "SELECT n.place, MIN(n.starts_at) AS first_at - FROM trip_nodes n - WHERE n.event_id = ? AND n.stage IN ('plan','actual') - AND n.place IS NOT NULL AND n.place <> '' - GROUP BY n.place - ORDER BY MIN(n.starts_at), n.place" - ); - $st->execute([$eventId]); + // `Reference 'first_at' not supported (reference to group function)`, + // 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。 + // 写成 `ORDER BY MIN(t), city` 就没问题(聚合函数本身可用)。 + $sql = "SELECT c.city AS city, MIN(c.t) AS first_at, MIN(c.role) AS first_role + FROM ( + SELECT from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role + FROM trip_nodes + WHERE event_id = ? AND stage IN ('plan','actual') + AND from_city IS NOT NULL AND from_city <> '' + UNION ALL + SELECT to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role + FROM trip_nodes + WHERE event_id = ? AND stage IN ('plan','actual') + AND to_city IS NOT NULL AND to_city <> '' + ) c + GROUP BY c.city + ORDER BY MIN(c.t), MIN(c.role), c.city"; + $st = Database::get()->prepare($sql); + $st->execute([$eventId, $eventId]); $out = []; foreach ($st->fetchAll() as $r) { - $p = trim((string)$r['place']); + $p = trim((string)$r['city']); if ($p !== '' && !in_array($p, $out, true)) { $out[] = mb_substr($p, 0, 60, 'UTF-8'); } @@ -235,19 +282,29 @@ final class Trip 'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')), ]; } - // ⚠️ 同 cities():ORDER BY 只写聚合函数,不写别名 + // ⚠️ 同 cities():只认 from_city / to_city(第 17 批第 1 条的城市聚合), +// ORDER BY 只写聚合函数、不写别名。原来读的是 place —— 那是**地点名** +// (「溪水国家森林公园」),拿它当城市正是这批要改掉的。 $st2 = Database::get()->prepare( - "SELECT event_id, place, MIN(starts_at) AS first_at - FROM trip_nodes - WHERE event_id IN ($in) AND stage IN ('plan','actual') - AND place IS NOT NULL AND place <> '' - GROUP BY event_id, place - ORDER BY event_id, MIN(starts_at), place" + "SELECT c.event_id, c.city, MIN(c.t) AS first_at, MIN(c.role) AS first_role + FROM ( + SELECT event_id, from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role + FROM trip_nodes + WHERE event_id IN ($in) AND stage IN ('plan','actual') + AND from_city IS NOT NULL AND from_city <> '' + UNION ALL + SELECT event_id, to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role + FROM trip_nodes + WHERE event_id IN ($in) AND stage IN ('plan','actual') + AND to_city IS NOT NULL AND to_city <> '' + ) c + GROUP BY c.event_id, c.city + ORDER BY c.event_id, MIN(c.t), MIN(c.role), c.city" ); - $st2->execute($ids); + $st2->execute(array_merge($ids, $ids)); foreach ($st2->fetchAll() as $r) { $id = (int)$r['event_id']; - $p = mb_substr(trim((string)$r['place']), 0, 60, 'UTF-8'); + $p = mb_substr(trim((string)$r['city']), 0, 60, 'UTF-8'); if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) { $city[$id][] = $p; } @@ -641,6 +698,31 @@ final class Trip if ($vid > 0 && !\App\Vehicle::find($familyId, $vid)) { $vid = 0; } + // ---- v24 第 17 批:出发地 / 到达地的**详细地址 + 坐标 + 城市** ---- + // 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。 + // 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」 + // → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。 + $fromPlace = (string)($d['from_place'] ?? ''); + $toPlace = (string)($d['to_place'] ?? ''); + $fromAddr = trim((string)($d['from_addr'] ?? '')); + $toAddr = trim((string)($d['to_addr'] ?? '')); + $fromLng = self::coordOrNull($d['from_lng'] ?? null); + $fromLat = self::coordOrNull($d['from_lat'] ?? null); + $toLng = self::coordOrNull($d['to_lng'] ?? null); + $toLat = self::coordOrNull($d['to_lat'] ?? null); + // 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本), + // 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。 + $fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'); + $toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'); + if ($fromCity === '') { + $fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr); + } + if ($toCity === '') { + $toCity = \App\Geo::cityFromPoint($toLng, $toLat, $toPlace !== '' ? $toPlace : $toAddr); + } + // 详细地址为空时用地点名兜底(用户看到的是"出发地"那行字,不能空着) + if ($fromAddr === '') { $fromAddr = $fromPlace; } + if ($toAddr === '') { $toAddr = $toPlace; } $fields = [ self::textOrNull2($d['title'] ?? ''), self::textOrNull($d['detail'] ?? ''), @@ -658,31 +740,70 @@ final class Trip self::textOrNull2($d['line_name'] ?? ''), self::textOrNull2($d['carrier'] ?? ''), self::textOrNull($d['trip_no'] ?? ''), + // ⚠️ 第 17 批:这两个字段**不再从表单接收**(用户第 3 条要求去掉)。 + // 但编辑老行程时如果把它们写成 null,TripLog 迁来的座位信息就永远消失了 + // —— 所以这里"保持原值不变"(下面对 UPDATE 单独处理这两列)。 self::textOrNull($d['seat_info'] ?? ''), self::textOrNull($d['gate'] ?? ''), - self::textOrNull2($d['from_place'] ?? ''), - self::textOrNull2($d['to_place'] ?? ''), + self::textOrNull2($fromPlace), + self::textOrNull2($toPlace), + // ---- v22 新增:城市 / 详细地址 / 坐标 ---- + self::textOrNull($fromCity), + self::textOrNull($toCity), + self::textOrNull($fromAddr), + self::textOrNull($toAddr), + $fromLng, $fromLat, $toLng, $toLat, ]; $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=?'; + carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?, + from_city=?, to_city=?, from_addr=?, to_addr=?, + from_lng=?, from_lat=?, to_lng=?, to_lat=?'; if ($id !== null && $id > 0) { - $db->prepare('UPDATE trip_nodes SET ' . $cols . ' WHERE id=?') - ->execute([...$fields, $id]); + // ⚠️ `seat_info` / `gate` 在 UPDATE 里**不参与**(表单已不提交它们): + // 写成 null 会把 TripLog 迁来的老数据抹掉。这两列从此只读不写。 + $colsKeep = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?, + category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?, + carrier=?, trip_no=?, from_place=?, to_place=?, + from_city=?, to_city=?, from_addr=?, to_addr=?, + from_lng=?, from_lat=?, to_lng=?, to_lat=?'; + $keep = $fields; + // 去掉 seat_info / gate 两项(它们在 $fields 里的下标是 15、16) + array_splice($keep, 15, 2); + $db->prepare('UPDATE trip_nodes SET ' . $colsKeep . ' WHERE id=?') + ->execute([...$keep, $id]); $nid = $id; } else { $db->prepare( 'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at, node_type, status, category, position, stage, travel_mode, vehicle_id, line_name, - carrier, trip_no, seat_info, gate, from_place, to_place) + carrier, trip_no, seat_info, gate, from_place, to_place, + from_city, to_city, from_addr, to_addr, from_lng, from_lat, to_lng, to_lat) VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')' )->execute([$familyId, $eventId, ...$fields]); $nid = (int)$db->lastInsertId(); } - self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []); + // ⚠️ 第 17 批:**角色标注功能已去掉**(用户第 3 条:「给参与人标注角色这个功能 + // 去掉,不要了」)→ 不再传 $d['roles']。saveNodeMembers 的第三个参数 + // 保留(老调用点/修正流程还在用),传空数组即可。 + self::saveNodeMembers($nid, $d['members'] ?? []); return $nid; } + /** 经纬度:只接受合理的数值范围,其余一律 null(脏数据不进库) */ + private static function coordOrNull($v): ?float + { + if ($v === null || $v === '' || !is_numeric($v)) { + return null; + } + $f = (float)$v; + // 经度 ±180、纬度 ±90;超出范围的显然是解析错了,宁可不存 + if (abs($f) > 180.0) { + return null; + } + return $f; + } + /** * 行程类型:存**名字**(可自定义的类型表里那个 name),认不出退回 'other'。 * @@ -897,14 +1018,29 @@ final class Trip public static function saveNodeMembers(int $nodeId, array $personIds, array $roles = []): void { $db = Database::get(); - $db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]); - $ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag) - VALUES (?,?,?)'); + // ⚠️ v24 第 17 批:**不再整表 DELETE 再插**,改成"差集增删"。 + // 原因:用户第 3 条把角色标注功能去掉了(表单不再提交 roles), + // 如果这里照旧先删光再插,`role_tag` 就会被写成 null —— + // TripLog 迁来的角色数据(虽然界面不再显示)会被**静默抹掉**。 + // 现在只做两件事:删掉"本次没选的人",插入"新选的人"; + // 已存在且仍被选中的,**原样不动**(role_tag 保住)。 + $keep = []; foreach ($personIds as $pid) { $pid = (int)$pid; - if ($pid <= 0) { - continue; + if ($pid > 0) { + $keep[$pid] = true; } + } + if ($keep) { + $in = implode(',', array_fill(0, count($keep), '?')); + $db->prepare("DELETE FROM trip_node_members WHERE node_id = ? AND person_id NOT IN ($in)") + ->execute([$nodeId, ...array_keys($keep)]); + } else { + $db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]); + } + $ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag) + VALUES (?,?,?)'); + foreach (array_keys($keep) as $pid) { $role = mb_substr(trim((string)($roles[$pid] ?? $roles[(string)$pid] ?? '')), 0, 30, 'UTF-8'); $ins->execute([$nodeId, $pid, $role !== '' ? $role : null]); } @@ -1005,6 +1141,62 @@ final class Trip return $s . ' – ' . date('Y年n月j日', $te); } + /** + * 一段行程的时间,**精确到时分**(用户第 17 批 第 4 条末)。 + * + * 用户原话:「行程中,时间明明具体到了时分秒,怎么不显示?」 + * + * ⚠️ 原来用的是 `formatSpan()` —— 它只到**日**(`Y年n月j日`)。 + * 那个精度是给"旅程"(容器)用的:一次出行说"7月10日到7月12日"就够。 + * 但**一段行程**(比如"10:30 出发去机场")只显示日期等于没说 —— + * 用户填进去的时分秒被吞掉了,看起来像"我填的时间没保存"。 + * + * 展示规则(同一天时只写一次日期,避免"2026年7月10日 10:00 – 2026年7月10日 12:00" + * 这种把同一天写两遍的啰嗦写法): + * · 同一天:`2026年7月10日 10:00 – 12:00` + * · 跨天: `2026年7月10日 10:00 – 7月12日 17:00` + * · 只有开始:`2026年7月10日 10:00` + * · 时分是 00:00(老数据只存了日期):**不写 00:00** —— 那会让人以为 + * "这个人专门挑午夜开始",其实只是没填时间。 + */ + public static function nodeSpan(?string $start, ?string $end): string + { + if ($start === null || $start === '') { + return '日期待补'; + } + $ts = strtotime($start); + if ($ts === false) { + return '日期待补'; + } + $sDate = date('Y年n月j日', $ts); + $sTime = date('H:i', $ts); + // 午夜 00:00 视为"没填具体时间"(日期控件的默认值),不显示它 + $sHasTime = $sTime !== '00:00'; + $head = $sDate . ($sHasTime ? ' ' . $sTime : ''); + + if ($end === null || $end === '') { + return $head; + } + $te = strtotime($end); + if ($te === false) { + return $head; + } + $eTime = date('H:i', $te); + $eHasTime = $eTime !== '00:00'; + // 同一天:后半段只写时分 + if (date('Y-m-d', $ts) === date('Y-m-d', $te)) { + if (!$eHasTime && !$sHasTime) { + return $sDate; // 两头都没时分 → 就是一天 + } + if (!$eHasTime) { + return $head . ' 起'; // 结束没填时分:说"从几点开始"更准 + } + return $sDate . ($sHasTime ? ' ' . $sTime : '') . ' – ' . $eTime; + } + // 跨天:结束那天写月日(不重复年份) + return $head . ' – ' . date('n月j日', $te) . ($eHasTime ? ' ' . $eTime : ''); + } + /** * 行程类型的**名字**。 * diff --git a/app/Vehicle.php b/app/Vehicle.php index cadf4a3..0b551ce 100644 --- a/app/Vehicle.php +++ b/app/Vehicle.php @@ -48,9 +48,13 @@ final class Vehicle /** * 财务里已登记的车子(**只读引用**,不复制进 vehicles)。 * - * 取的是所有账本里 kind='car' 的账目 —— 用户说"直接读取就可以"。 - * 返回结构与自有车辆**同形**(id 用负数区分:`-账目id`,`from` 标来源), - * 这样模板里画下拉、画配置列表都能用同一套代码。 + * 🔴 **自 v24 第 17 批(用户第 3 条)起,界面上已不再使用** —— + * 用户原话「车辆下拉框,不需要出现什么财务里登记的车,只把车辆列出来就行了」。 + * 方法本身保留的两个理由: + * ① `vehicles.ledger_id` 是**软关联**(某台出行车在财务里对应哪条资产账目), + * 将来"按车统计油费/保养"要用它把两边的数字对起来; + * ② 删掉方法会让那层对应关系在代码里失去唯一的说明处(注释也跟着没了)。 + * **不要再把它接回选车下拉** —— 那正是这一批要改掉的东西。 */ public static function fromLedger(int $familyId): array { @@ -86,16 +90,25 @@ final class Vehicle } /** - * 选车下拉用的合并列表:财务的车子在前(用户已经录好了),自家车辆在后。 + * 选车下拉用的列表 —— **只有 vehicles 表里的车**。 * - * @return array 每项都带 `from` = 'ledger' | 'local' + * ⚠️ 用户 2026-10-05 第 17 批 第 3 条把上一批的做法**推翻**了: + * 「车辆下拉框,不需要出现什么财务里登记的车,只把车辆列出来就行了。」 + * 第 16 批第 8 条当时说的是"财务里的车直接读取就可以",这一批明确不要了。 + * 理由(用户的思路):**出行的车**与**财务里的车子账目**是两回事 —— + * 后者记的是"这台车值多少钱"(资产),没有车牌车型,混进选车列表里 + * 只会让人在"大众朗逸"和"家里的老车"之间发懵。 + * 所以现在选车只认 vehicles 表(自家 / 租的 / 借的都录在这里)。 + * + * ⚠️ `fromLedger()` 方法**暂时保留但不再被任何界面调用**(见下方注释): + * 财务里那些车是真实存在的资产,将来若要"按车统计油费/保养"还会用到; + * 删掉它反而会丢掉那层对应关系(`vehicles.ledger_id` 软关联还指着它)。 + * + * @return array 每项都带 `from` = '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; diff --git a/assets/css/app.css b/assets/css/app.css index 2dc8801..b28835a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -3335,3 +3335,46 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .trip-stats { gap: 8px 18px; } .ts-n { font-size: 17px; } } + +/* ============================================================ + v24 第 17 批:城市的聚合 / 高德地址 / 表单精简 + 用户 10 条(城市聚合、类型照 TripLog、只有自驾选车、飞机铁路只记车次、 + 高德地址、弹框选人、去角色标注、图片右侧显示聚合城市、时间到时分秒)。 + ============================================================ */ + +/* ---- 时间:精确到时分的那一行(比原来只到日期的那行略宽)---- */ +.node-time { font-variant-numeric: tabular-nums; } + +/* ---- 出发地 → 到达地:带导航链接 ---- */ +.node-route-line a { + color: var(--brand-ink); text-decoration: none; + border-bottom: 1px dashed rgba(168, 74, 40, .35); +} +.node-route-line a:hover { border-bottom-style: solid; } + +/* ---- 地址输入(高德联想)---- + 可见文本框 + 隐藏的 addr/lng/lat/city;下面一行显示"详细地址"与导航链接。 */ +.loc-field { display: flex; flex-direction: column; gap: 4px; } +.loc-label { font-size: 13px; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; } +.loc-ico { font-size: 14px; } +.loc-input { width: 100%; } +.loc-extra { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px; min-height: 18px; } +.loc-detail { + font-size: 12px; color: var(--muted); flex: 1 1 140px; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.loc-detail:empty { display: none; } +.loc-nav { + flex: 0 0 auto; font-size: 12px; color: var(--brand-ink); + text-decoration: none; white-space: nowrap; +} +.loc-nav:hover { text-decoration: underline; } +.loc-nav[hidden] { display: none; } + +/* ---- 修正表单:里面的字段少,用同一套 .field 网格 ---- */ +.rev-form .field { margin-bottom: 0; } + +/* ---- 窄屏:地址那两行不换行挤成一团 ---- */ +@media (max-width: 620px) { + .loc-detail { flex: 1 1 100%; } +} diff --git a/config.example.php b/config.example.php index 37bc160..3a842ec 100644 --- a/config.example.php +++ b/config.example.php @@ -34,6 +34,26 @@ return [ // 生成方式:openssl rand -hex 16 'app_key' => '请填写32位随机十六进制串', + // ---------------- 高德地图(旅程的地址联想 / 逆地理 / 导航)---------------- + // 用户 2026-10-05 第 17 批 第 3 条:「出发地和到达地,都用高德地图的接口, + // 记录详细地址」。 + // + // ⚠️ 这里要用到**两把不同的 key**(在高德开放平台控制台分别申请): + // · `key` —— 既是 **Web 服务** key(服务端逆地理 regeo), + // 也是 **JS API** key(前端地址输入联想)。 + // 若两者分开申请,把 Web 服务的填这里, + // JS API 那把填在 `js_key`(见下); + // · `security_js` —— JS API 的**安全密钥**(2021-12-02 之后申请的 key 都要配)。 + // + // 不填也不影响使用:地址联想不加载、城市聚合自动回落到**离线解析** + // (从地址文本里提「XX市」),功能都还在,只是联想与逆地理的准确度差一些。 + 'amap' => [ + 'key' => '', + 'security_js' => '', + // 高德 URI API 的 `src`(调用来源,只用于统计,任意英数字串都行) + 'nav_src' => '', + ], + // ---------------- 调试 ---------------- // true 时引导页会打印数据库连接错误的详情。 // ⚠️ 线上务必保持 false —— 错误详情里可能带数据库账号名。 diff --git a/index.php b/index.php index c67f898..f5c541a 100644 --- a/index.php +++ b/index.php @@ -2993,7 +2993,10 @@ switch ($r) { $familyId = (int)Auth::familyId(); View::render('trips/vehicles', [ 'title' => '车辆管理', - 'ledger' => Vehicle::fromLedger($familyId), + // ⚠️ v24 第 17 批 第 3 条:**不再传** ledger(财务里的车)—— + // 用户原话「车辆下拉框,不需要出现什么财务里登记的车」。 + // 模板里那一整块也删掉了。fromLedger() 方法仍在模型层保留 + // (将来按车统计油费/保养要用它跟 vehicles.ledger_id 对上)。 'mine' => Vehicle::all($familyId), ], 'trips'); break; @@ -3142,11 +3145,9 @@ switch ($r) { $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; - } + // ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"), + // 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除, + // 数据库那两列保留,详见 Trip::saveNode 的处理)。 $d = [ 'title' => trim((string)($_POST['title'] ?? '')), 'detail' => trim((string)($_POST['detail'] ?? '')), @@ -3159,17 +3160,25 @@ switch ($r) { // 新建时用户在表单里选的「计划 / 实际」;编辑时表单带的是隐藏 stage 'stage' => (string)($_POST['stage_pick'] ?? $_POST['stage'] ?? 'plan'), 'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId), - 'roles' => $roles, - // ---- v21:交通与起终点(TripLog 同款)---- + // ---- 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'] ?? '')), + // ---- v22:出发地 / 到达地(地点名 + 详细地址 + 坐标 + 城市)---- + // 用户第 17 批 第 3 条:「出发地和到达地,都用高德地图的接口,记录详细地址」。 + // 城市由前端联想带回(`*_city`),带不回时 Trip::saveNode 里用 App\Geo 解析。 'from_place' => trim((string)($_POST['from_place'] ?? '')), 'to_place' => trim((string)($_POST['to_place'] ?? '')), + 'from_addr' => trim((string)($_POST['from_addr'] ?? '')), + 'to_addr' => trim((string)($_POST['to_addr'] ?? '')), + 'from_lng' => trim((string)($_POST['from_lng'] ?? '')), + 'from_lat' => trim((string)($_POST['from_lat'] ?? '')), + 'to_lng' => trim((string)($_POST['to_lng'] ?? '')), + 'to_lat' => trim((string)($_POST['to_lat'] ?? '')), + 'from_city' => trim((string)($_POST['from_city'] ?? '')), + 'to_city' => trim((string)($_POST['to_city'] ?? '')), ]; if ($d['title'] === '') { flash('err', '这段行程得有个名字。'); diff --git a/templates/trips/_loc_field.php b/templates/trips/_loc_field.php new file mode 100644 index 0000000..301d9e1 --- /dev/null +++ b/templates/trips/_loc_field.php @@ -0,0 +1,79 @@ +`**:这个片段在一个页面里会出现两次(出发地/到达地), + * for/id 对不上就会互相抢焦点。用包裹式 label。 + * + * 用法(require 前设好): + * $locPrefix 前缀('from' | 'to'),决定所有 id 与字段名 + * $locLabel 标签文字(「出发地」「到达地」) + * $locIcon 标签前的小图标(可选) + * $locPlaceholder 占位提示 + * $locRow 行数据(可选;编辑时回填):name/address/lng/lat 四个键 + */ + +declare(strict_types=1); + +$locPrefix = (string)($locPrefix ?? 'from'); +$locLabel = (string)($locLabel ?? '地点'); +$locIcon = (string)($locIcon ?? ''); +$locPlaceholder = (string)($locPlaceholder ?? '输入地址,如「青岛北站」'); +$locRow = is_array($locRow ?? null) ? $locRow : []; + +$locName = (string)($locRow['name'] ?? ''); +$locAddr = (string)($locRow['address'] ?? ''); +$locLng = (string)($locRow['lng'] ?? ''); +$locLat = (string)($locRow['lat'] ?? ''); + +// 导航链接:有坐标走 navigation,没坐标退化成"按名字搜" +$locNav = \App\Geo::navUrl($locLng, $locLat, $locName); +$locId = 'loc-' . $locPrefix; +?> +
+ + + + + + + + + + +
+ 默认是计划的时间,改成实际发生的即可。 -
- - -
+
-
- - - -
+
diff --git a/templates/trips/node_form.php b/templates/trips/node_form.php index c723be2..ab1a1e7 100644 --- a/templates/trips/node_form.php +++ b/templates/trips/node_form.php @@ -1,31 +1,38 @@ ` 内联): - * TripLog 的节点表单有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/ - * 参与人/照片/状态),内联在主页面里会把整个详情页撑得极长; - * 更要命的是**编辑**时每个行程内联一份表单 → 页面上 N 份表单互相干扰 - * (元素 id 撞车、密码管理器逐个扫描变卡)。这正是用户说的"把页面撑破"。 + * TripLog 的节点表单有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/ + * 参与人/照片/状态),内联在主页面里会把整个详情页撑得极长; + * 更要命的是**编辑**时每个行程内联一份表单 → 页面上 N 份表单互相干扰 + * (元素 id 撞车、密码管理器逐个扫描变卡)。这正是用户说的"把页面撑破"。 * - * 字段顺序照 TripLog 的 nodes/form.php: - * 类型 → 起止时间 → 标题 → 出行方式(+车辆+交通细节)→ 起点 → 终点 - * → 内容 → 参与人 → 照片 →(编辑时)状态与花销。 + * 字段顺序照 TripLog 的 nodes/form.php(第 17 批又砍掉了两处冗余): + * 类型 → 标题 → 起止时间 → 出行方式(自驾才有车辆)→ 线路/车次/承运方 + * → 出发地 → 到达地 → 内容 → 参与人 → 照片 →(编辑时)状态与花销。 * * 变量(require 前设好): - * $ev 所属旅程(events 行,带 title) - * $n 被编辑的行程;null = 新增 - * $stage 'plan'|'actual'(新增时由按钮决定;编辑时取行程自己的) - * $people 名册 - * $types 节点类型(NodeType::all) - * $vehicles 可挑的车(Vehicle::pickable,含"来自财务"的只读候选) + * $ev 所属旅程(events 行,带 title) + * $n 被编辑的行程;null = 新增 + * $stage 'plan'|'actual'(新增时由按钮决定;编辑时取行程自己的) + * $people 名册(**只用于弹框组件**,不再平铺成一长串勾选框) + * $types 节点类型(NodeType::all) + * $vehicles 可挑的车(Vehicle::pickable —— 第 17 批起**只有自家车辆库**) * $expenses 已有花销(编辑时) - * $errors 校验错误 + * $errors 校验错误 */ $isEdit = !empty($n); $canWrite = \App\Auth::canWrite(); @@ -41,15 +48,30 @@ 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']]); + +// 车辆下拉:只有**自驾**才显示(用户第 3 条)。服务端先按已存值决定初始显隐, +// 之后由 app.js 里的 data-mode-select 跟着下拉变。 +$needsVehicle = \App\Trip::modeNeedsVehicle($curMode); +// 车次/航班号:只有火车与飞机才显示(用户第 3 条) +$needsTicket = in_array($curMode, \App\Trip::TICKET_MODES, true); + +// 起终点(编辑时从库里回填;老数据列是 varchar,坐标是 decimal) +$fromRow = [ + 'name' => (string)($n['from_place'] ?? ''), + 'address' => (string)($n['from_addr'] ?? ''), + 'lng' => (string)($n['from_lng'] ?? ''), + 'lat' => (string)($n['from_lat'] ?? ''), + 'city' => (string)($n['from_city'] ?? ''), +]; +$toRow = [ + 'name' => (string)($n['to_place'] ?? ''), + 'address' => (string)($n['to_addr'] ?? ''), + 'lng' => (string)($n['to_lng'] ?? ''), + 'lat' => (string)($n['to_lat'] ?? ''), + 'city' => (string)($n['to_city'] ?? ''), +]; +$amap = \App\Geo::jsConfig(); ?>

@@ -76,7 +98,7 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
- +
@@ -135,77 +156,65 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
-
- \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/templates/trips/show.php b/templates/trips/show.php index c9cbab7..955cd13 100644 --- a/templates/trips/show.php +++ b/templates/trips/show.php @@ -75,7 +75,10 @@ $groupMeta = [ ⚠️ 第 1 条:「题图应该按照 16:9 显示,因为添加的时候,是按照这个比例裁剪的, 现在的显示比例不对」—— 之前 CSS 写的是 4:3,把 16:9 的图裁掉两边。 16:9 这个数字与 ImageSlot 里 cover 槽位的比例**必须一致**, - 部署断言里有一条专门比对它。 */ ?> + 部署断言里有一条专门比对它。 + ⚠️ 第 17 批 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**, + 而不是终点地址。」—— 城市来自 `Trip::cities()`(出发地 ∪ 到达地,去重), + 见 summary() → cities()。 */ ?>
@@ -178,7 +181,10 @@ foreach ($bysStage as $lst) { $shown += count($lst); } @@ -194,9 +200,6 @@ foreach ($bysStage as $lst) { $shown += count($lst); } 0): ?> 已修正 次 - - 📍 - @@ -209,14 +212,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
- +
@@ -224,9 +228,27 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
+
- → + + + + + + + → + + + + +
@@ -238,15 +260,12 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
💰 这一段花了
+
- - - - - - +
@@ -272,7 +291,7 @@ foreach ($bysStage as $lst) { $shown += count($lst); } - + diff --git a/templates/trips/vehicles.php b/templates/trips/vehicles.php index 2cb4987..955bac0 100644 --- a/templates/trips/vehicles.php +++ b/templates/trips/vehicles.php @@ -1,20 +1,24 @@ @@ -27,39 +31,10 @@ $back = url('trips');

- -
-

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

- -

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

- -
    - -
  • - 🚗 - - 来自财务 -
  • - -
-

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

- -
- -

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