Files
laoyang f58a128816 行程:#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
2026-11-05 12:40:57 +08:00

764 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · **地址联想**(高德 AutoComplete)—— 全站一份
*
* 🔴 v47(用户 2026-10-13)三条:
* ①「影院管理中,输入影院名称的时候,应该自动帮我补齐所在的地方和详细地址。」
* ②「行程里面…输入出发地和到达地的时候,都需要调用高德的接口,补齐详细地址。」
* ③「日程中,当我输入地点的时候,也应该调用高德的接口,补齐详细地址。」
*
* 为什么不再"每页各写一段":
* 原来的做法是 `trips/node_form.php` 里自己写一段内联脚本。v42 已经因此踩过一次
* (弹框是 AJAX 片段、内联 `<script>` 不执行 ⇒ 联想失效,只好抽了个 `fbNodeForm()`)。
* 这次要接到**三处**(影院管理页、观影新增弹框、日程表单,而日程本身也是弹框),
* 再各写一份必然出现"某个入口忘了补"——所以抽成这一份,谁用谁调 `fbAddr()`。
*
* ⚠️ 与项目其它按需资源同一套规矩:
* · 脚本**按需加载**(页面上真有 `[data-addr]` 才拉,见 layout.php 的 fb-addr meta);
* · SDK 的 key/安全密钥由 layout 注入(`window.FB_AMAP`),**本文件不碰配置**;
* · **高德挂了也必须能用** —— 输入框仍是普通文本框,手敲照常保存。
* 所以所有 AMap 的调用都在 `__AMAP_LOADED` 判断之后,一条都不在外面。
*
* ⚠️ 幂等:同一段表单可能被反复塞进 DOM(弹框开关),用 `data-addr-bound` 挡住重复绑定。
* **但"绑定"要分两层**:input 的事件只绑一次;每次调用都要**重画一次**已有值
* (因为重画后 DOM 是新的、可能带着服务端回填的地址)。
*/
(function () {
'use strict';
// 高德 SDK 的就绪状态由 layout 注入的那段脚本维护(见 fb-addr 注入块)
// 🔴 v68:**只认 `AMap.AutoComplete`**。v54 起 SDK 由一个不带 `plugin=` 的地址拉进来,
// 此时 `AMap` 有了但插件没有 ⇒ 这里必须为假,由下面 `ensurePlugin()` 去补;
// 若把判据放宽成"有 AMap 就算就绪",就会 `new AMap.AutoComplete` 抛错、
// 然后**静默**什么都不发生(正是用户看到的"输入地点没反应")。
function sdkReady() {
return !!(window.AMap && window.AMap.AutoComplete);
}
/**
* v68:按需把 `AMap.AutoComplete` 插件拉起来,成功后重扫整页。
*
* ⚠️ 为什么要这一步:高德 JS API 2.0 的服务插件是**独立 chunk**,
* SDK 主脚本 onload 时插件不一定在(实测:不带 `plugin=` 参数时一定不在)。
* 官方姿势就是 `AMap.plugin('AMap.AutoComplete', cb)`。
* ⚠️ `asking` 挡住重复请求:一页上可能有多个地点输入框,会同时触发。
*/
var asking = false;
function ensurePlugin() {
if (asking || !window.AMap || typeof window.AMap.plugin !== 'function') { return; }
if (window.AMap.AutoComplete) { fbAddr(document); return; }
asking = true;
try {
window.AMap.plugin('AMap.AutoComplete', function () {
asking = false;
if (sdkReady()) { fbAddr(document); } // 插件到了,重扫(此前没绑上的会补上)
});
} catch (e) {
asking = false;
}
}
/**
* v68:同一个地点框,**选中候选**后往下补一行"省市区 + 详细地址"。
*
* 为什么需要:日程表里地点只有**一栏**(`_event_fields.php` 只有 `location`),
* 用户 2026-10-20 要的是「获得准确的详细地址」—— 只把名字写回去,
* 名字照样是"青岛大学(浮山校区)",回头要导航/找人还是查不到门牌。
* ⇒ 输入框里保留**用户认得的名字**(或按 `data-addr-detail` 连地址一起写),
* 同时在**这一行下面**用 `.tip` 那行把官方地址显示出来。
*
* ⚠️ 插到哪:不能塞进输入框的父节点 —— 那是 `.mv-row`(标签 62px + 控件占满剩余)
* 的两列网格,多一个子项会把右边那列挤掉(本项目 v24 踩过同类排版坑)。
* ⇒ 找"这一整行"(`.mv-row` / `.field` / `.loc-field`),插到**它后面**。
*/
function showAddrHint(input, poi) {
var row = null;
try { row = input.closest('.mv-row, .field, .loc-field, .row'); } catch (e) { row = null; }
var anchor = row || input;
var host = anchor.parentNode;
if (!host) { return; }
var hint = host.querySelector('.fb-addr-hint');
var place = readPlace(poi);
var addr = String(poi.address || '').trim();
var text = [place, addr].filter(function (s) { return s !== ''; }).join(' · ');
if (text === '') {
if (hint) { hint.style.display = 'none'; }
return;
}
if (!hint) {
hint = document.createElement('span');
hint.className = 'tip fb-addr-hint';
hint.style.display = 'block';
hint.style.margin = '4px 0 0 62px'; // 与 `.mv-row` 的标签列对齐(62px 见 Theme/观影式排版)
host.insertBefore(hint, anchor.nextSibling);
}
hint.textContent = '高德:' + text;
hint.style.display = 'block';
}
/** 把 location(AMap.LngLat 或 {lng,lat} 或字符串)统一成 {lng,lat} */
function readLngLat(loc) {
if (!loc) { return { lng: '', lat: '' }; }
if (typeof loc === 'string') {
var p = loc.split(',');
return p.length >= 2 ? { lng: p[0].trim(), lat: p[1].trim() } : { lng: '', lat: '' };
}
var lng = (loc.lng !== undefined) ? loc.lng : (loc.getLng ? loc.getLng() : '');
var lat = (loc.lat !== undefined) ? loc.lat : (loc.getLat ? loc.getLat() : '');
return { lng: lng === null || lng === undefined ? '' : lng,
lat: lat === null || lat === undefined ? '' : lat };
}
/**
* 取"所在地方"。
*
* ⚠️ 高德的返回有**两套字段**,要看运气:
* · `poi.addressComponent.{province,city,district}` —— 结构化三级;
* · `poi.pname / cityname / adname` —— 老式平铺字段。
* 实测两者不是同时都有。取到哪级用哪级,取不到就留空(**绝不瞎编**)。
* @returns {string} 形如 "青岛市 市南区" / "青岛" / ''(都没有)
*/
/**
* 只取「市 + 区」,**不带省** —— 给输入框 value 用。
*
* 🔴 2026-11-03:用户要求详细地址写成「青岛市黄岛区珠江路1018号」。
* 而 `readPlace()` 返回的是带省的「山东省 青岛市 黄岛区」。
*
* 为什么 value 里**不带省**:
* · 城市名足够定位,省是冗余的(中国没有跨省同名地级市)
* · 输入框宽度有限,带上「山东省」会让后半截(门牌号)显示不全
* · 导航用不到省
*
* 直辖市的坑:`province === city === '北京市'`,只留一份。
*/
/**
* 只取**城市名**(「青岛市」),用于 `from_city` / `to_city` 这类聚合字段。
*
* 🔴 2026-11-05 新增。用户:「城市…直接城市就可以」。
*
* 与 `readPlace()` 的区别:
* · `readPlace()` = 省 + 市 + 区(「山东省青岛市市南区」)→ 给 `place` 显示用
* · `cityOnly()` = 只要市一级(「青岛市」) → 给 city 存库用
*
* 三级取值(高德不同接口给的字段不一样):
* ① `addressComponent.city`(AutoComplete / 逆地理)—— 最准
* ② 直辖市:`addressComponent.city` 是空,用 `province`(「北京市」)
* ③ `poi.district` 整串(inputtips:「山东省青岛市崂山区」)→ 正则剥出市名
*
* ⚠️ 拿不到就返回空串,**不猜**。空着比错着好(服务端还有一层 `Geo::cityOnly` 会补)。
*/
function cityOnly(poi) {
var a = poi.addressComponent || null;
var city = '';
var prov = '';
if (a) {
city = String(a.city || '').trim();
prov = String(a.province || '').trim();
/* 直辖市:`city` 为空(或与 province 相同),本身就是市一级 */
if (!city && prov) {
city = prov;
}
}
if (!city) {
/* inputtips 只给一个 district 整串:`山东省青岛市崂山区` /
`北京市丰台区` / `内蒙古自治区呼和浩特市新城区` */
var d = String(poi.district || '').trim();
city = cityFromDistrict(d);
}
return city;
}
/**
* 从「省市区」整串里剥出**市名**(「山东省青岛市崂山区」→「青岛市」)。
*
* 规则(按可靠度排序):
* ① 取**第一个**「XX市」—— 中文地址是 省→市→区 的层级顺序,
* 第一个「市」就是地级市。
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市;
* 「山东省青岛市市南区」的"市南区"里还有"市"字,靠"第一个"才能对。
* ② 直辖市(不带"市"字):「北京市丰台区」→ 第一个「市」就是「北京市」,
* 走 ① 即可;万一写成「北京朝阳区」才需要这级。
* ③ 都没有 ⇒ 空串。
*/
function cityFromDistrict(d) {
if (!d) { return ''; }
var s = String(d).trim();
/* ① 第一个「XX市」:XX 里不能含"省市县区旗盟"这些层级字眼,
否则「山东省」会被当成「山东省市」 */
var m = s.match(/([^\s省市县区旗盟]{1,10})市/);
if (m) {
return m[1] + '市';
}
/* ② 直辖市:开头就是「北京市」这类,上面已覆盖;
剩下「北京朝阳区」这种去掉末尾区县,取前段 */
var m2 = s.match(/^(北京|上海|天津|重庆)/);
if (m2) {
return m2[1] + '市';
}
return '';
}
function cityDistrictOf(poi) {
var a = poi.addressComponent || null;
var city = '', district = '';
if (a) {
city = String(a.city || a.province || '').trim();
district = String(a.district || '').trim();
}
if (!city && !district) {
city = String(poi.cityname || poi.pname || '').trim();
district = String(poi.adname || '').trim();
}
if (!city && !district) {
// inputtips 只给一个整串:「山东省青岛市崂山区」
// ⇒ 从整串里截出「XX市XX区」部分
var whole = String(poi.district || '').trim();
if (whole !== '') {
var mi = whole.indexOf('市');
if (mi >= 0) {
city = whole.substring(0, mi + 1);
district = whole.substring(mi + 1);
} else {
city = whole;
}
}
}
var parts = [];
if (city) { parts.push(city); }
if (district && district !== city) { parts.push(district); }
return parts.join('');
}
/**
* 把「省市区」与「街道门牌」合并,**缺哪段补哪段**,已有的不重复。
*
* 🔴 2026-11-03:原来这里判断太粗("address 含「区」字就整个不用 cd"),
* 导致 `cd='青岛市黄岛区'` + `address='黄岛区珠江路1018号'`
* 拼成 `黄岛区珠江路1018号` —— **把「青岛市」丢了**。
* 而城市名正是地理编码最关键的线索(丢了就会搜到外省同名路)。
*
* 正确逻辑:逐段检查 `address` 里有没有,没有才补。
*
* cd='青岛市黄岛区', address='珠江路1018号'
* → 两段都没有 ⇒ 全补 ⇒ '青岛市黄岛区珠江路1018号'
* cd='青岛市黄岛区', address='黄岛区珠江路1018号'
* → 有「黄岛区」、缺「青岛市」⇒ 补城市 ⇒ '青岛市黄岛区珠江路1018号'
* cd='青岛市黄岛区', address='青岛市黄岛区珠江路1018号'
* → 全都有 ⇒ 原样
*
* @param {string} cd cityDistrictOf 的结果(如「青岛市黄岛区」)
* @param {string} addr 高德的 poi.address(如「珠江路1018号」)
* @return {string}
*/
function mergeAddrTail(cd, addr) {
var ad = String(addr || '').trim();
var c = String(cd || '').trim();
if (c === '') { return ad; }
if (ad === '') { return c; }
// 把 cd 拆成「城市」与「区县」两段:找第一个「市」字切开
var mi = c.indexOf('市');
var city = '', dist = '';
if (mi >= 0) {
city = c.substring(0, mi + 1); // 含「市」
dist = c.substring(mi + 1);
} else {
city = c;
}
var needCity = city !== '' && ad.indexOf(city) < 0;
// 区县名可能不带「区/县」后缀出现在 address 里,用去掉后缀的裸名判断
var distBare = dist.replace(/[区县市旗]$/, '');
var needDist = dist !== '' && ad.indexOf(dist) < 0
&& (distBare === '' || ad.indexOf(distBare) < 0);
/* ⚠️ 拼接顺序必须是「城市 → 区县 → 街道」。
测出来的 case:cd='青岛市黄岛区'、addr='青岛市珠江路1018号'
(有城缺区)时,若直接 `prefix + ad` 会得到
「黄岛区青岛市珠江路1018号」—— 区跑到城市前面了。
⇒ 分段拼:先城市,再区,最后街道(街道在 ad 里,
但要先把 ad 里已有的城市/区剥掉避免重复)。 */
var tailAd = ad;
if (!needCity) {
// ad 已含城市 ⇒ 剥掉前缀,后面统一按 城市+区+街道 拼
var idxC = ad.indexOf(city);
if (idxC === 0) {
tailAd = ad.substring(city.length);
} else if (idxC > 0) {
tailAd = ad.substring(0, idxC) + ad.substring(idxC + city.length);
}
}
if (!needDist && dist !== "") {
var idxD = tailAd.indexOf(dist);
if (idxD >= 0) {
tailAd = tailAd.substring(0, idxD) + tailAd.substring(idxD + dist.length);
} else if (distBare !== "") {
var idxB = tailAd.indexOf(distBare);
if (idxB >= 0) {
// 裸名保留(「黄岛珠江路」里的「黄岛」是地址的一部分,不剥)
}
}
}
var prefix = "";
if (city !== "") { prefix += city; }
if (dist !== "") { prefix += dist; }
return prefix + tailAd.trim();
}
function readPlace(poi) {
var a = poi.addressComponent || null;
var province = '', city = '', district = '';
if (a) {
// ⚠️ 直辖市的 province 与 city 会**同时**是"北京市",拼起来变"北京市 北京市"
// ⇒ 相同时只留一份。
province = String(a.province || '').trim();
city = String(a.city || '').trim();
district = String(a.district || '').trim();
}
if (!province && !city && !district) {
province = String(poi.pname || '').trim();
city = String(poi.cityname || '').trim();
district = String(poi.adname || '').trim();
}
/* 🔴 v68:第三套字段 —— **输入提示**(`/v3/assistant/inputtips`,正是
AutoComplete 背后用的那个接口)只给一个 `district` 整串:
`"山东省青岛市崂山区"`。前两套都没有时用它,否则"详细地址"那行
永远只有门牌号、没有城市(实测踩到)。 */
if (!province && !city && !district) {
district = String(poi.district || '').trim();
}
var parts = [];
if (province) { parts.push(province); }
if (city && city !== province) { parts.push(city); }
if (district) { parts.push(district); }
return parts.join(' ');
}
/**
* v69(工单 #32):自建候选面板。
*
* **为什么要自建**:`AMap.AutoComplete` 的内置下拉只渲染 `name`,
* 同名地点(多个「星巴克」、同一个商场的不同入口)在候选里长得一模一样,
* 用户没法判断该选哪个。要在地名后面补详细地址,就只能自己接管渲染。
*
* ⚠️ 定位用 `absolute` + 挂在 `document.body` 上,**不挂在输入框父节点里**:
* 1. 输入框常在 `.mv-row` 这种"标签 62px + 控件占满"的两列网格里,
* 多一个子项会把右边那列挤掉(本项目 v24 踩过同类排版坑);
* 2. 表单弹框多有 `overflow: hidden`,挂里面会被裁掉。
* 用 `position: fixed` + 实时读 `getBoundingClientRect()` 定位,
* 滚动/缩放时跟着走(见 `placePanel`)。
* @param {HTMLElement} input 那个地址输入框
* @param {Function} onPick 点中候选后的回调(收到高德的 poi 对象)
*/
function makePanel(input, onPick) {
var el = document.createElement('div');
el.className = 'fb-addr-panel';
el.setAttribute('data-fb-addr-panel', '1');
/* ⚠️ 样式内联而不写进 app.css:这份脚本是全站共用、按需加载的,
把样式焊在这里才不会有"某个页面忘了引那段 CSS"的漏。 */
el.style.cssText = [
'position:fixed', 'z-index:2147483000', 'display:none',
'margin:0', 'padding:4px 0', 'list-style:none',
'max-height:264px', 'overflow-y:auto',
'background:#fff', 'border-radius:10px',
'box-shadow:0 6px 24px rgba(0,0,0,.16), 0 1px 3px rgba(0,0,0,.10)',
'border:1px solid rgba(0,0,0,.08)',
'-webkit-overflow-scrolling:touch',
'font-size:14px', 'line-height:1.35'
].join(';');
document.body.appendChild(el);
var panel = { el: el, input: input, onPick: onPick, tips: [] };
/* 滚动/缩放时重算位置 —— 用 capture 阶段接,任何滚动容器都能收到 */
window.addEventListener('scroll', function () { placePanel(panel); }, true);
window.addEventListener('resize', function () { placePanel(panel); });
return panel;
}
/** 把面板放到输入框正下方,宽度跟随输入框(不超出视口) */
function placePanel(panel) {
if (!panel.el || panel.el.style.display === 'none') { return; }
var r = panel.input.getBoundingClientRect();
/* ⚠️ 量不到尺寸(`0×0`)有两种可能:
① 输入框真的不在布局里(表单被隐藏/已从 DOM 摘掉)→ 该收起;
② **刚 append 完、样式还没算完**(同一 tick 内读 rect 常见于旧引擎),
或**无布局环境**(jsdom 做单元测试时 rect 恒为 0)。
原来的写法一律 `hidePanel` 收掉,于是②这种情况会出现
"候选明明查到了却一显示就消失"——**这不是理论风险,是实测踩到的**。
⇒ 改成:量不到就**先不定位**(保留默认位置/宽度),但**不收起**。
面板若真该消失,`hidePanel` 在别处(失焦、选出、输入过短)都会调。 */
if (r.width === 0 && r.height === 0) {
panel.el.style.minWidth = '240px';
return;
}
var vw = window.innerWidth || document.documentElement.clientWidth;
var w = Math.min(Math.max(r.width, 240), vw - 16);
var left = Math.min(Math.max(r.left, 8), vw - w - 8);
panel.el.style.width = w + 'px';
panel.el.style.left = left + 'px';
/* 下方空间不足就翻到输入框上方(手机上键盘弹起时尤其常见) */
var below = window.innerHeight - r.bottom;
var h = panel.el.offsetHeight || 0;
if (below < Math.min(h, 160) && r.top > below) {
panel.el.style.top = Math.max(8, r.top - h - 6) + 'px';
} else {
panel.el.style.top = (r.bottom + 6) + 'px';
}
}
function hidePanel(panel) {
if (!panel || !panel.el) { return; }
panel.el.style.display = 'none';
panel.el.innerHTML = '';
}
/**
* 渲染候选:**每行 = 地名(深色,主)+ 省市区/详细地址(灰色,副)**。
*
* 🔴 这正是工单 #32 的诉求 —— 地址必须出现在下拉里,作为选择依据。
* 同名地点只有靠这一行灰字才区分得开。
*
* ⚠️ 高德的 `tips` 里不同条目字段并不齐(实测):
* · POI 类:`name` + `address` + `district`
* · 纯行政区划类:`name`(就是地名)+ `district`,**没有 address**
* ⇒ secondary 要把能拿到的都拼上,全空就只显示主标题(不能留一片空白)。
* ⚠️ 用 `textContent` 逐个填,**不拼 HTML 字符串**:
* 地点名是高德返回的、可能含 `<` `&`,拼 HTML 会破版甚至注入。
*/
function renderPanel(panel, tips, onPick) {
var el = panel.el;
el.innerHTML = '';
var list = [];
for (var i = 0; i < tips.length; i++) {
var t = tips[i];
if (!t) { continue; }
/* 只要"有名字"的;`location` 为空的纯提示行(高德有时给空壳)跳过 */
var nm = String(t.name || '').trim();
if (nm === '') { continue; }
list.push(t);
}
if (list.length === 0) { hidePanel(panel); return; }
list.forEach(function (t) {
var row = document.createElement('div');
row.style.cssText = [
'padding:9px 14px', 'cursor:pointer', 'display:block',
'border-bottom:1px solid rgba(0,0,0,.04)'
].join(';');
var main = document.createElement('div');
main.style.cssText = 'color:#1b1b1f;font-weight:500;word-break:break-all';
main.textContent = String(t.name || '').trim();
row.appendChild(main);
/* 副标题 = 省市区 + 详细地址。两者常常有包含关系
(`district` 是"山东省青岛市崂山区",`address` 是"香港东路 195 号"),
直接拼接不会重复;但若 address 已经以 district 开头则去重。 */
var place = readPlace(t);
var addr = String(t.address || '').trim();
var parts = [];
if (place !== '') { parts.push(place); }
if (addr !== '' && addr.indexOf(place) !== 0) { parts.push(addr); }
else if (addr !== '' && place === '') { parts.push(addr); }
var sub = parts.join(' ').replace(/\s+/g, ' ').trim();
if (sub !== '') {
var desc = document.createElement('div');
desc.style.cssText = 'color:#8a8a8e;font-size:12px;margin-top:2px;word-break:break-all';
desc.textContent = sub;
row.appendChild(desc);
}
/* 悬停/按下高亮(触屏没有 hover,所以两件都绑) */
row.addEventListener('mouseenter', function () { row.style.background = '#f5f5f7'; });
row.addEventListener('mouseleave', function () { row.style.background = 'transparent'; });
row.addEventListener('touchstart', function () { row.style.background = '#f5f5f7'; });
/* ⚠️ 用 `mousedown` + `preventDefault` 而不是 `click`:
输入框失焦会触发收起逻辑,`click` 可能来不及。
`preventDefault` 还能阻止输入框先失焦再复位造成的"闪一下"。 */
row.addEventListener('mousedown', function (ev) {
ev.preventDefault();
hidePanel(panel);
onPick(t);
});
/* 触屏上 mousedown 也会触发,但为稳妥补一次 touchend(并挡掉随后的 300ms click 穿透) */
row.addEventListener('touchend', function (ev) {
ev.preventDefault();
hidePanel(panel);
onPick(t);
});
el.appendChild(row);
});
el.style.display = 'block';
placePanel(panel); // 先显示再量高度,才能判断"下方够不够"
}
/**
* 绑定一个地址输入框。
*
* @param {HTMLElement} input 那个要联想的输入框(必须有 `data-addr`)
* @param {HTMLElement} scope 可选:限定字段查找范围(弹框里的一段表单)
*/
function bindOne(input, scope) {
if (!input || input.getAttribute('data-addr-bound') === '1') { return; }
/* 🔴🔴 v68 修的**第二个** bug:这里原来一进门就打 `data-addr-bound='1'`,
而"SDK 就绪"的判断在**后面**。只要那一次判断为假(v54 之后恒假),
输入框就被永久标成"已绑定" ⇒ 后面谁来调 `fbAddr()` 都会被这行挡掉,
**哪怕插件后来加载成功了也再没机会绑上**。表现就是"以前好用、现在不好用了",
而且改什么地方都不恢复,只能刷掉重来(重来也一样假)。
⇒ 标记改成"**真的 new 成功了**"才打。 */
// 字段查找范围:`data-addr-for` 给的是一个选择器。
// 🔴 必须优先取**最近的那个**祖先(`closest`),不能 `querySelector` 取第一个 ——
// 行程表单里 from / to 两组字段都在页面上,取第一个会把出发地的地址
// 写到到达地上("填错了但看起来像填对了",最难查)。
// 取不到祖先时才退回:先按选择器全局取第一个,最后退回 scope/document。
var root = null;
var forSel = input.getAttribute('data-addr-for');
if (forSel) {
try { root = input.closest(forSel); } catch (e) { root = null; }
if (!root) { root = (scope || document).querySelector(forSel); }
}
if (!root) {
root = (scope || document);
}
function fieldFor(target) {
if (!target) { return null; }
// 🔴 同一个"逻辑字段"在项目里有两套命名,必须都认(v47 实测踩到):
// · 影院页 —— 全拼:`address` / `place`
// · 行程地点 —— 缩写:`from_addr` / `to_addr`(`_addr`,不是 `_address`)
// 只认全拼的话 `[name$="address"]` 匹配不到 `from_addr`
// (`from_addr` 以 `_addr` 结尾)⇒ 地址永远填不进去,
// 而"名字和经纬度都填上了"会让人误以为整个回填是好的。
var SYN = {
address: ['address', 'addr'],
addr: ['addr', 'address'],
place: ['place', 'city'],
city: ['city', 'place'],
lng: ['lng'],
lat: ['lat'],
};
var names = SYN[target] || [target];
for (var k = 0; k < names.length; k++) {
var nm = names[k];
// ① 精确名
var el = root.querySelector('[name="' + nm + '"]');
if (el) { return el; }
// ② 带前缀的(`from_addr`)—— 后缀匹配
// ⚠️ 能安全用后缀,是因为 `root` 已经收窄到**这一组**字段的容器
// (`data-addr-for=".loc-field"` + `closest`):
// 出发/到达两组字段在各自的 .loc-field 里,互不可见。
// 若在整页范围做后缀匹配,`from_addr` 与 `to_addr` 会互相抢。
try {
var cands = root.querySelectorAll('[name$="' + nm + '"]');
if (cands.length) { return cands[0]; }
cands = root.querySelectorAll('[name$="_' + nm + '"]');
if (cands.length) { return cands[0]; }
} catch (e) { /* 极老浏览器不支持 $= ⇒ 找不到就不填,不报错 */ }
}
return null;
}
var targets = (input.getAttribute('data-addr-fill') || '')
.split(',').map(function (s) { return s.trim(); })
.filter(Boolean);
// 手敲也要能被"补齐"(用户可能不用联想直接打字,或 SDK 没加载)
// —— 这里只在**选了候选**时填,避免把用户手写的地址覆盖掉。
if (!sdkReady()) {
/* v68:插件还没到 —— **不打已绑定标记**,去按需拉一次插件,
到了以后 `fbAddr(document)` 会重扫到这里重新执行。
这样"SDK 晚到 / 插件晚到 / 弹框晚插入"三种时序都能自愈。 */
ensurePlugin();
return;
}
var detail = input.getAttribute('data-addr-detail') === '1';
try {
/* 🔴🔴 v69(用户 2026-10-30,工单 #32):「输入一个简单地址、下拉显示详细地址时,
不能只把地点告诉我,一定要把详细地址也放在后面。因为同一个地址可能有好几个,
我根本不知道应该选哪一个。」
⇒ **不再用 `AMap.AutoComplete` 的内置下拉**(它只画 `name`,我们无法控制),
改成"高德只当查询器、下拉自己画":
· 构造时**不传 `input`** ⇒ 高德不会往 DOM 里塞它自己的候选面板;
· 用户输入 → 防抖 → `ac.search(kw, cb)` → 拿 `result.tips`;
· 自己渲染每行「**地名 + 省市区 + 详细地址**」,地址在后、灰色小字。
⚠️ 选中后的全部既有行为(回填、`data-addr-detail`、框下提示、
`fb-addr-picked` 事件)**原样保留**,只是入口从高德的 `select`
事件换成我们自己那行候选的 click —— 见下面的 `pick()`。 */
var ac = new window.AMap.AutoComplete({});
var pick = function (poi) {
if (!poi) { return; }
// 输入框里放**名字**(用户认的是名字,不是"一串门牌号")
var name = String(poi.name || '').trim();
if (name !== '') { input.value = name; }
/* v68(用户 2026-10-20「获得准确的详细地址」):日程的地点只有这一栏,
所以把**详细地址**也带上 —— 但名字在前(人认的是名字),
地址在后,用中点分隔:`青岛大学(浮山校区) · 宁夏路308号`。
⚠️ 只对显式声明 `data-addr-detail="1"` 的输入框这么做:
旅程/影院那两处都有自己的 address 字段,不能往名字框里塞门牌。
⚠️ 必须**延后一拍**写:AutoComplete 在 select 回调之后还会自己把
`poi.name` 塞回输入框(实测,v68 第一版就因此白写)——
同步写会被它覆盖,看起来像"详细地址没生效"。 */
if (detail) {
/* 🔴🔴 2026-11-03 修(用户:「这并不是详细地址,详细地址应该是
青岛市黄岛区珠江路1018号」):
原来拼的是 `name · poi.address`,而高德的 `poi.address`
**只到街道级**(「珠江路1018号」),省市区在别的字段里。
⇒ 存进库里就是「招商银行(...) · 珠江路1018号」,
**缺城市**。鸿蒙端做地理编码时抓不到城市,
全国搜「珠江路1018号」→ 搜到**江苏南通**(差 500 公里)。
现在拼三段:`name · 市+区 + 街道门牌`
→ 「招商银行(...) · 青岛市黄岛区珠江路1018号」
⚠️ 用 `cityDistrictOf()` 而不是 `readPlace()`:
value 里不带省(省冗余、输入框显示不下、导航用不到)。
⚠️ 若高德连 city 都没给(极少数),退回原来的两段拼法,
至少不比改之前差。 */
var ad0 = String(poi.address || '').trim();
var cd = cityDistrictOf(poi);
if (name !== '') {
var tail = mergeAddrTail(cd, ad0);
if (tail !== '') {
var full = name + ' · ' + tail;
input.value = full;
setTimeout(function () { input.value = full; }, 0);
}
}
}
targets.forEach(function (t) {
var el = fieldFor(t);
if (!el) { return; }
// 🔴 只填**空白**的字段。理由(用户 2026-10-13 要的是"自动补齐"):
// 用户可能已经在地址栏手写了更细的门牌("3 号楼 2 单元"),
// 高德给的 `address` 只是"到路口/到楼"的粒度 —— 直接覆盖会把他写的东西抹掉,
// 而"抹掉用户输入"是不可逆的(他点保存后就真没了)。
// ⇒ 空着才补;已经有内容就尊重用户的。
if (String(el.value || '').trim() !== '') { return; }
/* 🔴 2026-11-05 修(用户:「城市…什么省也带上了,市也带上了,
区甚至都带上了,这没必要,直接城市就可以」)。
`city` 字段**不能用 `readPlace()`** —— 那个函数的用途是
「显示完整地名」(省 + 市 + 区),是给 `place` 输入框用的。
拿它填 city 会存进库里变成「山东省青岛市市南区」,
导致旅程的城市聚合统计里出现一长串行政区划。
⇒ `city` 单独走 `cityOnly()`:只取「青岛市」这一级。 */
if (t === 'place') {
var pl = readPlace(poi);
if (pl !== '') { el.value = pl; }
} else if (t === 'city') {
var co = cityOnly(poi);
if (co !== '') { el.value = co; }
} else if (t === 'address' || t === 'addr') {
var ad = String(poi.address || '').trim();
if (ad !== '') { el.value = ad; }
} else if (t === 'lng' || t === 'lat') {
var ll = readLngLat(poi.location);
var v = (t === 'lng') ? ll.lng : ll.lat;
if (v !== '') { el.value = v; }
}
});
// v68:把"官方给的省市区 + 详细地址"显示在框下(不塞进 value,见 showAddrHint)
if (detail) { showAddrHint(input, poi); }
// 让调用方知道"值被程序改过"(有些页面要跟着重画/联动)
try {
input.dispatchEvent(new CustomEvent('fb-addr-picked', {
bubbles: true,
detail: { name: name, place: readPlace(poi), address: String(poi.address || '') }
}));
} catch (err) { /* 老浏览器没有 CustomEvent 构造器,忽略即可 */ }
};
var panel = makePanel(input, pick);
var timer = -1;
/* 输入 → 防抖 260ms → 查候选。
⚠️ 260ms 是本项目其它联想(观影片名)同款节奏,别乱调:
太快会在中文输入法拼字过程中反复请求(白烧配额、候选跳来跳去)。 */
var onInput = function () {
if (timer >= 0) { clearTimeout(timer); }
var kw = String(input.value || '').trim();
/* 少于 2 个字不查 —— 一个字的候选既多又没用,还会把高德配额吃掉。
⚠️ 但要把面板收起来:用户已经把字删掉了,留着旧候选会误点。 */
if (kw.length < 2) {
if (timer >= 0) { clearTimeout(timer); timer = -1; }
hidePanel(panel);
return;
}
timer = setTimeout(function () {
timer = -1;
try {
ac.search(kw, function (status, result) {
if (status !== 'complete' || !result) { hidePanel(panel); return; }
var tips = result.tips || [];
renderPanel(panel, tips, pick);
});
} catch (e) { hidePanel(panel); }
}, 260);
};
input.addEventListener('input', onInput);
/* 点别处收起。用 `mousedown` 而不是 `click`:
`click` 在「点候选行」时也会冒泡上来,会把候选先收掉导致点不中。 */
var onDocDown = function (ev) {
if (panel.el && panel.el.contains(ev.target)) { return; }
if (ev.target === input) { return; }
hidePanel(panel);
};
document.addEventListener('mousedown', onDocDown);
input.setAttribute('data-addr-bound', '1');
} catch (err) {
/* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级、不打扰用户。
⚠️ 不写 `data-addr-bound`:下次 `fbAddr()` 还能再试(用户切页面/重开弹框)。 */
}
}
/**
* 初始化(**可重复调用**)。
* @param {HTMLElement|Document} scope 限定范围;缺省 = 整页
*/
function fbAddr(scope) {
var root = scope || document;
var inputs = Array.prototype.slice.call(root.querySelectorAll('[data-addr]'));
inputs.forEach(function (el) { bindOne(el, root); });
}
window.fbAddr = fbAddr;
/* v68:高德 SDK 是**异步**插入的(layout 的 fbAmapSdk),而我们可能先执行;
插件(AutoComplete)又是 SDK 之后的一跳。两个时序都得兜住:
· SDK 到了 → 拉插件 → 重扫(layout 的 s.onload 会派发 fb-amap-ready)
· 本脚本自己先跑(没有 SDK)→ 什么都不做,等事件 */
try {
window.addEventListener('fb-amap-ready', function () { ensurePlugin(); });
} catch (e) { /* 极老浏览器没有 addEventListener(不可能)—— 忽略 */ }
// 首屏跑一次。⚠️ 脚本是 defer 加载的,此时 DOM 已解析完(在 </body> 前引入亦可)。
fbAddr(document);
ensurePlugin(); // SDK 若已就位但插件没拉,这里补一次
/* 通知"我上线了"。
⚠️ 为什么需要:调用方(行程/影院/日程的弹框)可能在**本脚本加载之前**
就把 `fbLoadAddr()` 调过了 —— 那种情况下它的 `window.fbAddr` 判断会落空。
它们监听这个事件补一次(见 trips/node_form.php 的 initNodeForm)。 */
try {
window.dispatchEvent(new Event('fb-addr-ready'));
} catch (e) {
/* 老浏览器没有 Event 构造器(极少)—— 调用方还有 fbAddr 兜底,不影响功能 */
}
})();