行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)

#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊
#48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间
#50 类型点击无反应(@Builder 值传参不建立依赖)
#51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调

API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action
DB 新增:trip_nodes.actual_started_at / actual_ended_at
This commit is contained in:
2026-11-05 12:40:57 +08:00
parent a1140437a1
commit f58a128816
9 changed files with 1361 additions and 46 deletions
+225
View File
@@ -474,6 +474,101 @@ final class Geo
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
* 不是靠猜。
*/
/**
* 把任意「行政区划串」归一化为**纯市名**(「青岛市」)。
*
* 🔴 2026-11-05 新增。用户原话:
* 「它那个城市原来我说的是指定城市就可以,比如北京市、青岛市。
* 现在弄得很乱,什么省也带上了,市也带上了,区甚至都带上了,
* 这没必要,直接城市就可以。」
*
* ## 为什么必须在服务端做
*
* 前端(网页 `addr_suggest.js`、鸿蒙端)都可能传进「山东省青岛市市南区」,
* 这是历史行为。靠"前端自觉"不可靠:
* · 老版本 App 还在用;
* · 第三方/脚本调用不经过我们的前端;
* · 老数据已经脏了。
* ⇒ 服务端统一归一化,前端传什么都剥成市名。**这是唯一的真防线。**
*
* ## 取值顺序
*
* ① 已经就是纯市名(「青岛市」)→ 原样返回
* ② 含「XX市」→ 取**第一个**(中文地址是 省→市→区 层级顺序)
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市
* 「五大连池市」;「山东省青岛市市南区」的"市南区"里还有"市"字
* ③ 直辖市不带"市"字(「北京朝阳区」)→ 开头匹配四直辖市
* ④ 都没有 → 返回空串(**不猜**)
*
* @param string $raw 可能带省/区/空格/「·」分隔的行政区划串
* @return string 纯市名;认不出返回空串
*/
public static function cityOnly(string $raw): string
{
$s = trim($raw);
if ($s === '') {
return '';
}
/* 已经是纯市名(最常见的正常情况)→ 直接返回,不做正则 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市' && mb_strpos($s, ' ') === false
&& mb_strpos($s, '·') === false && mb_strpos($s, '省') === false
&& mb_strpos($s, '区') === false && mb_strpos($s, '县') === false) {
return $s;
}
/* 带「·」的(老格式「山东·青岛」)→ 取后半段再走一遍 */
$dot = mb_strpos($s, '·');
if ($dot !== false) {
$s = trim(mb_substr($s, $dot + 1, null, 'UTF-8'));
if ($s !== '') {
/* 「青岛」这种不带市的也要认 —— 补上市字再返回 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市') {
return $s;
}
if (!preg_match('/[省区县旗盟]/u', $s)) {
return $s . '市';
}
}
}
/* 空格分隔的(「山东省 青岛市 市南区」)→ 逐段找第一个「XX市」 */
$segs = preg_split('/\s+/u', $s) ?: [];
foreach ($segs as $seg) {
$got = self::firstCityWord($seg);
if ($got !== '') {
return $got;
}
}
/* 整串里找第一个「XX市」 */
$got = self::firstCityWord($s);
if ($got !== '') {
return $got;
}
/* 直辖市不带"市"字(「北京朝阳区」) */
foreach (self::MUNICIPALITIES as $m) {
$short = rtrim($m, '市');
if ($short !== '' && mb_strpos($s, $short) === 0) {
return $m;
}
}
return '';
}
/**
* 从一个片段里取**第一个**「XX市」,XX 中不允许出现层级字眼。
*
* ⚠️ `[^省市县区旗盟]` 这个字符类不能省:
* 否则「山东省青岛市」会匹配到「山东省市」(把"山东省"当成了城市名前缀)。
*/
private static function firstCityWord(string $s): string
{
if ($s === '') {
return '';
}
if (preg_match('/([^\s省市县区旗盟的了吧呢啊个这那有字方地]{2,6})市/u', $s, $m)) {
return $m[1] . '市';
}
return '';
}
public static function cityFromText(string $text): string
{
$t = trim($text);
@@ -636,6 +731,136 @@ final class Geo
}
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
/**
* 地址联想(高德输入提示)。
*
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 1002600003(AGC 认证失败),
* 而 AGC 那条链路需要开地图服务 + 重新生成 Profile,可能等 24 小时。
* ⇒ 加这个接口:端上走我们的服务器,服务器用**高德 Web 服务 key** 调 inputtips。
*
* 好处(不只是绕过 AGC):
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也用高德,
* 两端选中的地址是同一套 POI,不会再出现"网页能搜到、手机搜不到";
* · inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
* 地址天生完整,不用像 Map Kit 那样自己拼。
*
* @param string $kw 关键词
* @param int $limit 最多几条
* @return array<int,array{name:string,detail:string,district:string,address:string,lng:string,lat:string}>
* **任何失败都返回空数组**,不抛错(联想挂掉不能拖垮"记一条日程")
*/
public static function suggest(string $kw, int $limit = 8): array
{
$q = trim($kw);
if ($q === '' || mb_strlen($q, 'UTF-8') < 2) {
return [];
}
$key = self::webKey();
if ($key === '') {
return [];
}
$url = 'https://restapi.amap.com/v3/assistant/inputtips'
. '?key=' . urlencode($key)
. '&keywords=' . urlencode($q)
. '&datatype=all'
. '&citylimit=false';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 6,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '' || !is_string($body) || $body === '') {
return [];
}
$j = json_decode($body, true);
if (!is_array($j) || (string)($j['status'] ?? '') !== '1' || empty($j['tips'])) {
return [];
}
$out = [];
foreach ($j['tips'] as $t) {
/* ⚠️ 高德的字段类型**不保证是字符串**:
实测 `district`/`address`/`location` 在部分条目里是**数组**(空数组),
直接当字符串用会触发 "Array to string conversion" 警告。
⇒ 统一过一遍 toStr()。 */
$name = self::toStr($t['name'] ?? '');
$dist = self::toStr($t['district'] ?? '');
$addr = self::toStr($t['address'] ?? '');
$loc = self::toStr($t['location'] ?? '');
/* ⚠️ 有些条目是"纯行政区划"(如 name=招商银行、district=空、address=空),
没有地址也没有坐标 ⇒ 面板上就是一行空白,跳过。 */
if ($name === '' || ($addr === '' && $dist === '' && $loc === '')) {
continue;
}
$lng = '';
$lat = '';
if ($loc !== '' && strpos($loc, ',') !== false) {
$seg = explode(',', $loc);
if (count($seg) === 2) {
// 高德给的是 "经度,纬度"
$lng = (string)$seg[0];
$lat = (string)$seg[1];
}
}
// 详细地址 = 省市区 + 门牌(用户要的"参考信息")
$detail = $dist;
if ($addr !== '') {
$detail = $detail === '' ? $addr : ($detail . $addr);
}
$out[] = [
'name' => $name,
'detail' => $detail,
'district' => $dist,
'address' => $detail,
'lng' => $lng,
'lat' => $lat,
];
if (count($out) >= $limit) {
break;
}
}
return $out;
}
/**
* 把高德返回的值统一成字符串。
*
* 高德的 PHP 侧字段类型不稳定(字符串 / 空数组 / 数组都见过),
* 直接 `(string)` 会触发 "Array to string conversion" 警告并得到 "Array"。
*/
private static function toStr($v): string
{
if (is_string($v)) {
return trim($v);
}
if (is_int($v) || is_float($v)) {
return (string)$v;
}
// 数组/对象/null 一律当空(高德用 [] 表示"没这个字段")
return '';
}
/** 端上地址联想用的 key(Web 服务类型) */
public static function webKey(): string
{
$a = fb_config('amap_web');
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
}
public static function apiKey(): string
{
$a = fb_config('amap');