Files
laoyang 69c47c3c43 网页端下拉框改成自绘浮层(照 FeedLite 的做法)(#85)
用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统里面的
下拉框到底是怎么做的?按它的做法做,它也有主题颜色,我们这个也有主题颜色,
就按照一样的做法去做。」

看了 FeedLite(~/code/FeedLite)的实现,关键结论:
  **原生 <select> 收起时能改样式,但展开的选项列表由浏览器绘制,CSS 完全改不动**
  (只能改 option 底色,各平台还不统一)⇒ 想好看只有自绘这条路。

照它的做法移植:

① CSS(assets/css/app.css)
   .sel-native  原生 select 视觉隐藏(**不能 display:none**,那样不参与提交)
   .sel         外层 inline-flex
   .sel-btn     触发按钮 —— 外观与普通输入框完全一致(40 高、同圆角同边框)
   .sel-caret   自绘箭头(CSS mask + 内联 SVG),**打开时转 180° 并变主题色**
   .sel-panel   浮层,position:fixed 挂 body
   .sel-item    列表项:34 高、圆角 7
   .sel-item.on 选中项:深一档主题底色 + 主题色文字 + 右侧对勾

② 主题色三档浅色(照 FeedLite 的比例)
   helpers.php 新增 fb_color_tint()(把 hex 往白色混)。
   layout.php 在 <body> 的 style 里输出:
     --brand-tint-1(混 91% 白)→ hover
     --brand-tint-2(混 80% 白)→ 选中项
     --brand-tint-3(混 72% 白)→ 选中项 hover
   ⚠️ 在 PHP 里算好,**不用 CSS 的 color-mix()** —— FeedLite 实测过:
      `color-mix(in srgb, var(--brand) 9%, #fff)` 在 Chrome 里解析成透明。

③ JS(assets/js/app.js 新增 fbBindSelects())
   遍历所有 select,在旁边生成按钮 + 浮层,原生 select 保留参与提交。
   · 派发原生 change → 兼容 onchange="this.form.submit()" 的内联写法
   · 浮层 fixed 定位(面板/浮层里大量 overflow:hidden,放旁边会被裁)
   · 下方空间不足且上方更宽裕时**向上翻**
   · 键盘:↑↓ / Home / End / Enter / Esc
   · 浮层 minWidth 跟随触发按钮 → "和输入框一样宽"
   · data-sel-enhanced 幂等标记

④ AJAX 路径也要增强
   task_panel.paint() 与 entry_popup.loadPanel() 里,紧跟 fbBootAntd() 之后
   调一次 window.fbBindSelects(document) —— 否则动态插入的 select 是裸的。

⚠️ app.js 整个是 IIFE 包裹的(结尾 `})();`),新函数必须插在闭包内部;
   直接追加到结尾会跑到全局作用域。

实测(Playwright + 站点真实 app.js/css):
  增强:data-sel-enhanced=1、原生 select 已隐藏、按钮文字「工作相关」✅
  尺寸:按钮 648 x 40,行宽 648 → 占满整行 ✅
  浮层:宽 648(与按钮同宽)✅ 6 项、选中项有对勾 ✅
  打开态:底色 rgb(250,242,238)=tint-1、文字 rgb(168,74,40)=brand、
          箭头 rotate 180° ✅
  选「个人事务」→ select.value=3、按钮文字同步、浮层关闭 ✅
2026-11-05 20:23:16 +08:00

539 lines
32 KiB
PHP
Raw Permalink 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 这些纸墨底色 ——
模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?>
<?php
/* #85:下拉框浮层要用的三档浅色(与 FeedLite 同一套比例)。
在这里一次算好,CSS 直接用 var(--brand-tint-1) 之类。
⚠️ 算不出来(主题色不是 6 位 hex)就留空,CSS 里有 fallback。 */
$fbTint1 = fb_color_tint((string)$themeVars['brand'], 0.91);
$fbTint2 = fb_color_tint((string)$themeVars['brand'], 0.80);
$fbTint3 = fb_color_tint((string)$themeVars['brand'], 0.72);
?>
<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']) ?>;
--brand-tint-1: <?= e($fbTint1) ?>; --brand-tint-2: <?= e($fbTint2) ?>; --brand-tint-3: <?= e($fbTint3) ?>;">
<?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 /* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
而是使用js动态调整。」
⇒ 新建/修改成功后**取服务端渲染好的片段整块替换**,不手写复刻渲染。
历史教训:手写复刻必然出错(`templates/calendar/index.php` 里 `putMonth` 的注释记着
一次真实事故 —— 新加一条 07:00 的日程被 append 到末尾,落进「+N 条」看不见,
刷新后却又跑到第一位)。
⚠️ 全站加载:它在**首页**和**日程页**各有落点,按需加载的判据会来回漏;
自带"两个落点都不在就什么都不做"的判据,开销可忽略。
⚠️ 必须排在 `entry_popup.js` 之后(浮层保存成功后要调它的 refresh)。
`defer` 保证按文档顺序执行,所以这两行的先后就是执行先后。 */ ?>
<script src="<?= e(asset('/assets/js/cal_sync.js')) ?>" defer></script>
<?php /* 🔴 v78:首页任务面板的动作脚本(状态/看板/子任务/附件/评论)。
同样全站加载:面板是浮层的一部分,而浮层本身就挂在本布局里;
脚本自带"面板不在就什么都不做"的判据。 */ ?>
<script src="<?= e(asset('/assets/js/task_panel.js')) ?>" defer></script>
<?php /* 🔴 v78:「新建任务」弹框的开关。任务页与首页共用同一张弹框,
所以开关也只有一份 —— 两处各写一段,必然有一处忘了打开时 boot 日期控件。 */ ?>
<script src="<?= e(asset('/assets/js/task_add.js')) ?>" defer></script>
<?php /* 🔴 v81(用户 2026-10-22):「首页上的其他卡片也应该有这些基本功能……把原来模块里
弹的那个框,现在拿到这边来。」⇒ 观影、纪念日的新建弹框也搬上首页。
三张弹框的开关逻辑一模一样(点遮罩关 / 点面板不关 / Esc 先让 antd 收面板 /
打开那一刻才 boot 日期控件),所以抽成**一份通用的**,各自只声明容器 id。
⚠️ 与上面 `task_add.js` 并存不冲突:那一份仍管 `#taskAddCard`(v78 已上线),
这一份只认 `[data-fb-add-modal]`,两者选择器不相交。 */ ?>
<script src="<?= e(asset('/assets/js/add_modal.js')) ?>" defer></script>
<?php /* 🔴🔴 v93(用户 2026-10-25):「如果新建日程,日历本是拉不下来的」——
通用「色点下拉」(`.cs`)的交互。原来只内联在 `templates/calendar/index.php` 里,
而「新建日程」那张弹框是**首页与日程页共用**的同一份模板 ⇒ 首页那份没有交互,
按钮点下去菜单不展开(用户看到的就是"只有一个『工作』")。
⚠️ 全站加载 + 按 `[data-cs-picker]` 初始化:只认这个标记,不扫所有 `.cs`
(观影表单的影院下拉有自己的内联绑定,两边都绑会"点一次开两次")。
⚠️ 幂等(`data-cs-ready`):浮层里点「编辑」会 AJAX 换进含下拉的表单,
那边由 `entry_popup.js` 再调一次 `fbInitCsPicker()` 补绑。 */ ?>
<script src="<?= e(asset('/assets/js/cs_picker.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>