diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 2e425ea..e9ecee5 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -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()` 只解析 `///` **三段**, + `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); diff --git a/app/Ledger.php b/app/Ledger.php index 94005e0..ca4ae68 100644 --- a/app/Ledger.php +++ b/app/Ledger.php @@ -69,6 +69,11 @@ final class Ledger $r['my_perm'] = (int)$r['owner_id'] === $personId ? 'write' : (string)($r['share_perm'] ?? 'read'); + /* v68:**是不是我建的**单独给一个布尔。 + `my_perm` 只有 read/write 两档 —— 被共享成 write 的人 `my_perm` + 也是 write,端上没法从它区分"主人"与"被共享者", + 而"改共享关系"只有主人能做(见 ApiHandler 的 shares 段)。 */ + $r['is_owner'] = (int)$r['owner_id'] === $personId; $out[] = $r; } return $out; @@ -87,6 +92,7 @@ final class Ledger return null; } $r['my_perm'] = (int)$r['owner_id'] === $personId ? 'write' : (string)($r['share_perm'] ?? 'read'); + $r['is_owner'] = (int)$r['owner_id'] === $personId; // v68,见 mine() 的说明 return $r; } diff --git a/assets/js/addr_suggest.js b/assets/js/addr_suggest.js index 77b81d9..d0f2492 100644 --- a/assets/js/addr_suggest.js +++ b/assets/js/addr_suggest.js @@ -26,9 +26,74 @@ 'use strict'; // 高德 SDK 的就绪状态由 layout 注入的那段脚本维护(见 fb-addr 注入块) + // 🔴 v68:**只认 `AMap.AutoComplete`**。v54 起 SDK 由一个不带 `plugin=` 的地址拉进来, + // 此时 `AMap` 有了但插件没有 ⇒ 这里必须为假,由下面 `ensurePlugin()` 去补; + // 若把判据放宽成"有 AMap 就算就绪",就会 `new AMap.AutoComplete` 抛错、 + // 然后**静默**什么都不发生(正是用户看到的"输入地点没反应")。 function sdkReady() { - return !!(window.__AMAP_LOADED && typeof window.AMap !== 'undefined' - && window.AMap.AutoComplete); + 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} */ @@ -68,6 +133,13 @@ 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); } @@ -83,7 +155,12 @@ */ function bindOne(input, scope) { if (!input || input.getAttribute('data-addr-bound') === '1') { return; } - input.setAttribute('data-addr-bound', '1'); + /* 🔴🔴 v68 修的**第二个** bug:这里原来一进门就打 `data-addr-bound='1'`, + 而"SDK 就绪"的判断在**后面**。只要那一次判断为假(v54 之后恒假), + 输入框就被永久标成"已绑定" ⇒ 后面谁来调 `fbAddr()` 都会被这行挡掉, + **哪怕插件后来加载成功了也再没机会绑上**。表现就是"以前好用、现在不好用了", + 而且改什么地方都不恢复,只能刷掉重来(重来也一样假)。 + ⇒ 标记改成"**真的 new 成功了**"才打。 */ // 字段查找范围:`data-addr-for` 给的是一个选择器。 // 🔴 必须优先取**最近的那个**祖先(`closest`),不能 `querySelector` 取第一个 —— @@ -144,7 +221,15 @@ // 手敲也要能被"补齐"(用户可能不用联想直接打字,或 SDK 没加载) // —— 这里只在**选了候选**时填,避免把用户手写的地址覆盖掉。 - if (!sdkReady()) { return; } // 降级:没有联想,输入与保存都正常 + 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 }); @@ -156,6 +241,23 @@ 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; } @@ -178,6 +280,9 @@ } }); + // v68:把"官方给的省市区 + 详细地址"显示在框下(不塞进 value,见 showAddrHint) + if (detail) { showAddrHint(input, poi); } + // 让调用方知道"值被程序改过"(有些页面要跟着重画/联动) try { input.dispatchEvent(new CustomEvent('fb-addr-picked', { @@ -186,8 +291,11 @@ })); } catch (err) { /* 老浏览器没有 CustomEvent 构造器,忽略即可 */ } }); + /* ✅ 到这里才算**真的绑上了** —— v68 之前这行在最前面,绑不上也照样标记(见上)。 */ + input.setAttribute('data-addr-bound', '1'); } catch (err) { - /* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级,不打扰用户 */ + /* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级、不打扰用户。 + ⚠️ 不写 `data-addr-bound`:下次 `fbAddr()` 还能再试(用户切页面/重开弹框)。 */ } } @@ -203,8 +311,17 @@ 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 已解析完(在 前引入亦可)。 fbAddr(document); + ensurePlugin(); // SDK 若已就位但插件没拉,这里补一次 /* 通知"我上线了"。 ⚠️ 为什么需要:调用方(行程/影院/日程的弹框)可能在**本脚本加载之前** diff --git a/templates/calendar/_event_fields.php b/templates/calendar/_event_fields.php index 95fe531..6844fd1 100644 --- a/templates/calendar/_event_fields.php +++ b/templates/calendar/_event_fields.php @@ -64,10 +64,18 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us 换成高德认得的规范地名",后续要查经纬度/导航才查得到。 ⚠️ 这段表单既在整页(各视图的日程弹框)也在 AJAX 片段里出现, 所以打好 `data-addr` 标记即可 —— 由 layout 的 loader 与 - 弹框打开时的 `fbLoadAddr(scope)` 负责绑定(与 person_picker 同一套)。 */ ?> + 弹框打开时的 `fbLoadAddr(scope)` 负责绑定(与 person_picker 同一套)。 + 🔴 v68(用户 2026-10-20):「输入地点,应该调用高德接口,获得**准确的详细地址**, + 现在这个功能不好用了。」 + ① 不好用的真根因不在这一行(是 `layout.php` 的加载器取了不带 + `plugin=AMap.AutoComplete` 的 SDK 地址,见那里的注释); + ② 「详细地址」这一条要额外做点事:这一栏**只有它一个字段**, + 光把名字写回来,门牌号仍然没有 ⇒ 加 `data-addr-detail="1"`: + 选中候选时把地址一并写进 value(`名字 · 门牌`), + 并在这一行下面用一行灰字显示「省市区 · 门牌」。 */ ?> + data-addr data-addr-detail="1"> diff --git a/templates/layout.php b/templates/layout.php index 69ad24d..ad90166 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -376,6 +376,9 @@ window.FB_NOTIFY = { s.setAttribute('data-fb-amap', '1'); s.onload = function () { window.__AMAP_LOADED = true; + /* v68:通知"SDK 到了"。`addr_suggest.js` 可能在 SDK 之前就执行过 + (比如页面自己先引了那份脚本),它监听这个事件补一次按需加载插件 + 重扫。 */ + try { window.dispatchEvent(new Event('fb-amap-ready')); } catch (e) {} res(typeof window.AMap !== 'undefined'); }; s.onerror = function () { sdkReady = null; res(false); }; // 失败不锁死,下次可重试 @@ -384,12 +387,53 @@ window.FB_NOTIFY = { return sdkReady; }; + /** + * 🔴🔴 v68(用户 2026-10-20):「日程里输入地点应该调高德获得详细地址, + * 现在这个功能不好用了。」 + * + * 真根因(**不是 key 失效,也不是域名白名单**,真浏览器逐环量过): + * v54 给旅程地图加了 `fb-amap` 这条 meta(SDK 地址**不带** `plugin=AMap.AutoComplete`), + * 同时把加载器的配置源改成 `metaAmap || metaAddr` ⇒ 拉的是**不带插件**的那个地址。 + * SDK 的 `script.onload` 时 `AMap` 有了、`AMap.AutoComplete` 是 undefined + * (实测 `{"ac":"undefined","amap":"object"}`)⇒ `addr_suggest.js` 的 `sdkReady()` + * 恒假 ⇒ 联想一次都没绑上。而"地图视图"本身只用 `AMap.Map`,所以这条路一直都正常, + * 坏的只有联想 —— 表现就是"输入地点没反应",且**一声不响**。 + * + * 两个保险都加上: + * ① 这里在绑联想之前**显式**把插件按需拉起来(`AMap.plugin`,高德 2.0 的官方姿势; + * 带 `&plugin=` 参数也行,但那条只能由 SDK 地址决定,容易再被别的入口绕开); + * ② `addr_suggest.js` 那边不再"没就绪就打已绑定标记"(见该文件的 v68 注释)。 + * @return Promise 插件是否可用 + */ + function needAutoComplete() { + if (typeof window.AMap === 'undefined') { return Promise.resolve(false); } + if (window.AMap.AutoComplete) { return Promise.resolve(true); } + return new Promise(function (res) { + var done = false; + function fin(ok) { if (!done) { done = true; res(!!ok); } } + try { + window.AMap.plugin('AMap.AutoComplete', function () { + fin(window.AMap && window.AMap.AutoComplete); + }); + } catch (e) { fin(false); } + /* 插件是异步 chunk:网络慢时别把整页流程挂住 —— 超时后按"还没好"返回, + `addr_suggest.js` 会自己再兜一次(它也认 `AMap.plugin`)。 */ + setTimeout(function () { fin(window.AMap && window.AMap.AutoComplete); }, 8000); + }); + } + /* 安全密钥必须在 SDK **之前**设好(高德要求),否则 AutoComplete 会被拒。 */ function boot() { /* ⚠️ 密钥的设置已经收进 `fbAmapSdk()`(地图与联想两个入口共用一处), - 这里只需要"先 SDK、再我们的脚本"这个顺序。 */ + 这里只需要"先 SDK、再插件、最后我们的脚本"这个顺序。 + 🔴 插件那一步**不管成功与否都继续**去拉我们的脚本: + 插件 8 秒没到(弱网)也要让 `addr_suggest.js` 上线 —— + 它自己还会再 `AMap.plugin` 一次并监听 `fb-amap-ready`。 + 在这里 `return false` 会连脚本都不加载 ⇒ 连"自愈"的机会都没有。 */ return window.fbAmapSdk().then(function (ok) { if (!ok) { return false; } + return needAutoComplete(); + }).then(function () { return loadOurs(); }); }