地址联想:下拉候选补详细地址,同名地点可区分 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)判据全部正确转红。
This commit is contained in:
2026-10-30 07:37:55 +08:00
parent 42aff02687
commit df657e5e5a
+215 -5
View File
@@ -147,6 +147,168 @@
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); // 先显示再量高度,才能判断"下方够不够"
}
/**
* 绑定一个地址输入框。
*
@@ -232,9 +394,19 @@
var detail = input.getAttribute('data-addr-detail') === '1';
try {
var ac = new window.AMap.AutoComplete({ input: input });
ac.on('select', function (e) {
var poi = (e && e.poi) ? e.poi : null;
/* 🔴🔴 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; }
// 输入框里放**名字**(用户认的是名字,不是"一串门牌号")
@@ -290,8 +462,46 @@
detail: { name: name, place: readPlace(poi), address: String(poi.address || '') }
}));
} catch (err) { /* 老浏览器没有 CustomEvent 构造器,忽略即可 */ }
});
/* ✅ 到这里才算**真的绑上了** —— v68 之前这行在最前面,绑不上也照样标记(见上)。 */
};
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 没绑域名等)—— 静默降级、不打扰用户。