Files
FamilyBook/templates/layout.php
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

496 lines
29 KiB
PHP
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.
<?php
// ⚠️ 「系统设置」**不在**顶栏里(用户明确要求):它已经收进右上角头像下拉菜单里了,
// 顶栏留「首页 / 家谱 / 名册 / 观影」四个日常入口,别再把管理入口顶出来。
// ⚠️ v11:「影院」也**不在**顶栏里了(用户明确要求)—— 影院是观影模块的一部分,
// 统一从观影列表页右侧的「设置」按钮进(那里还挂着高级搜索/统计/年度报告)。
// 别再往 $navItems 里加子功能入口,顶栏只放"模块"这一级。
// ⚠️ v14 起导航条**不再写死**:模块清单在 App\Module::LIST 里(加一个模块只改那一处),
// 显示哪些、什么顺序由**每个人自己**的 user_settings('nav_modules') 决定
// (用户第 10 条:允许设置哪些模块出现在导航条上,拖动排序)。
// App\Module::navFor() 内部做了两道兜底(剔除已不存在的模块、补上新装的模块),
// 这里直接拿结果用,不要再自己过滤一遍。
$navKeys = \App\Module::navFor((int)\App\Auth::personId());
$navItems = [];
foreach ($navKeys as $k) {
$navItems[$k] = [\App\Module::name($k), \App\Module::route($k)];
}
// 本页属于哪个模块 → 决定主题色(用户第 11 条:每个模块一个主题色)
$moduleKey = \App\Module::ofPage((string)$pageKey);
if ($moduleKey === '') {
$moduleKey = 'home';
}
$themeVars = \App\Module::cssVars($moduleKey, (int)\App\Auth::familyId());
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后
// 链接就变成 people/show&id=0,还顺带在页首打出一条 Warning。
// 改名前请先确认所有模板都没有依赖这个布尔值。
$isLoggedIn = \App\Auth::check();
?>
<!DOCTYPE html>
<html lang="zh-CN" class="no-js">
<head>
<?php /* v24 第 12 批 #2:解决「打开页面先闪一下原生输入框,然后才变成 Semi 控件」。
做法是经典的 **no-js / js 二态**:
· 服务端先渲染 class="no-js"(此时原生控件可见 = 无 JS 也能用);
· 下面这段内联脚本**在 <head> 里立即把 class 换成 js** ——
它在 CSS 生效前、任何渲染之前就跑了,所以浏览器**根本没机会**画出原生框;
· CSS 里 `.js .dt-input .dt-native { visibility:hidden }` 先把原生藏住
(用 visibility 而不是 display:none:保留占位高度,避免升级瞬间布局跳动);
· app.js 的 Semi 就绪后加 `.is-semi` 把 Semi 显示出来(原生继续藏着)。
这样"闪烁"从**结构上**消失,而不是靠调加载顺序去掩盖。 */ ?>
<script>document.documentElement.className =
document.documentElement.className.replace('no-js', 'js');</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?> · 家书</title>
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#A84A28">
<link rel="stylesheet" href="<?= e(asset('/assets/css/app.css')) ?>">
<?php /* v24 第 9 批 #9:Semi Design DatePicker 懒加载清单(用户点名 semi.design)。
这里只登记带版本号的资源地址,**不加载** —— app.js 只有在页面上真出现
.dt-input(templates/_datetime.php)时才去下这 4 个文件(合计约 4 MB,
绝不能让没有日期控件的页面白拉)。 */ ?>
<meta name="fb-semi" content="<?= e(json_encode([
'css' => asset('/assets/vendor/semi/semi.min.css'),
'react' => asset('/assets/vendor/semi/react.min.js'),
'dom' => asset('/assets/vendor/semi/react-dom.min.js'),
'ui' => asset('/assets/vendor/semi/semi-ui.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<?php /* v24 第 11 批 #5:Cropper.js(裁剪)也是**按需加载** ——
只有页面上真有 input[type=file][data-crop-ratio] 时才去拉(见 app.js)。
同样在这里登记带版本号的地址,绝不在没有裁剪的页面白拉。 */ ?>
<meta name="fb-cropper" content="<?= e(json_encode([
'css' => asset('/assets/vendor/cropper/cropper.min.css'),
'js' => asset('/assets/vendor/cropper/cropper.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<?php /* 🔴 v47(用户 2026-10-13):**地址联想**(高德 AutoComplete)。
影院名称 / 行程出发到达 / 日程地点 三处都要"边打边出候选、选中自动补齐地址"。
与 Cropper 同一套按需加载:页面上真有 `[data-addr]` 才去拉脚本与高德 SDK
—— 高德 SDK 本身百 KB 级,绝不能让用不到的页面白拉。
⚠️ 高德的两个配置(JS API key 与安全密钥)**只在真要用时才输出**:
它们是外部服务的凭据,没用到地址联想的页面不该把它们写进 HTML。
⚠️ key 为空(没配高德)时整块不输出 ⇒ 前端 `sdkReady()` 为假 ⇒
联想静默降级、输入框照常用(见 addr_suggest.js 的降级注释)。 */
$_amapJs = \App\Geo::jsConfig();
$_needAddr = ($_amapJs['key'] !== '');
?>
<?php /* 🔴 v54(用户 2026-10-16 第 3 条):**高德地图 SDK** 的独立登记项。
旅程模块的"地图视图"要用 `AMap.Map` / `AMap.Marker`(不需要地址联想),
所以 SDK 的加载器必须能从**不依赖 `[data-addr]`** 的入口触发。
做法:把 SDK 地址与密钥单独挂一个 `fb-amap` meta,`fb-addr` 那份**共用**
同一个加载器(见下面脚本里 `window.fbAmapSdk()`)。
⚠️ 仍然只在**配了 key** 时输出(凭据不该出现在用不到高德的页面)。
⚠️ 插件按需:地图本身**不需要** AutoComplete 插件,只有地址联想要 ——
所以两份 meta 各带自己的 `plugin`(`fb-amap` 不带插件)。 */
if ($_needAddr): ?>
<meta name="fb-amap" content="<?= e(json_encode([
'sdk' => 'https://webapi.amap.com/maps?v=2.0&key=' . rawurlencode($_amapJs['key']),
'security' => $_amapJs['security'],
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<meta name="fb-addr" content="<?= e(json_encode([
'js' => asset('/assets/js/addr_suggest.js'),
'sdk' => 'https://webapi.amap.com/maps?v=2.0&key=' . rawurlencode($_amapJs['key'])
. '&plugin=AMap.AutoComplete',
'security' => $_amapJs['security'],
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<?php endif; ?>
<?php /* 🔴🔴 v38(用户 2026-10-10):「我终于找到了一个我比较认可的日期选择插件
https://ant-design.antgroup.com/components/date-picker-cn 现在观影这个模块
进行测试吧…等这个模块测试完没问题了,再更新到其他的模块。」
⇒ Ant Design 的 RangePicker **只在观影模块试点**(其余页面继续用自建下拉)。
同样按需加载:只有页面真出现 `[data-dt-antd]` 才去拉那几个文件
(antd-with-locales 单文件约 1.8 MB,绝不能让用不到的页面白拉)。
⚠️ 必须同时引 `reset.css` —— antd 的组件依赖它做 box-sizing/边距归一,
不引会让面板里的日期格子错位(它是 antd 官方文档里指定的用法)。
⚠️ `dayjs` 也要引:antd 5 的值类型是 dayjs 对象,UMD 版从全局 `dayjs` 取。 */ ?>
<meta name="fb-antd" content="<?= e(json_encode([
'css' => asset('/assets/vendor/antd/reset.css'),
/* 🔴 v39(用户 2026-10-10):「时间控件…还有下面的确定按钮,现在显示的都是英文,
应该给它设置成中文。」
⇒ 换成 **antd-with-locales**(同一个包的另一个产物,多带各语言包),
它在全局多挂一个 `antd.locales`,`antd.locales.zh_CN` 就是简体中文包
(含「确定 / 取消 / 此刻 / 今天」与星期/月份格式)。
⚠️ 普通 `antd.min.js` **不含** locales(实测 `antd.locales` 是 undefined)
—— 这是"按钮还是英文"的真正原因,换掉这个文件才解决。
⚠️ 它还依赖 dayjs 的中文 locale(星期/月份由 dayjs 出),见下面的 dayjsZh。 */
'antd' => asset('/assets/vendor/antd/antd-with-locales.min.js'),
'dayjs' => asset('/assets/vendor/antd/dayjs.min.js'),
/* dayjs 的核心包**只有 en**;中文 locale 是单独一个文件,
加载后调 `dayjs.locale('zh-cn')` 才生效(否则面板里星期还是 Su/Mo…)。 */
'dayjsZh' => asset('/assets/vendor/antd/dayjs-zh-cn.js'),
/* ⚠️ antd 的 UMD **要求全局有 React / ReactDOM**(缺一个就直接挂不出来 ——
实测报错是 `Cannot read properties of undefined (reading 'createContext')`)。
项目里已经有一套 React 18.3.1(原本给 Semi 用的),**直接复用同一份**:
同一个 UMD 文件、同版本,先后加载只会重复赋值、不会冲突。
⇒ 主版本要动的话,这两处(fb-semi / fb-antd)必须一起动。 */
'react' => asset('/assets/vendor/semi/react.min.js'),
'dom' => asset('/assets/vendor/semi/react-dom.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
</head>
<?php /* 主题色:把 --brand / --brand-2 / --brand-ink 三个变量在 <body> 上换成本模块的色,
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 ——
模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?>
<body class="mod-<?= e($moduleKey) ?>"
data-media-base="media.php?k="
style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;">
<?php if ($isLoggedIn): ?>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="<?= e(url('home')) ?>">
<img src="<?= e(asset('/assets/icons/icon-96.png')) ?>" alt="">
<span>家书</span>
</a>
<nav class="nav">
<?php foreach ($navItems as $key => list($label, $route)): ?>
<?php /* 高亮按「本页属于哪个模块」判,而不是 pageKey 全等 ——
子页(观影里的影院管理/统计/年度报告)也要让「观影」亮着。 */ ?>
<a class="<?= $moduleKey === $key ? 'on' : '' ?>" href="<?= e(url($route)) ?>"><?= e($label) ?></a>
<?php endforeach; ?>
</nav>
<div class="topbar-right">
<?php /* 🔴 v32 第 3 条:浏览器提醒的**开关**(小铃铛)。
用户原话:「…则需要在 9:50 时浏览器中发提醒通知。」
⚠️ 浏览器**不会**允许"页面加载时自动申请通知权限"(Chrome 会直接静默拒绝,
并把域名记进"滥用"名单),所以权限必须由用户**点一下**才要。
⇒ 这里放一个按钮,由 assets/js/notify.js 绑定。
⚠️ 已经授权时(或浏览器不支持 Notification 时)按钮自动隐藏 ——
见 notify.js 的 paint()。所以未授权的人才会看到它。 */ ?>
<button type="button" class="btn btn-ghost btn-icon fb-notify-enable" hidden
title="开启浏览器提醒" aria-label="开启浏览器提醒">
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7a4 4 0 0 1 8 0v3l1 2H3l1-2z"/>
<path d="M6.5 12.5a1.6 1.6 0 0 0 3 0"/>
</svg>
</button>
<?php /* 姓名 + 头像 = 一个下拉菜单,而不是「点名字改资料」+旁边两个裸链接。
裸链接在窄屏只能靠 display:none 藏起来(一藏入口就没了),
收进菜单之后同一个入口在手机上也点得到。
用 <details> 实现:不需要 JS 就能展开/收起,app.js 只额外补
"点外面"和"Esc"两件事。 */ ?>
<details class="menu who-menu">
<summary class="who" title="我的">
<img src="<?= e(avatar_url($_SESSION['avatar'] ?? null)) ?>" alt="">
<span class="who-name"><?= e(\App\Auth::name()) ?></span>
<?php if (\App\Auth::isGuest()): ?><span class="badge">访客</span><?php endif; ?>
<i class="caret" aria-hidden="true"></i>
</summary>
<div class="menu-list">
<?php
/* 🔴 v30 第 5 条:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
放在个人设置前面,名字:通知消息。」
⇒ 它排在**个人设置之前**,且带一个未读角标(有几条没看)。
⚠️ 这是通知唯一的入口 —— 顶栏不渲染它(它不在 Module::LIST,
见 app/Notification.php 头部的第 ①条)。 */
$fbUnread = \App\Notification::unreadCount((int)\App\Auth::personId());
?>
<a href="<?= e(url('notifications')) ?>">
通知消息
<?php if ($fbUnread > 0): ?>
<span class="menu-badge"><?= (int)$fbUnread ?></span>
<?php endif; ?>
</a>
<a href="<?= e(url('settings/me')) ?>">个人设置</a>
<?php if (\App\Auth::isAdmin()): ?>
<a href="<?= e(url('settings')) ?>">系统设置</a>
<?php endif; ?>
<a href="<?= e(url('auth/password')) ?>">修改密码</a>
<a class="menu-exit" href="<?= e(url('auth/logout', ['_csrf' => csrf_token()])) ?>">退出登录</a>
</div>
</details>
</div>
</div>
</header>
<?php endif; ?>
<main class="<?= in_array($pageKey, ['install', 'login'], true) ? 'container-narrow' : 'container' ?>">
<?php foreach (take_flash() as $f): ?>
<div class="flash">
<div class="flash-item <?= $f['type'] === 'err' ? 'err' : '' ?>"><?= e($f['message']) ?></div>
</div>
<?php endforeach; ?>
<?php require $tplFile; ?>
</main>
<?php /* 全局附件预览层(v24 第 3 条):图片 / 视频 / PDF 都在本页这一层里看,
右上角恒有下载按钮。内容由 assets/js/preview.js 按 data-prev 现填。 */ ?>
<div class="pv" id="pv" role="dialog" aria-modal="true" aria-hidden="true" aria-label="附件预览" hidden>
<div class="pv-head">
<span class="pv-name"></span>
<span class="pv-meta"></span>
<a class="pv-dl" href="#">下载</a>
<button type="button" class="pv-close" aria-label="关闭">✕</button>
</div>
<div class="pv-stage"></div>
<?php /* v24 第 19 批 用户第 6 条:左右箭头,直接看下一张。
⚠️ 箭头用 `hidden` 属性控显隐(见 preview.js 的 syncArrows)——
只有一个可预览项时不该出现(点了没反应比没有更糟)。
与 .pv 一样,自己声明了 display 的元素要藏就得补 [hidden] 规则。 */ ?>
<button type="button" class="pv-nav pv-prev" aria-label="上一张" hidden>‹</button>
<button type="button" class="pv-nav pv-next" aria-label="下一张" hidden>›</button>
</div>
<?php /* 🔴 v43(用户 2026-10-11 第 6 条):**条目详情浮层**(全站一份)。
首页的日程 / 首页的近期任务 / 日程页的条目 —— 点它们不再直接跳页,
而是弹这一个浮层(详情 + 「详细信息」跳转 + 「编辑」就地换编辑表单)。
⚠️ 空壳放这里(不放各页面):「三处入口都在不同页面」,
放页面里迟早有一个忘了 include —— 那种"某些页面点了没反应"最难查。
⚠️ 内容全是 JS 按点击项的 `data-fb-entry` 现填,同一页一百条也只有一个浮层。 */
require __DIR__ . '/_entry_popup.php'; ?>
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
<?php /* 🔴 v43:条目详情浮层的脚本。**全站加载** ——
三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏;
它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?>
<script src="<?= e(asset('/assets/js/entry_popup.js')) ?>" defer></script>
<?php /* 🔴 v32 第 3 条:浏览器通知(站内消息 + 日程到期提醒)。
⚠️ **只在已登录时加载** —— 登录页轮询会一直 302 去登录页,白跑。
同理也不在"免登录的导航展示页"上跑(那页没有会话)。
⚠️ 里面**不含**任何权限申请:权限必须由用户点小铃铛才要(见 notify.js 头部第 ①条)。 */ ?>
<?php if (\App\Auth::check()): ?>
<script>
window.FB_NOTIFY = {
poll: <?= json_encode(url('notifications/poll')) ?>,
list: <?= json_encode(url('notifications')) ?>,
icon: <?= json_encode(asset('/assets/icons/icon-96.png')) ?>,
canPush: <?= json_encode(\App\Auth::canWrite()) ?>
};
</script>
<script src="<?= e(asset('/assets/js/notify.js')) ?>" defer></script>
<?php endif; ?>
<?php /* v24 第 20 批:选人组件脚本改成**按需加载**(页面上真有 `.pp` 才拉)。
⚠️ 理由与 Cropper.js 同一套:`person_picker.js` 只在放选人组件的页面才有用,
而它并不小;每页都无条件带一份,等于让"没有选人组件"的页面白付流量。
⚠️ 判据用 `document.querySelector('.pp[data-pp]')`(组件根节点的真实标记),
**不是**含糊地找 "pp" 这两个字母(页面里 `pp` 太容易撞:分页、其它类名…)。
⚠️ DOM 必须已经解析到组件才判断 → 脚本放在 `</body>` 前(本文件本来就是),
所以这里能直接查;不需要 DOMContentLoaded 包一层。
⚠️ 无 JS 兜底不受影响:组件的 `<noscript>` 块本来就是给"脚本没跑"准备的。
🔴🔴 v43(用户 2026-10-11 第 4 条):**上面那条"页面加载时扫一次"的判据不够了**。
行程详情页的编辑弹框是 AJAX 取片段、后来才把 `.pp` 塞进 DOM 的 ——
脚本那一刻**根本没被加载过**,之后也没人去补 ⇒ 选人按钮永远点不动
(「编辑一个行程的时候,参与的人…弹不出选人的框了」)。
⇒ 额外暴露一个 `window.fbLoadPicker()`:任何"动态插入含 `.pp` 的 HTML"之后
调它一次,脚本会被**按需拉起**(幂等:第二次调用直接返回同一个 Promise)。
配套的是 person_picker.js 里的 `window.fbInitPickers(scope)`(真正去绑定)。 */ ?>
<script>
(function () {
var url = <?= json_encode(asset('/assets/js/person_picker.js')) ?>;
var loading = null;
function load() {
if (window.fbInitPickers) { return Promise.resolve(true); }
if (loading) { return loading; }
loading = new Promise(function (res) {
var s = document.createElement('script');
s.src = url;
s.defer = true;
s.onload = function () { res(true); };
s.onerror = function () { loading = null; res(false); };
document.head.appendChild(s);
});
return loading;
}
// 供动态内容(AJAX 片段)按需拉起
window.fbLoadPicker = load;
// 首屏就有的(绝大多数页面):立刻拉
if (document.querySelector('.pp[data-pp]')) { load(); }
})();
</script>
<?php /* 🔴 v47(用户 2026-10-13):**地址联想**的按需加载(高德 AutoComplete)。
判据用 `[data-addr]`(组件根的真实标记),**不用**含糊的子串。
三层顺序不能反:先拉 SDK、再拉我们的脚本 —— 我们的脚本一执行就会去
`new AMap.AutoComplete(...)`,SDK 没就位就只能降级(等于白接)。
⚠️ SDK 加载失败也要能继续:`__AMAP_LOADED` 为假时 addr_suggest.js 里的
所有 AMap 调用都在判断之后 ⇒ 输入框仍是普通文本框,手敲照常保存。
⚠️ 用 `window.fbLoadAddr()` 暴露给 AJAX 片段(观影/日程的弹框都是后塞进来的),
与 person_picker 的 `fbLoadPicker()` 完全同一套路。 */ ?>
<script>
(function () {
/* 🔴 v54:配置源改成 `fb-amap`(**只要有高德 key 就输出**,与本页是否需要联想无关)——
因为"地图 SDK"与"地址联想脚本"现在是两件独立的事(见 meta 那段的说明)。
⚠️ 兼容旧路径:万一哪天有页面只带了 `fb-addr`(老缓存 HTML),这里也能兜住。 */
var metaAmap = document.querySelector('meta[name="fb-amap"]');
var metaAddr = document.querySelector('meta[name="fb-addr"]');
var meta = metaAmap || metaAddr;
if (!meta) { return; } // 没配高德 key → 什么都不做
var conf;
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
if (!conf || !conf.sdk) { return; }
var sdkReady = null;
function loadOurs() {
if (window.fbAddr) { return Promise.resolve(true); }
// 联想脚本只在 `fb-addr` 那份配置里(地图页不需要它)
var jsUrl = '';
if (metaAddr) {
try {
jsUrl = (JSON.parse(metaAddr.getAttribute('content') || '{}') || {}).js || '';
} catch (e) { jsUrl = ''; }
}
if (!jsUrl) { return Promise.resolve(false); }
return new Promise(function (res) {
var s = document.createElement('script');
s.src = jsUrl;
s.defer = true;
s.onload = function () { res(true); };
s.onerror = function () { res(false); };
document.head.appendChild(s);
});
}
/**
* 🔴 v54(用户 2026-10-16 第 3 条):把"**只拉高德地图 SDK**"这一步单独暴露出来。
*
* 为什么不能只留 `fbLoadAddr()`:那个函数**顺带**会去拉 `addr_suggest.js`
* (地址联想那一整块),而旅程地图只用 SDK 自带的 `AMap.Map` / `AMap.Marker`,
* 根本不需要联想脚本 —— 让地图页白拉一个用不上的脚本是浪费。
*
* ⚠️ 安全密钥 `_AMapSecurityConfig` 必须在 SDK **之前**设好(高德硬要求,
* 否则 SDK 内的服务调用会被拒)—— 所以密钥设置放在这里、由两个入口共用。
* ⚠️ 失败**不锁死** `sdkReady`:下次调用可以重试(网络抖动不该让地图永久白屏)。
* @return Promise<boolean> SDK 是否可用(AMap 全局对象就位)
*/
window.fbAmapSdk = function () {
if (window.__AMAP_LOADED && typeof window.AMap !== 'undefined') {
return Promise.resolve(true);
}
if (sdkReady) { return sdkReady; }
/* 密钥必须在此刻(SDK 之前)设好,见上面的说明 */
if (!window._AMapSecurityConfig) {
window._AMapSecurityConfig = { securityJsCode: conf.security || '' };
}
sdkReady = new Promise(function (res) {
var s = document.createElement('script');
s.src = conf.sdk;
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); }; // 失败不锁死,下次可重试
document.head.appendChild(s);
});
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<boolean> 插件是否可用
*/
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、再插件、最后我们的脚本"这个顺序。
🔴 插件那一步**不管成功与否都继续**去拉我们的脚本:
插件 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();
});
}
window.fbLoadAddr = function (scope) {
return boot().then(function (ok) {
if (ok && window.fbAddr) { window.fbAddr(scope || document); }
return ok;
});
};
if (document.querySelector('[data-addr]')) {
// ⚠️ SDK 是 defer 之后的动态插入,比我们的脚本晚 —— 所以必须等它 onload 再绑。
boot();
}
})();
</script>
<?php /* v27:笔记的分块编辑器同样**按需加载**(页面上真有 `#noteForm` 才拉)。
理由与 person_picker.js 完全同一套:它只在写笔记那一页有用,
每页都带一份等于让别的页面白付流量。判据用表单的真实 id(不是含糊找 "note")。 */ ?>
<script>
(function () {
if (!document.getElementById('noteForm')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/note_editor.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
<script src="<?= e(asset('/assets/js/filter.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
<?php /* 🔴 v54(用户 2026-10-16 第 3 条):旅程**地图**脚本,按需加载。
判据用 `#tripMapCanvas`(地图容器真实 id)—— 只有旅程列表的"地图视图"会输出它,
别的页面不该白拉这一个脚本(与 note_editor / addr_suggest 同一套规矩)。
⚠️ 它内部再按需去要**高德 SDK**(`window.fbAmapSdk()`,见上面 fb-amap 那段)——
所以这一条只管"要不要引我们自己的脚本",SDK 由脚本自己拉。 */ ?>
<script>
(function () {
if (!document.getElementById('tripMapCanvas')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/trip_map.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
<?php /* 🔴 v52(用户第 4 条):笔记**详情页**的分段编辑(就地改一段、不跳整页编辑器)。
⚠️ 只在阅读态需要它 —— 判据用 `.note-read-body[data-note-id]`(模板在阅读态才输出),
编辑页没有这个元素,因此不会白拉一份脚本。 */ ?>
<script>
(function () {
if (!document.querySelector('.note-read-body[data-note-id]')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/note_inline.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
</body>
</html>