地址联想:下拉候选补详细地址,同名地点可区分 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:
+214
-4
@@ -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 构造器,忽略即可 */ }
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
/* ✅ 到这里才算**真的绑上了** —— v68 之前这行在最前面,绑不上也照样标记(见上)。 */
|
||||
} 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 没绑域名等)—— 静默降级、不打扰用户。
|
||||
|
||||
Reference in New Issue
Block a user