diff --git a/assets/js/addr_suggest.js b/assets/js/addr_suggest.js index d0f2492..2c2ba10 100644 --- a/assets/js/addr_suggest.js +++ b/assets/js/addr_suggest.js @@ -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 没绑域名等)—— 静默降级、不打扰用户。