Files
laoyang 8d79857f1a v54:首页任务标题截断(真根因=两列网格被撑破)/ 名册分组→标签且候选读词表 / 旅程地图视图 / 旅程设置按钮 / 显示方式默认列表或地图
用户 2026-10-16 傍晚五条。

① 首页「近期任务」标题太长会挤成两行
   用户原话:「首页**近期任务**那个地方要注意截断:如果任务标题太长,
   会把这个任务**挤成两行**。」
   🔴🔴 **真根因是两层,第一版只修了表面**:
     · 表面:`.dash-what` 只有 `flex:1 1 auto; min-width:0`,**缺** nowrap/hidden/ellipsis
       ⇒ 收缩后文字在框内换行;
     · 真身:`.home-duo` 是**两列 grid**,grid 子项默认 `min-width: auto`
       ("我最小也得装下我的内容")⇒ 一条长标题把那一列顶到 **1763px**
       (父 `home-duo` 才 1032px)⇒ **整个两列布局被撑破**、页面横向溢出到 **2089px**
       (视口 1440)。⇒ 行宽 1721px、`scrollWidth === clientWidth`,
       **`text-overflow: ellipsis` 看起来"没生效"** —— 其实容器自己先爆了。
   修法:`.home-duo > * { min-width: 0; }`,且 **整条 flex 链**都补
     (`.hd-pane` → `.hd-pane-head` → `.hd-pane-body` → `.dash-list/.tk-list` → `.tk-row`)
     —— 链上任何一层漏了,收缩就断在那一层、下面的 nowrap 全白写。
   配套:任务标题加 `title=` 悬停看全。
   ⚠️ 这是本项目**第二次**踩 flex/grid 的 `min-width:auto`(v53 的 `.mv-grid` 是同类)。
      **以后"文字截断不生效"先量容器有没有被撑破**。

② 名册「分组」→「标签」;打标签的候选要**从设置里读**
   用户原话:「现在名册里面给人打标签的时候,**这个标签数据是从哪读出来的**呀?
   在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候,
   **却不是从这些分组里读出来的**。……应该叫「名册标签」,是不是?
   **应该从这里面把所有标签都读出来**吧?……以后就没有分组的概念了,
   **全部都是标签的概念**。」
   🔴 **用户观察得完全正确,这是两套数据源**:
     · 设置页显示的词表来自 `person_groups`(**分组表**);
     · 打标签的候选来自 `PersonTag::cloud()`(**你已经打过的词**,现算的);
     ⇒ 两边根本不是一个数据源 ⇒ "设置里列着一堆、打标签时却只有几个"。
   修法:**`PersonTag::choices()` = 设置页词表 ∪ 我用过的词**(并集,词表在前)。
     · 词表里的词**还没用过**也能一点就打上(这正是词表的意义);
     · 用过的词补在后面(历史不丢);
     · 名册列表 / 新增 / 编辑 / 挑人 / 导入 **五处同一个函数**。
   界面口径全面改"标签"(**表名仍是 `person_groups`**:改名要背着 `PersonGroup` 类、
   `person_roster.group_id`、以及**另一个会话正在写的 `app/ApiHandler.php`**
   (它引用了 `PersonGroup::orderFor`)一起动,风险远大于收益):
     · `_person_groups.php` 标题「名册标签」、按钮「加一个我自己的标签」;
     · 删除文案改成"只是从候选里去掉这个词,已打在别人身上的不受影响"
       (**语义真的变了**:标签是各自身上的字符串,不是引用);
     · `people/_settings_menu.php`、`people/settings.php`、`settings/me.php`、
       `settings.php`(「公共分组」→「公共标签」)全部同步。
   顺带按"全部都是标签"改另两条入口:
     · `people/pick.php`「加进哪一组」下拉 → **打标签**;
     · `people/import.php` 同上;`ContactImporter::run()` 加 `$tags` 参数。
   🔴 新增 `PersonTag::addOne()`(**追加**一个标签)—— 这两处**绝不能用 `setFor()`**
      (它是"先 DELETE 再 INSERT"的整组替换)⇒ 会把人**原有**的标签清光
      (症状是"用一次导入,别人原有的标签全没了",极难联想)。
   ⚠️ `group_id` 在 pick/import 的**服务端仍收着**(老书签/脚本兼容),只是界面不再展示。

③ 旅程模块加**地图视图**(读 TripLog 移植)
   用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,**可以在地图上显示旅程数据**。
   1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」
   读 TripLog(`templates/activities/index.php`)得到的要点:
     · 用**高德 AMap**(合规白名单内,绝不是境外瓦片);
     · `position` 必须是 **[lng,lat] 数组**(传 `{lng,lat}` 裸对象会让 AMap 抛错、
       整个绘制循环当场中断 —— 它注释里写着"批 11 的光秃秃地图就是它");
     · `setFitView()` 后**上限 11 级**(只有一趟时会被放大到街道级,那是导航视角);
     · `.amap-marker` 整层 `pointer-events:none`(否则包装层挡住别家卡片的点击);
     · **容器必须有固定高度**(0 高度 → 空白地图)。
     它那套"同城环形扇开"是**几百行几何**,本仓数据量没压出这个问题 ⇒ **有意不做**。
   本仓数据更规整:`trip_nodes` 早有 `from_lng/from_lat/to_lng/to_lat`(v17 批)
   + `from_city/to_city` ⇒ 新增 `Trip::attachSpots()`:
     ① 有坐标取**重心**(不是端点 —— 端点会让"青岛→伊春"偏到某一头);
     ② 没坐标退到**城市重心**(`Geo::lookup` 离线城市表,**不发网络请求**:
        列表页几十趟若逐条打高德逆地理,一次开页就是几十个外部请求);
     ③ 都没有就不上图(列表里照常有,模板会说清"有几趟没上图")。
     🔴 城市那里**必须也取重心**:`cities()` 的顺序是**按时间**(出发地在最前),
        取"第一个城市"会让**所有旅程都叠在家上**、地图毫无信息量。
   布局:两个视图用**标签页**(`?view=map` 是**链接**不是纯 JS 按钮 ——
     书签/前进后退/无 JS 都正确;也**有意不**把地图藏在 `display:none` 里,
     高德在 0 尺寸容器里会渲染空白)。
   高德 SDK 加载器重构:原来 `fbLoadAddr()` 顺带拉 `addr_suggest.js`,
   而地图只用 SDK 自带的 `AMap.Map/Marker` ⇒ 抽出 `window.fbAmapSdk()`(只拉 SDK)、
   新增 `fb-amap` meta(不带 plugin),`fb-addr` 那份共用同一加载器。

④ 旅程列表页「新建旅程」**后面**加「设置」按钮
   用户原话:「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。」
   ⇒ 补 `_settings_menu.php`(设置恒排最右),菜单**第一条**加「显示方式」。

⑤ 显示方式:默认列表还是地图(用户自配)
   用户原话:「3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」
   ⇒ 新增 `trips/display`(设置页 + `?set=` 快速设置)与 `trips/display_save`,
     存 **`user_settings.trip_default_view`**(每人一份,**不是**全家设置)。
   ⚠️ 访客也能改(只影响自己看到的默认,不是数据变更)。

验证(新增 2 套件 + 更新 e2e/deploy 断言):
 · _fb_v54_ui.py(**33 条**真机断言)
 · _b54_falsify.py(**11 注入 / 11 全抓**,基线 33/0)
 · e2e **1518/0**;familybook_deploy.sh 加「第54批形态」段(**24 条**)
 · 部署 **0 FAIL**;线上 **20 个文件 md5 逐字节一致**;
   线上实测:词表 24 条、`choices()` 24 条、**`cloud` 15 条全部 ⊆ `choices`**、
   4 趟旅程全部可上图(样例落在济南·青岛重心);诊断页已删复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md):
 · 🔴🔴 **"文字截断不生效"要先量容器有没有被撑破**(第一嫌疑是 flex/grid 子项
   `min-width:auto`,不是 `text-overflow` 写错);
 · **反向断言("不该再出现 X")必须用 `_code_has`(剥注释)** ——
   需求原话正当留在注释里,裸 `grep -F` 会命中自己的说明(本轮部署假红一次);
 · **冗余防线会让反向验证打不红**(两处 `min-width:0` 只去掉一条 ⇒ 探针照旧全绿,
   注入必须一起去掉);
 · **界面多加一个字符就可能打歪既有断言**(我在词表管理页给标签名加了 `#`,
   把 e2e 的 `my_group_order()` 读成 `#家人`)。
2026-10-17 00:15:06 +08:00

226 lines
12 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
/**
* 旅程列表(v12 建立;v24 第 16 批 第 9 条把术语**固定下来**;
* v54 用户 2026-10-16 第 3/4/5 条加了**地图视图**与显示方式设置)。
*
* ⚠️ 术语(用户第 9 条原话):「活动都改名吧,改成旅程,新建旅程,旅程包含行程,
* 这个名字就固定下来。」
* → **旅程** = 这一页列的东西(容器);**行程** = 旅程里面的一段一段。
* (第 15 批曾把容器叫"活动",本次按用户要求改成"旅程"。)
*
* 时间与城市都是从各段**行程**派生出来的(第 15 批第 2 条)——
* 列表里读的是 Trip::attachDerived() 挂上来的值,不是旅程自己的字段。
*
* 🔴 v54(用户 2026-10-16 第 3 条):「你去读一下 TripLog 的代码,在旅程这个模块,
* 可以在地图上显示旅程数据。1. 先读一下它之前那个代码是怎么实现的,
* 在我们的旅程模块也实现一下。」
* ⇒ 这一页现在有**两个视图**:「列表」(原有)与「地图」(新,见 _map_body.php)。
* 两者是**同一份 $rows** 的两种看法,用标签页切换、不刷新整页。
* ⚠️ 地图坐标由 `Trip::attachSpots()` 挂在 $rows 上(见路由)。
* 🔴 v54(第 4/5 条):「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。
* 3. 里面的设置让用户自己配置:到底**默认显示旅程列表,还是用地图显示**。」
* ⇒ 右上角加了 `_settings_menu.php`(含新的「显示方式」设置页);
* 默认看哪个由 `$tripDefaultView`(我的 user_settings)决定。
*/
$canWrite = \App\Auth::canWrite();
/* 默认视图:'list' | 'map'(来自 user_settings.trip_default_view,见路由)。
⚠️ 给个兜底常量而不是空串 —— 万一调用方没传,页面也要能正常显示列表。 */
$tripDefaultView = (string)($tripDefaultView ?? 'list');
if (!in_array($tripDefaultView, ['list', 'map'], true)) {
$tripDefaultView = 'list';
}
/* URL 上的 ?view=map 优先于个人默认("我就想现在看一眼地图"不该被设置卡住)。
⚠️ 值不合法时落回默认,避免 ?view=xxx 让页面空白。 */
$tripView = (string)($_GET['view'] ?? '');
if (!in_array($tripView, ['list', 'map'], true)) {
$tripView = $tripDefaultView;
}
$tripHasSpot = false;
foreach ((array)($rows ?? []) as $__r) {
if (!empty($__r['trip_spot'])) { $tripHasSpot = true; break; }
}
unset($__r);
?>
<div class="page-head">
<h1>旅程</h1>
<?php /* 导航条上这个模块仍叫「旅程」(用户就是这么称呼它的),
但这一页列的顶层单位是**活动** —— 一句副标题把两级关系说清楚,
否则"导航写旅程、页面写活动"会让人以为点错了地方。 */ ?>
<span class="sub">共 <?= (int)$total ?> 个 · 每个旅程里可以有若干段行程</span>
<div class="actions">
<?php if ($canWrite): ?>
<?php /* 🔴 v41(用户 2026-10-10):新建旅程改成**弹框**(原来是跳 `trips/new` 页)。
⚠️ 保留 `data-href`:JS 没跑时它仍是可直接访问的链接(渐进增强)。
⚠️ 用 `<button>` 而不是 `<a>` —— 点它不跳页,只开弹框。 */ ?>
<button class="btn" type="button" id="tripNewBtn"
data-href="<?= e(url('trips/new')) ?>"
aria-haspopup="dialog" aria-expanded="false">新建旅程</button>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
<?php /* 🔴 v54(第 4 条):「在旅程的「新建旅程」**后面**也加一个「设置」按钮。」
与其它旅程页同一份菜单(`_settings_menu.php`),设置恒排最右。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php /* ---- 视图切换(列表 / 地图)----
⚠️ 用**链接**而不是纯 JS 按钮:`?view=map` 能被书签、前进后退、无 JS 环境正确处理;
点链接会整页重载一次(比"两个 DOM 都渲染、JS 藏一个"更省内存,
地图那种案例尤其不该把它藏在 display:none 里 —— 高德在 0 尺寸容器里会渲染空白)。
⚠️ 只在**真有可显示的点**时才给「地图」这颗;否则点进去是一片空地图,
不如老实告诉用户"还没有能上图的位置"(他自己去填出发到达)。 */ ?>
<div class="trip-views" role="tablist">
<a class="tv-btn<?= $tripView === 'list' ? ' is-on' : '' ?>" role="tab"
aria-selected="<?= $tripView === 'list' ? 'true' : 'false' ?>"
href="<?= e(url('trips', ['view' => 'list'] + ($keyword !== '' ? ['keyword' => $keyword] : []))) ?>">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></svg>
列表
</a>
<a class="tv-btn<?= $tripView === 'map' ? ' is-on' : '' ?>" role="tab"
aria-selected="<?= $tripView === 'map' ? 'true' : 'false' ?>"
href="<?= e(url('trips', ['view' => 'map'] + ($keyword !== '' ? ['keyword' => $keyword] : []))) ?>">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.5l3.5-1.2 4 1.4 3.5-1.2v10.5l-3.5 1.2-4-1.4-3.5 1.2z"/><path d="M6 2.3v10.4M10 3.7v10.4"/></svg>
地图
<?php if (!$tripHasSpot): ?><span class="tv-dim" title="还没有能上图的位置">(0)</span><?php endif; ?>
</a>
<?php /* "把当前这个设成默认" —— 放在标签页同一行右侧。
⚠️ 只在**当前视图不是默认**时才给,否则会出现两颗功能重复的入口。 */ ?>
<?php if ($tripView !== $tripDefaultView): ?>
<a class="tv-default" href="<?= e(url('trips/display', ['set' => $tripView])) ?>"
title="以后打开旅程模块默认看这个">把「<?= $tripView === 'map' ? '地图' : '列表' ?>」设为默认</a>
<?php else: ?>
<span class="tv-default">默认看:<?= $tripView === 'map' ? '地图' : '列表' ?></span>
<?php endif; ?>
</div>
<?php if ($tripView === 'map'): ?>
<?php /* ---- 地图视图 ---- */ ?>
<?php require __DIR__ . '/_map_body.php'; ?>
<?php else: ?>
<?php /* ---- 列表视图:包一层 id,供"新建成功后**动态刷新**"换掉(用户第 2 条)---- */ ?>
<div id="tripListHost">
<?php $tripListBody = true; require __DIR__ . '/_list_body.php'; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<?php /* ---- 新建旅程弹框(内嵌同一份 form.php;初始关闭)---- */ ?>
<?php
$tpModalOnly = true;
$tpOpen = false;
$tpModal = true;
$tIn = null; // 新建
$errorsIn = [];
require __DIR__ . '/form.php';
unset($tpModalOnly, $tpOpen, $tpModal, $tIn, $errorsIn);
?>
<script>
/* ==================== 新建旅程:弹框 + AJAX + 成功后刷新列表 ====================
用户第 2 条:「下面的按钮就是确定和取消两个…如果是取消的话,就直接关闭这个弹窗。
确定完了之后,动态刷新旅程这个列表页面。」
🔴 与观影详情页编辑弹框同一套做法,理由是**同一批坑**:
· 事件委托(挂 document)—— 校验失败时服务端会把弹框整块重画,
旧元素连监听一起丢掉;委托则一次绑定、永久有效。
· `<button form="tripForm">` 在表单**外面**:实测点它浏览器会提交,
但挂在 document 上的 submit 监听**收不到事件** ⇒ 主路必须走 click。
· 成功后只换 `#tripListHost` 的内容(保留滚动位置、不闪白)。 */
(function () {
if (!window.fetch || !window.FormData) { return; } // 老浏览器:保持原生整页提交
function card() { return document.getElementById('tripNewCard'); }
function setOpen(on) {
var c = card();
if (!c) { return; }
c.hidden = !on;
var b = document.getElementById('tripNewBtn');
if (b) { b.setAttribute('aria-expanded', on ? 'true' : 'false'); }
if (on) {
/* 弹框是动态插入的:日期/选人/上传那些组件要在这一刻升级 */
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('t-title');
if (t) { t.focus(); }
}
}
function busy(on) {
var s = document.getElementById('tripNewSubmit');
if (s) { s.disabled = !!on; s.textContent = on ? '保存中…' : '确定'; }
}
/* 打开 / 关闭(委托) */
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('#tripNewBtn')) { e.preventDefault(); setOpen(true); return; }
if (t.closest('#tripNewX') || t.closest('#tripNewCancel')) { setOpen(false); return; }
var c = card();
if (c && t === c) { setOpen(false); } // 点遮罩关;点面板内部不关
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var c = card();
if (!c || c.hidden) { return; }
var d = document.querySelector('.ant-picker-dropdown');
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; } // 交给 antd
setOpen(false);
});
/* 确定:提交(click 主路 + submit 兜回车) */
function save() {
var form = document.getElementById('tripForm');
if (!form) { return; }
busy(true);
fetch(form.action, {
method: 'POST', body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
redirect: 'follow',
}).then(function (r) {
var ct = (r.headers.get('content-type') || '');
if (ct.indexOf('application/json') >= 0) {
/* 校验失败:服务端回重画后的弹框 → 整块换掉(红字/回填由模板产出) */
return r.json().then(function (j) {
var old = card();
if (!old || !j || !j.modal) { return; }
var tmp = document.createElement('div');
tmp.innerHTML = j.modal;
var fresh = tmp.querySelector('#tripNewCard');
if (!fresh) { return; }
old.parentNode.replaceChild(fresh, old);
setOpen(true);
});
}
/* 成功:跟到最终页(列表),只换列表容器 */
return r.text().then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var freshList = doc.getElementById('tripListHost');
var curList = document.getElementById('tripListHost');
if (freshList && curList) { curList.innerHTML = freshList.innerHTML; }
else { window.location.reload(); return; }
/* flash 在正文容器里、会随列表一起换掉;但"新弹框"要复位成关闭 */
var c = card();
if (c) { c.hidden = true; }
var b = document.getElementById('tripNewBtn');
if (b) { b.setAttribute('aria-expanded', 'false'); }
});
}).catch(function () {
busy(false);
form.submit(); // 退回原生提交(不静默吞)
}).then(function () { busy(false); });
}
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest || !t.closest('#tripNewSubmit')) { return; }
ev.preventDefault();
save();
});
document.addEventListener('submit', function (ev) {
var f = ev.target;
if (!f || f.id !== 'tripForm') { return; }
ev.preventDefault();
save();
});
})();
</script>
<?php endif; ?>