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:
+325
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user