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,路由已生效)。
This commit is contained in:
2026-10-20 21:35:47 +08:00
parent 1bc313adc6
commit b2b103df0f
5 changed files with 250 additions and 8 deletions
+67
View File
@@ -2363,6 +2363,7 @@ final class ApiHandler
}
if ($method === 'POST') {
Api::requireWrite();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
// ⚠️ `amount` 交给模型自己转分(Ledger::toCents)—— App 传元,
// 与网页表单同一口径;这里**不要**先转一次(会双重换算)。
$amount = Api::param('amount', '');
@@ -2378,11 +2379,77 @@ final class ApiHandler
], $pid);
Api::ok(['id' => $eid], 201);
}
/* 🔴 v68:改一笔 / 删一笔。
⚠️ 为什么 id 放在 **body/query** 而不是第 4 段路径:
`Api::dispatch()` 只解析 `/<res>/<id>/<sub>` **三段**,
`PATCH /ledgers/3/entries/17` 的第 4 段会被直接丢掉,
请求会落到"整本账本"上(v66 在 `trips/vehicles` 上踩过一次)。 */
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
$eid = Api::int('id');
if ($eid <= 0) { Api::fail('要改哪一笔?缺 `id`。', 'unprocessable', 422); }
$amount = Api::param('amount', '');
if (trim((string)$amount) === '') {
Api::fail('金额不能为空。', 'unprocessable', 422);
}
Ledger::updateEntry($id, $eid, [
'kind' => Api::str('kind', 'expense'),
'amount' => $amount,
'title' => Api::str('title', '', 120),
'note' => Api::str('note', ''),
'person_id' => Api::int('person_id'),
]);
Api::ok(['updated' => $eid]);
}
if ($method === 'DELETE') {
Api::requireWrite();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
$eid = Api::int('id');
if ($eid <= 0) { Api::fail('要删哪一笔?缺 `id`。', 'unprocessable', 422); }
Ledger::removeEntry($id, $eid);
Api::ok(['deleted' => $eid]);
}
}
/* 🔴 v68:共享给谁(端上"财务 → 设置 → 共享给谁"原来是个灰条)。
`Ledger::setShare/unshare/shares` 早就在模型里,只是没有路由。
⚠️ 只有**账本主人**能改共享关系:拿到 write 权限的被共享者
不该能再把账本转手给别人(否则等于改了别人的账本)。 */
if ($sub === 'shares') {
$isOwner = (int)($lg['owner_id'] ?? 0) === $pid;
if ($method === 'GET') {
Api::ok(['items' => Ledger::shares($id)]);
}
if ($method === 'POST') {
Api::requireWrite();
if (!$isOwner) { Api::fail('只有账本主人能改共享。', 'forbidden', 403); }
$who = Api::int('person_id');
if ($who <= 0) { Api::fail('要共享给谁?缺 `person_id`。', 'unprocessable', 422); }
Ledger::setShare($id, $who, Api::str('perm', 'read'));
Api::ok(['items' => Ledger::shares($id)]);
}
if ($method === 'DELETE') {
Api::requireWrite();
if (!$isOwner) { Api::fail('只有账本主人能改共享。', 'forbidden', 403); }
$who = Api::int('person_id');
if ($who <= 0) { Api::fail('要取消谁的共享?缺 `person_id`。', 'unprocessable', 422); }
Ledger::unshare($id, $who);
Api::ok(['items' => Ledger::shares($id)]);
}
}
if ($method === 'GET') {
$es = Ledger::entries($id);
Api::ok(['ledger' => $lg, 'summary' => Ledger::summary($es)]);
}
/* v68:账本改名(端上"账本名字"那条原来也是灰条) */
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
$nm = Api::str('name', '', 60);
if ($nm === '') { Api::fail('账本名字不能为空。', 'unprocessable', 422); }
Ledger::rename($fam, $id, $nm);
Api::ok(Ledger::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Ledger::remove($fam, $id, $pid);