行程:#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:
+225
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user