Files
FamilyBook/assets/js/addr_suggest.js
T
laoyang b2b103df0f v68:修好日程「地点」的高德联想(v54 起静默失效)+ 账本四条动作路由
用户 2026-10-20:「网页端添加日程的时候,输入地点,应该调用高德接口,
获得准确的详细地址,现在这个功能不好用了,先修一下。」

## 根因不是 key、不是域名白名单 —— 是插件从来没被拉起来
`layout.php` 里 `fb-amap` meta 给的 SDK 地址**不带 `plugin`**,而加载器优先用它
⇒ `window.AMap.AutoComplete` **恒为 undefined** ⇒ 联想实例建不出来。
更糟的是 `addr_suggest.js` **一进函数就打 `data-addr-bound='1'`**,
于是"绑定失败"被记成"已经绑定过",此后**再也不重试** —— 全链路静默,
界面上就是"输了字什么也不弹"。
(用真浏览器逐环量过才定位:key 有效、`inputtips` 真能返回 10 条候选,
  问题全在我们自己这一侧。)

修法(双保险):
- `layout.php` 新增 `needAutoComplete()`:SDK 到位后 `AMap.plugin('AMap.AutoComplete', cb)`,
  超时也放行;启动顺序改成 **SDK → 插件 → 我们的脚本**。
- `addr_suggest.js` 就绪判据收紧成 `!!(AMap && AMap.AutoComplete)`;
  `data-addr-bound` 改为 **new 成功之后**才打;新增 `ensurePlugin()`
  并监听 `fb-amap-ready` 自愈(插件晚到也能接上)。

## 「准确的详细地址」
日程的地点**只有一栏**(`_event_fields.php` 只有 location),只把名字写回去,
回头要导航/找人还是查不到门牌。⇒ 地点框加 `data-addr-detail="1"`,
选中候选后写「名字 · 详细地址」(名字在前,人认的是名字),
并在这一行下面给一条「高德:省市区 · 门牌」提示。
⚠️ 提示行必须插在**整行之后**,塞进 `.mv-row` 会把 62px 标签列挤掉(v24 踩过)。
⚠️ 详细地址必须**延后一拍**写:AutoComplete 在 select 回调之后还会把 `poi.name`
   塞回输入框,同步写会被它覆盖(第一版就因此白写)。

## 账本:服务端补四条路由
`Ledger::rename / setShare / unshare / shares / updateEntry / removeEntry`
**模型一直都有**,`ApiHandler::ledger()` 却只开了「建账本 / 记一笔 / 删账本」
⇒ 端上只能把「共享给谁」「账本名字」画成灰条,账目也改不了删不掉。
新增:`PATCH /ledger/<id>`(改名)、`/ledger/<id>/shares`(GET/POST/DELETE)、
`PATCH|DELETE /ledger/<id>/entries`(id 走 `Api::int('id')` ——
`Api::dispatch()` 只解析 3 段,`/ledger/3/entries/17` 的第 4 段会被丢掉)。
`Ledger::mine/find` 补 `is_owner`(`my_perm` 只有 read/write,分不出主人);
改共享只允许主人(422)。

## 验证
- `familybook_push.sh` 全量流水线跑到 v47 停在 1 条失败:
  「③ 选中候选后地点被换成规范地名」—— 因为这条旧契约是 `loc == '万象城 CGV'`,
  而我们现在**有意**写成「名字 · 详细地址」。
  ⇒ 断言**升级**(不是放宽):既要求名字在前、又要求门牌进来,比原来更严。
- `_fb_v47_addr_sim.py` 复验 **16 / 0**;
  并做注入验证:把 `data-addr-detail="1"` 删掉 → ③ 立刻变红(证明断言真守着)。
- `_fb_v68_addr.py` 三模式 5/0(`--old` 拆掉两处时候选 0 条、`--one` 只拆 layout 仍 5/0)。
- 线上核对:`/api/v1/ledger/1/shares` 返回 **401**(不是 404,路由已生效)。
2026-10-20 21:35:47 +08:00

335 lines
17 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(' ');
}
/**
* 绑定一个地址输入框。
*
* @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 {
var ac = new window.AMap.AutoComplete({ input: input });
ac.on('select', function (e) {
var poi = (e && e.poi) ? e.poi : null;
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 构造器,忽略即可 */ }
});
/* ✅ 到这里才算**真的绑上了** —— v68 之前这行在最前面,绑不上也照样标记(见上)。 */
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 兜底,不影响功能 */
}
})();