v24 第 17 批:城市聚合 / 高德地址 / 表单精简(用户 10 条)

用户 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 与源库对齐。
This commit is contained in:
2026-10-05 16:45:10 +08:00
parent 2e51d3f5e4
commit c30ca99d22
13 changed files with 1158 additions and 235 deletions
+325
View File
@@ -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'] ?? '')) : '',
];
}
}
+79 -2
View File
@@ -31,6 +31,29 @@ final class NodeType
'other' => ['其他', '📌'],
];
/**
* TripLog(trip.yangyq.net)实际用过的类型 —— 用户第 17 批 第 2 条要求
* 「你看看 triplog 里面都有哪些,都拿过来」。
*
* ⚠️ 这份清单是**照着线上库抄的**(7 种,见 Schema 里那句注释)。
* 与 BUILTIN 的差别就两种:
* · 这里**没有「住宿」** —— 本地有,保留(家庭出游住宿是刚需,
* TripLog 当时用「休息」兼职了这件事,不代表我们也要那样);
* · 这里**多「休息/突发/购物/拜访亲友」** —— 本地要补上。
* 图标也照 TripLog 抄(🍽️ 😴 ⚠️ 🛍️ 🏠),保持两边认得出是同一类。
*
* @var array<string,string> 名字 => 图标
*/
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 = ?');
+57 -1
View File
@@ -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',
+233 -41
View File
@@ -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 : '');
}
/**
* 行程类型的**名字**。
*
+21 -8
View File
@@ -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;
+43
View File
@@ -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%; }
}
+20
View File
@@ -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 —— 错误详情里可能带数据库账号名。
+19 -10
View File
@@ -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', '这段行程得有个名字。');
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* 地址输入(高德联想 + 详细地址 + 坐标),v24 第 17 批 用户第 3 条。
*
* 用户原话:「出发地和到达地,都用**高德地图的接口**,记录详细地址,
* 这个功能,triplog 也有,如果之前有数据,里面记录了行程的详细地址,
* 也都把数据迁移过来。」
*
* 界面照 TripLog 的 nodes/form.php:
* 一个可见的文本框(人能看到、能改、能自己敲)+ 四个隐藏字段
* (name / address / lng / lat —— 选中联想项后由 JS 填上)。
* **有坐标时**旁边出一个「🧭 导航到这里」链接(高德 URI API)。
*
* ⚠️ 三个必须做对的细节:
* ① **没配 key 也要能用**:SDK 不加载时文本框就是普通输入框,
* 用户手敲地名照样能存(只是没有详细地址与坐标)。
* 降级路径不能是"功能消失",只能是"辅助变少"。
* ② **assoc 用 map 而不是 select**:TripLog 用 AMap.AutoComplete 直接挂到 input 上,
* 能边打边出候选,比"点一下弹搜索框"顺手得多。
* ③ **不要 `<label for>`**:这个片段在一个页面里会出现两次(出发地/到达地),
* 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;
?>
<div class="loc-field" data-loc="<?= e($locPrefix) ?>">
<label class="loc-label" for="<?= e($locId) ?>-input">
<?php if ($locIcon !== ''): ?><span class="loc-ico"><?= e($locIcon) ?></span><?php endif; ?>
<?= e($locLabel) ?>
</label>
<input class="input loc-input" type="text"
id="<?= e($locId) ?>-input"
name="<?= e($locPrefix) ?>_place"
value="<?= e($locName) ?>"
placeholder="<?= e($locPlaceholder) ?>"
autocomplete="off"
data-loc-input>
<?php /* 高德没加载 / 用户自己敲的地名:这四个字段就是兜底存放处 */ ?>
<input type="hidden" name="<?= e($locPrefix) ?>_addr" value="<?= e($locAddr) ?>">
<input type="hidden" name="<?= e($locPrefix) ?>_lng" value="<?= e($locLng) ?>">
<input type="hidden" name="<?= e($locPrefix) ?>_lat" value="<?= e($locLat) ?>">
<?php /* 城市由前端选中时带回(高德 addressComponent 直接给城市名),
带不回就由服务端 App\Geo 解析 —— 两条路都通 */ ?>
<input type="hidden" name="<?= e($locPrefix) ?>_city" value="<?= e((string)($locRow['city'] ?? '')) ?>">
<div class="loc-extra">
<span class="loc-detail" data-loc-detail><?= e($locAddr !== '' && $locAddr !== $locName ? $locAddr : '') ?></span>
<a class="loc-nav" data-loc-nav
href="<?= e($locNav !== '' ? $locNav : '#') ?>"
target="_blank" rel="noopener"
<?= $locNav === '' ? 'hidden' : '' ?>>🧭 导航到这里</a>
</div>
</div>
<?php
// 只 unset 本片段自己的变量,宿主模板一个都不碰(与 _datetime.php 同一约定)
unset($locPrefix, $locLabel, $locIcon, $locPlaceholder, $locRow,
$locName, $locAddr, $locLng, $locLat, $locNav, $locId);
+15 -14
View File
@@ -39,11 +39,12 @@ $rvN = (int)($rvNode['revision_n'] ?? 0);
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">默认是计划的时间,改成实际发生的即可。</span>
</div>
<div class="field">
<label for="<?= e($rvPrefix) ?>-place">实际地点</label>
<input class="input" id="<?= e($rvPrefix) ?>-place" name="place"
value="<?= e($rvNode['place'] ?? '') ?>" placeholder="没变就留空">
</div>
<?php /* 用户第 17 批 第 3 条:「参与人那里,和记录电影人一样,**弹框选择**,
不要全列出来,太多了。」——修正表单也是"选参与人",同样走统一组件。
⚠️ 本批同时**去掉了「实际地点」输入框**:用户第 1 条把"地点"这件事
收敛成了**出发地 / 到达地**(城市由它们聚合),单留一个 free-text
「实际地点」会让人分不清它和出发地是什么关系。
修正要记的就是用户第 13 批原话里那三样:实际时间 / 实际参与人 / 说明。 */ ?>
<div class="field full">
<label for="<?= e($rvPrefix) ?>-detail">这次修正的说明</label>
<input class="input" id="<?= e($rvPrefix) ?>-detail" name="detail"
@@ -52,15 +53,15 @@ $rvN = (int)($rvNode['revision_n'] ?? 0);
<?php if (!empty($rvPeople)): ?>
<div class="field full">
<label>实际参与的人</label>
<div class="nf-members">
<?php foreach ($rvPeople as $p): ?>
<label class="check-item">
<input type="checkbox" name="members[]" value="<?= (int)$p['id'] ?>"
<?= in_array((int)$p['id'], $rvMembers, true) ? 'checked' : '' ?>>
<span><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php
$ppName = 'members[]';
$ppPeople = $rvPeople;
$ppSelected = $rvMembers;
$ppMode = 'multi';
$ppOpenBtn = '+ 选择实际参与的人';
$ppTitle = '这次实际去的都有谁';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<?php endif; ?>
</div>
+218 -104
View File
@@ -1,31 +1,38 @@
<?php
/**
* 一段行程的新增 / 编辑 —— **独立页面**(v24 第 16 批 用户第 5 / 6 / 7 条)。
* 一段行程的新增 / 编辑 —— **独立页面**(v24 第 16 批 用户第 5 / 6 / 7 条;
* 第 17 批按用户第 3 条再改一轮)。
*
* 用户原话:
* · 第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
* · 第 6 条:「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
* · 第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
* · 第 17 批 第 3 条(本次):
* ① 「出行方式,**只有选择自驾**,才让选车辆」;
* ② 「飞机、铁路,只记录**车次、航班号**就可以…**座位什么的不记录了**」;
* ③ 「出发地和到达地,都用**高德地图的接口,记录详细地址**」;
* ④ 「参与人那里,和记录电影人一样,**弹框选择**,不要全列出来,太多了」;
* ⑤ 「给参与人标注角色这个功能**去掉**,不要了」。
*
* ⚠️ 为什么必须独立成页(而不是继续用 `<details>` 内联):
* 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();
?>
<div class="page-head">
<h1>
@@ -76,7 +98,7 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
<?php endif; ?>
<div class="form-grid">
<?php /* ---- 类型(TripLog 的第一个字段)---- */ ?>
<?php /* ---- 类型(TripLog 的第一个字段;第 17 批 第 2 条已补齐到 8 种)---- */ ?>
<div class="field">
<label for="nf-type">类型 *</label>
<select class="select" id="nf-type" name="node_type">
@@ -109,7 +131,7 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* ---- 起止时间:一个范围控件 ---- */ ?>
<?php /* ---- 起止时间:一个范围控件(精确到秒,用户第 17 批问"怎么不显示时分秒")---- */ ?>
<div class="field dt-range full">
<label>开始 / 结束</label>
<?php $dtName = 'starts_at'; $dtValue = $n['starts_at'] ?? '';
@@ -118,11 +140,10 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
$dtId = 'nf-e'; $dtSeconds = true; $dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填。</span>
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
</div>
<?php /* ---- 出行方式 + 车辆(TripLog 的 travel_modes)----
用户第 8 条:车辆从财务里**读**,也可以加自己的(租的 / 借的)。 */ ?>
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
<div class="field">
<label for="nf-mode">出行方式</label>
<select class="select" id="nf-mode" name="travel_mode" data-mode-select>
@@ -135,77 +156,65 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
</select>
</div>
<div class="field" data-mode-vehicle hidden>
<?php /* ---- 车辆:**只有自驾**才出现(用户第 17 批 第 3 条)----
用户原话:「出行方式,只有选择自驾,才让选车辆,车辆下拉框,
不需要出现什么财务里登记的车,只把车辆列出来就行了。」 */ ?>
<div class="field" data-mode-vehicle<?= $needsVehicle ? '' : ' hidden' ?>>
<label for="nf-veh">车辆</label>
<select class="select" id="nf-veh" name="vehicle_id">
<option value="">— 不指定 —</option>
<?php
$curVeh = (int)($n['vehicle_id'] ?? 0);
$ledgerShown = false;
foreach ($vehicles as $v):
$isLedger = (($v['from'] ?? '') === 'ledger');
if ($isLedger && !$ledgerShown) {
echo '<optgroup label="财务里已登记的车">';
$ledgerShown = true;
}
?>
<option value="<?= $isLedger ? (int)$v['id'] : (int)$v['id'] ?>"
<?= $curVeh === (int)$v['id'] ? ' selected' : '' ?>>
<?= e(\App\Vehicle::label($v) . ($isLedger ? '(来自财务)' : '')) ?>
<?php $curVeh = (int)($n['vehicle_id'] ?? 0); ?>
<?php foreach ($vehicles as $v): ?>
<option value="<?= (int)$v['id'] ?>"<?= $curVeh === (int)$v['id'] ? ' selected' : '' ?>>
<?= e(\App\Vehicle::label($v)) ?>
</option>
<?php endforeach; ?>
<?php if ($ledgerShown): ?></optgroup><?php endif; ?>
</select>
<span class="tip">
财务里登记过的车子会直接列在这里;租的车、借别人的车到
「设置 → 车辆管理」加一台。
<?php if (!$vehicles): ?>
车辆库还是空的 —— 到「设置 → 车辆管理」加一台(自家 / 租的 / 借的都能加)。
<?php else: ?>
租的车、借别人的车也到「设置 → 车辆管理」里加一台。
<?php endif; ?>
</span>
</div>
<?php /* ---- 交通细节(照 TripLog:线路名 / 车次 / 承运方 / 座位 / 登机口)---- */ ?>
<?php /* ---- 交通细节:**只有火车/飞机**才需要车次航班号(用户第 17 批 第 3 条)----
用户原话:「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
因为可能是多个人同行,座位什么的不记录了。」
⚠️ 所以这里**没有**「座位 / 车厢」与「登机口 / 站台」两个输入框
—— 它们对"一家人出行"没有意义(五六张票座位不在一起,
登机口出发当天才知道)。数据库那两列保留(老数据不丢),界面不再出现。 */ ?>
<div class="field" data-mode-ticket<?= $needsTicket ? '' : ' hidden' ?>>
<label for="nf-tripno">车次 / 航班号</label>
<input class="input" id="nf-tripno" name="trip_no"
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
<span class="tip">座位这类同团不同座的信息就不记了。</span>
</div>
<?php /* 线路名与承运方:火车/飞机时最有意义(「京沪高铁」「中国铁路」),
也允许其他方式填(比如「轮渡 5 号线」)—— 所以不按方式隐藏。 */ ?>
<div class="field">
<label for="nf-line">线路名</label>
<input class="input" id="nf-line" name="line_name"
value="<?= e(old('line_name', $n['line_name'] ?? '')) ?>" placeholder="如:京沪高铁、地铁 3 号线">
</div>
<div class="field">
<label for="nf-tripno">车次 / 航班号</label>
<input class="input" id="nf-tripno" name="trip_no"
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
</div>
<div class="field">
<label for="nf-carrier">承运方</label>
<input class="input" id="nf-carrier" name="carrier"
value="<?= e(old('carrier', $n['carrier'] ?? '')) ?>" placeholder="如:中国铁路、滴滴">
</div>
<div class="field">
<label for="nf-seat">座位 / 车厢</label>
<input class="input" id="nf-seat" name="seat_info"
value="<?= e(old('seat_info', $n['seat_info'] ?? '')) ?>" placeholder="如:7 车 12F">
</div>
<div class="field">
<label for="nf-gate">登机口 / 站台</label>
<input class="input" id="nf-gate" name="gate"
value="<?= e(old('gate', $n['gate'] ?? '')) ?>" placeholder="如:T3 登机口 B12">
value="<?= e(old('carrier', $n['carrier'] ?? '')) ?>" placeholder="如:中国铁路、国航">
</div>
<?php /* ---- 起点 / 终点(TripLog 的 start/end_location)---- */ ?>
<?php /* ---- 出发地 / 到达地:高德联想 + 详细地址(用户第 17 批 第 3 条)----
用户原话:「出发地和到达地,都用高德地图的接口,记录详细地址」。 */ ?>
<div class="field">
<label for="nf-from">出发地</label>
<input class="input" id="nf-from" name="from_place"
value="<?= e(old('from_place', $n['from_place'] ?? '')) ?>" placeholder="如:家里">
<?php $locPrefix = 'from'; $locLabel = '出发地'; $locIcon = '🚩';
$locPlaceholder = '输入地址,如「家里」「青岛北站」'; $locRow = $fromRow;
require __DIR__ . '/_loc_field.php'; ?>
</div>
<div class="field">
<label for="nf-to">到达地</label>
<input class="input" id="nf-to" name="to_place"
value="<?= e(old('to_place', $n['to_place'] ?? '')) ?>" placeholder="如:青岛北站">
</div>
<div class="field">
<label for="nf-place">地点</label>
<input class="input" id="nf-place" name="place"
value="<?= e(old('place', $n['place'] ?? '')) ?>" placeholder="这一段主要在哪(用于统计城市)">
<span class="tip">旅程页上的"去了几个城市"就是按这里统计的。</span>
<?php $locPrefix = 'to'; $locLabel = '到达地'; $locIcon = '🏁';
$locPlaceholder = '输入地址,如「青岛北站」「栈桥」'; $locRow = $toRow;
require __DIR__ . '/_loc_field.php'; ?>
</div>
<div class="field">
@@ -225,40 +234,30 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
placeholder="这一段发生了什么"><?= e(old('detail', $n['detail'] ?? '')) ?></textarea>
</div>
<?php /* ---- 参与人(+角色,TripLog 的 role_tag)---- */ ?>
<?php /* ---- 参与人:**弹框选人**(用户第 17 批 第 3 条)----
用户原话:「参与人那里,和记录电影人一样,弹框选择,不要全列出来,太多了。
给参与人标注角色这个功能去掉,不要了。」
⚠️ 所以:① 用全站统一的选人组件(_person_picker.php,与观影同一个);
② **没有**角色输入框了(上一批那个 <details> 折叠区删掉了)。 */ ?>
<div class="field full">
<label>参与的人</label>
<?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<div class="nf-members">
<?php foreach ($people as $p): ?>
<?php $pid = (int)$p['id']; ?>
<label class="check-item">
<input type="checkbox" name="members[]" value="<?= $pid ?>"
<?= in_array($pid, $picked, true) ? 'checked' : '' ?>>
<span><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
<details class="role-set">
<summary class="link-quiet">给参与的人标个角色(可选)</summary>
<div class="role-grid">
<?php foreach ($people as $p): ?>
<?php $pid = (int)$p['id']; ?>
<div class="role-row">
<span class="role-who"><?= e(person_name($p)) ?></span>
<input class="input" name="roles[<?= $pid ?>]" maxlength="30"
value="<?= e($roles[$pid] ?? '') ?>" placeholder="如:司机 / 摄影">
</div>
<?php endforeach; ?>
</div>
<span class="tip">不填也没关系;填了会显示在那个人名字旁边。</span>
</details>
<?php
$ppName = 'members[]';
$ppPeople = $people;
$ppSelected = $picked;
$ppMode = 'multi';
$ppOpenBtn = '+ 添加参与的人';
$ppTitle = '这一段里有谁';
$ppTip = '不勾 = 这段没单独记参与人(旅程页仍会按各段汇总)。';
require __DIR__ . '/../_person_picker.php';
?>
<?php endif; ?>
</div>
<?php /* ---- 照片(用户第 3 条:图片视频都针对一次具体行程)---- */ ?>
<?php /* ---- 照片(用户第 16 批 第 3 条:图片视频都针对一次具体行程)---- */ ?>
<div class="field full">
<label for="nf-photo">这一段行程的照片</label>
<?php if ($isEdit && !empty($n['attachs'])): ?>
@@ -295,7 +294,7 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
</div>
<?php /* ==================== 花销(只在编辑时出现)====================
用户第 2 条要"一共花了多少钱"、本次确认「行程级花销记录」。
用户第 2 条要"一共花了多少钱"、第 16 批确认「行程级花销记录」。
⚠️ 新增时不给花销面板:那时行程 id 还不存在,花销挂不上(先存行程再加花销,
少一种"提交了但没存上"的状态)。 */ ?>
<?php if ($isEdit): ?>
@@ -361,4 +360,119 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
</div>
</form>
</div>
<?php endif; ?>
<?php endif; ?>
<?php /* ==================== 高德 SDK(按需加载,与 TripLog 同一套做法)====================
⚠️ SDK 加载**失败也必须能用**:文本框就是普通输入框,用户手敲地名照样能存。
所以这段脚本里所有 AMap 的用法都放在 `__AMAP_LOADED` 判断之后,
并且给"搜索这个地名"兜底链接单独一条路径(不依赖 SDK)。 */ ?>
<?php if ($amap['key'] !== ''): ?>
<script>
window._AMapSecurityConfig = { securityJsCode: '<?= e($amap['security']) ?>' };
window.__AMAP_LOADED = false;
</script>
<script src="https://webapi.amap.com/maps?v=2.0&key=<?= e($amap['key']) ?>&plugin=AMap.AutoComplete"
onload="window.__AMAP_LOADED = true;"
onerror="window.__AMAP_ERROR = 'script load failed';"></script>
<?php endif; ?>
<script>
/* ============================================================
地址联想(v24 第 17 批 用户第 3 条)
照 TripLog 的做法:AMap.AutoComplete 直接挂在 input 上,边打边出候选;
选中后把 name / address / lng / lat / city **同时**写进五个字段。
⚠️ 高德没加载时整个块跳过,input 仍是普通文本框(降级可用)。
⚠️ 用 IIFE 包起来:这个页面只有一份表单,但函数名别污染全局
(app.js 里也有通用初始化,命名撞了会互相覆盖)。
============================================================ */
(function () {
'use strict';
function bindLoc(prefix) {
var input = document.getElementById('loc-' + prefix + '-input');
if (!input) { return; }
var wrap = input.closest('.loc-field');
if (!wrap) { return; }
var fAddr = wrap.querySelector('input[name="' + prefix + '_addr"]');
var fLng = wrap.querySelector('input[name="' + prefix + '_lng"]');
var fLat = wrap.querySelector('input[name="' + prefix + '_lat"]');
var fCity = wrap.querySelector('input[name="' + prefix + '_city"]');
var dTail = wrap.querySelector('[data-loc-detail]');
var navEl = wrap.querySelector('[data-loc-nav]');
/* 人选完之后(或手敲完)更新"详细地址"那行字与导航链接。
导航链接用高德 URI API —— **没有 SDK 也能用**,所以不放在 __AMAP_LOADED 里。 */
function paint() {
var name = input.value.trim();
var addr = fAddr ? fAddr.value : '';
var lng = fLng ? fLng.value : '';
var lat = fLat ? fLat.value : '';
if (dTail) { dTail.textContent = (addr && addr !== name) ? addr : ''; }
if (navEl) {
if (lng !== '' && lat !== '') {
navEl.href = 'https://uri.amap.com/navigation?to=' + encodeURIComponent(lng + ',' + lat + (name ? ',' + name : ''))
+ '&mode=car&policy=1&coordinate=gaode&callnative=1';
navEl.hidden = false;
} else if (name !== '') {
navEl.href = 'https://uri.amap.com/search?keyword=' + encodeURIComponent(name);
navEl.hidden = false;
} else {
navEl.hidden = true;
}
}
}
// 手敲也要更新(用户可能不用联想,直接打字)
input.addEventListener('change', paint);
input.addEventListener('blur', paint);
if (!window.__AMAP_LOADED || typeof AMap === 'undefined' || !AMap.AutoComplete) {
return; /* 降级:没有联想,但输入与保存都正常 */
}
try {
var ac = new AMap.AutoComplete({ input: input });
ac.on('select', function (e) {
var d = (e && e.poi) ? e.poi : null;
if (!d) { return; }
var loc = d.location || null; // 可能是 AMap.LngLat,也可能是 {lng,lat}
var lng = loc ? (loc.lng !== undefined ? loc.lng : (loc.getLng ? loc.getLng() : '')) : '';
var lat = loc ? (loc.lat !== undefined ? loc.lat : (loc.getLat ? loc.getLat() : '')) : '';
if (fAddr) { fAddr.value = d.address || ''; }
if (fLng) { fLng.value = (lng === null || lng === undefined) ? '' : lng; }
if (fLat) { fLat.value = (lat === null || lat === undefined) ? '' : lat; }
/* 高德把城市放在 addressComponent / pname+cityname 上 —— 尽量带回城市,
省掉服务端一次逆地理请求(用户第 1 条要的就是城市)。 */
if (fCity) {
if (d.addressComponent && d.addressComponent.city) {
fCity.value = d.addressComponent.city;
} else if (d.cityname) {
fCity.value = d.cityname;
} else {
fCity.value = '';
}
}
paint();
});
} catch (err) {
/* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级,不打扰用户 */
}
}
bindLoc('from');
bindLoc('to');
/* 出行方式 → 显隐车辆 / 车次(用户第 17 批 第 3 条) */
var modeSel = document.querySelector('[data-mode-select]');
if (modeSel) {
var vehBox = document.querySelector('[data-mode-vehicle]');
var tikBox = document.querySelector('[data-mode-ticket]');
var DRIVE = ['drive']; // 与 Trip::DRIVE_MODES 一致
var TICKET = ['train', 'plane']; // 与 Trip::TICKET_MODES 一致
var sync = function () {
var v = modeSel.value;
if (vehBox) { vehBox.hidden = DRIVE.indexOf(v) === -1; }
if (tikBox) { tikBox.hidden = TICKET.indexOf(v) === -1; }
};
modeSel.addEventListener('change', sync);
sync();
}
})();
</script>
+35 -16
View File
@@ -75,7 +75,10 @@ $groupMeta = [
⚠️ 第 1 条:「题图应该按照 16:9 显示,因为添加的时候,是按照这个比例裁剪的,
现在的显示比例不对」—— 之前 CSS 写的是 4:3,把 16:9 的图裁掉两边。
16:9 这个数字与 ImageSlot 里 cover 槽位的比例**必须一致**,
部署断言里有一条专门比对它。 */ ?>
部署断言里有一条专门比对它。
⚠️ 第 17 批 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,
而不是终点地址。」—— 城市来自 `Trip::cities()`(出发地 ∪ 到达地,去重),
见 summary() → cities()。 */ ?>
<div class="card card-pad trip-hero">
<div class="trip-hero-cover">
<?php if (!empty($t['cover_key'])): ?>
@@ -178,7 +181,10 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php foreach ($list as $n): ?>
<?php
$nid = (int)$n['id'];
$span = \App\Trip::formatSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null);
// ⚠️ 第 17 批 第 4 条末:「行程中,时间明明具体到了时分秒,怎么不显示?」
// → 行程行改用 nodeSpan()(精确到时分),**不再**用只到日期的 formatSpan()。
// formatSpan 那个精度是给"旅程"(容器)用的。
$span = \App\Trip::nodeSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null);
$mode = (string)($n['travel_mode'] ?? '');
$spent = (int)($n['spent_cents'] ?? 0);
?>
@@ -194,9 +200,6 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<span class="rev-badge" title="已修正过多次">已修正 <?= (int)$n['revision_n'] ?> 次</span>
<?php endif; ?>
<?php if (!empty($n['place'])): ?>
<span class="node-place">📍 <?= e((string)$n['place']) ?></span>
<?php endif; ?>
<?php if ($mode !== ''): ?>
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?></span>
<?php endif; ?>
@@ -209,14 +212,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
</div>
<?php endif; ?>
<?php /* 交通细节(TripLog 同款):只显示**填过的**那几个,没填就不占地方 */ ?>
<?php /* 交通细节:**只保留**「线路名 / 车次航班号 / 承运方」。
⚠️ 第 17 批 第 3 条明确「座位什么的不记录了」→
座位 / 车厢、登机口 / 站台**不再出现在这里**
(数据库那两列还留着老数据,只是不再展示)。 */ ?>
<?php
$traffic = [];
if (!empty($n['line_name'])) { $traffic[] = (string)$n['line_name']; }
if (!empty($n['trip_no'])) { $traffic[] = (string)$n['trip_no']; }
if (!empty($n['carrier'])) { $traffic[] = (string)$n['carrier']; }
if (!empty($n['seat_info'])) { $traffic[] = '座位 ' . (string)$n['seat_info']; }
if (!empty($n['gate'])) { $traffic[] = '登机口/站台 ' . (string)$n['gate']; }
?>
<?php if ($traffic): ?>
<div class="node-traffic">
@@ -224,9 +228,27 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
</div>
<?php endif; ?>
<?php /* 出发地 → 到达地(第 17 批 第 3 条要记详细地址;
这里显示地点名,详细地址放 title 里悬停可见 + 带导航链接)。 */ ?>
<?php if (!empty($n['from_place']) || !empty($n['to_place'])): ?>
<div class="node-route-line">
<?= e((string)($n['from_place'] ?? '—')) ?> <span class="arrow">→</span> <?= e((string)($n['to_place'] ?? '—')) ?>
<?php
$navFrom = \App\Geo::navUrl($n['from_lng'] ?? null, $n['from_lat'] ?? null, (string)($n['from_place'] ?? ''));
$navTo = \App\Geo::navUrl($n['to_lng'] ?? null, $n['to_lat'] ?? null, (string)($n['to_place'] ?? ''));
?>
<?php if ($navFrom !== ''): ?>
<a href="<?= e($navFrom) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a>
<?php else: ?>
<?= e((string)($n['from_place'] ?? '—')) ?>
<?php endif; ?>
<span class="arrow">→</span>
<?php if ($navTo !== ''): ?>
<a href="<?= e($navTo) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a>
<?php else: ?>
<?= e((string)($n['to_place'] ?? '—')) ?>
<?php endif; ?>
</div>
<?php endif; ?>
@@ -238,15 +260,12 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<div class="node-spent">💰 这一段花了 <?= e(\App\Expense::money($spent)) ?></div>
<?php endif; ?>
<?php /* ⚠️ 第 17 批 第 3 条:「给参与人标注角色这个功能去掉,不要了。」
所以这里**不再渲染** role_tag(数据库里老数据的角色还在,只是不显示)。 */ ?>
<?php if (!empty($n['members'])): ?>
<div class="trip-people">
<?php foreach ($n['members'] as $mm): ?>
<span class="node-member">
<?= person_chip($mm) ?>
<?php if (!empty($mm['role_tag'])): ?>
<span class="node-role"><?= e((string)$mm['role_tag']) ?></span>
<?php endif; ?>
</span>
<?= person_chip($mm) ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
@@ -272,7 +291,7 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php foreach ($n['revisions'] as $rv): ?>
<tr>
<td><?= (int)$rv['seq'] === 1 ? '原计划' : '第 ' . ((int)$rv['seq'] - 1) . ' 次修正' ?></td>
<td class="small"><?= e(\App\Trip::formatSpan($rv['starts_at'] ?? null, $rv['ends_at'] ?? null)) ?></td>
<td class="small"><?= e(\App\Trip::nodeSpan($rv['starts_at'] ?? null, $rv['ends_at'] ?? null)) ?></td>
<td><?= e((string)($rv['place'] ?? '')) ?></td>
<td class="small"><?= e((string)($rv['detail'] ?? '')) ?></td>
</tr>
+14 -39
View File
@@ -1,20 +1,24 @@
<?php
/**
* 车辆管理(v24 第 16 批 用户第 8 条)。
* 车辆管理(v24 第 16 批 用户第 8 条;第 17 批 第 3 条改)。
*
* 用户原话:「注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加
* 那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
* 比如租的车,借别人的车。」
* 用户原话(第 16 批 第 8 条):
* 「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
* 注意,我们在财务中,已经把车辆添加进去了…但是可以添加其他的车辆,
* 比如租的车,借别人的车。」
*
* 所以这一页有两块:
* ① **财务里已登记的车**(只读)—— 只列出来,不给改不给删(那是财务的数据);
* ② **出行用的车**(本表)—— 自家 / 租的 / 借的,可增删改。
* 用户原话(**第 17 批 第 3 条 —— 本次,推翻上一批的做法**):
* 「车辆下拉框,**不需要出现什么财务里登记的车**,只把车辆列出来就行了。」
*
* ⚠️ 所以这一页现在**只有一块**:「出行用的车」(自家 / 租的 / 借的)。
* 原来那块「财务里已登记的车」整块删掉了 —— 财务里的 kind=car 是**资产账目**
* ("这台车值多少钱"),它没有车牌车型,混进选车列表只会让人发懵。
* `Vehicle::fromLedger()` 方法保留在模型层(将来按车统计油费/保养还要用它
* 跟 `vehicles.ledger_id` 对上),但**不再被任何界面调用**。
*
* @param array $ledger Vehicle::fromLedger() 的结果(只读)
* @param array $mine Vehicle::all() 的结果
*/
$canWrite = \App\Auth::canWrite();
$ledger = $ledger ?? [];
$mine = $mine ?? [];
$back = url('trips');
?>
@@ -27,39 +31,10 @@ $back = url('trips');
</div>
</div>
<?php /* ---- ① 来自财务的车(只读引用)---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
财务里已登记的车
<span class="hint">只读 —— 要改请到财务模块</span>
</h2>
<?php if (!$ledger): ?>
<p class="empty-hint">
财务里还没有登记车子。到「财务 → 记一笔」里把车子作为一项资产记下来,
这里就会自动出现(<b>不必在这里重复添加</b>)。
</p>
<?php else: ?>
<ul class="veh-list">
<?php foreach ($ledger as $v): ?>
<li class="veh-row veh-ro">
<span class="veh-ico">🚗</span>
<span class="veh-name"><?= e((string)$v['name']) ?></span>
<span class="veh-tag">来自财务</span>
</li>
<?php endforeach; ?>
</ul>
<p class="small muted">
这些是财务里的**资产记录**(有一台车值多少钱),不一定有车牌与车型。
出行要选车时它们也会出现在下拉里,标着「来自财务」。
</p>
<?php endif; ?>
</div>
<?php /* ---- ② 出行用的车(本表)---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
出行用的车
<span class="hint">自家车可以录一台方便选;租的车、借的车录在这里</span>
<span class="hint">自家车录一台方便选;租的车、借的车也录在这里</span>
</h2>
<?php if ($mine): ?>