Files
FamilyBook/assets/js/addr_suggest.js
T
laoyang df657e5e5a 地址联想:下拉候选补详细地址,同名地点可区分 laoyang/FamilyBook#32
用户 2026-10-30:「输入一个简单地址、下拉显示详细地址时,不能只把地点告诉我,
一定要把详细地址也放在后面。因为同一个地址可能有好几个,我根本不知道应该选哪一个。」

原来用的是 AMap.AutoComplete 的内置下拉 —— 它只画 name,同名地点
(多个「星巴克」、同一商场的不同入口)长得一模一样,无法判断该选哪个。
⇒ 改成「高德只当查询器、下拉自己画」:
  · 构造时不传 input,高德不再塞自己的面板
  · 输入防抖 260ms → ac.search(kw) → 自己渲染 result.tips
  · 每行 = 地名(14px 深色)+ 省市区/详细地址(12px 灰字)

选中后的既有行为原样保留(只是入口从高德的 select 事件换成自建候选的点击):
data-addr-fill 回填、data-addr-detail 写门牌、框下 .fb-addr-hint 提示、
fb-addr-picked 事件。

几处刻意的取舍:
  · 面板挂 body + position:fixed,不挂输入框父节点 —— 否则会被 .mv-row
    的两列网格挤掉、或被子表单弹框的 overflow:hidden 裁掉
  · placePanel 量不到尺寸(0x0)时**只跳过定位、不收起**。原来收起了,
    导致候选查到却一显示就消失(实测踩到)
  · 少于 2 字不查,并收起面板,避免残留旧候选被误点
  · 候选行用 textContent 逐个填,不拼 HTML(地点名含 < & 会破版/注入)

验证:jsdom 27 项(含 from/to 不串、SDK 未就绪降级、自愈)+
真实浏览器 54 项(桌面/手机视口 × 表单/弹框/贴底部三种布局)全通过;
反向验证 3 个坏变体(不渲染副标题/副标题为空/不挂 body)判据全部正确转红。
2026-10-30 07:37:55 +08:00

545 lines
27 KiB
JavaScript
Raw 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} 形如 "青岛市 市南区" / "青岛" / ''(都没有)
*/
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) {
var ad0 = String(poi.address || '').trim();
if (ad0 !== '' && name !== '') {
var full = name + ' · ' + ad0;
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; }
if (t === 'place' || t === 'city') {
var pl = readPlace(poi);
if (pl !== '') { el.value = pl; }
} 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 兜底,不影响功能 */
}
})();