Files
FamilyBook/templates/trips/show.php
T
laoyang 4fbac180e1 v51:旅程编辑保存后刷新 / 名册编辑页可改标签 / 手机号归一 / 反复点击带入时间+默认1h+分栏 / 日程设置拆分
用户 2026-10-15 六条。

① 旅程详情「编辑」保存后页面不刷新(改了题图/标题,页头纹丝不动)
   **两个 bug 串在一起**:
   A. 只换 `#nodeListHost`(行程列表),而标题/题图/时间城市都在那容器**外面**(服务端直出);
   B. 🔴 `form.id === 'tripForm'` **永远为假** —— HTML 表单把**同名具名控件**挂成自己的属性,
      `tripForm` 里有 `<input name="id">` ⇒ `form.id` 返回的是**那个 input**
      (`String(form.id)` = `[object HTMLInputElement]`)。同类还有 form.name/action/method。
   ⇒ 修法:`form.getAttribute('id')` 判型;tripForm(编辑旅程本体)→ **整页刷新**,
      nodeForm(行程增删改)→ 仍只换行程列表(那是 v41 用户明确要的"不整页跳")。

② 名册编辑页不能选标签("应该能编辑这个人的所有信息才对")
   `templates/people/form.php` 里压根没有标签 UI ⇒ 新增 `#ptBlock`:
   候选勾选(PersonTag::cloud)+ 自由输入(`name="tags"`,唯一提交源,勾选框只是快捷方式)。
   ⚠️ `PersonTag::setFor(家庭, **我**, 他, …)` —— owner 永远是"我",不影响全家的标签。
   ⚠️ 守卫按**字段存在与否**分流(array_key_exists),不是"值是否为空"——否则改自己时
      表单没这个字段,按"空值=清空"会把人家的标签抹掉。
   ⚠️ 校验失败的两条重渲染路径也要带上 `myTags`/`tagChoices`(取 `$_POST['tags']`)。

③ 导入的手机号带空格 → 编辑时被判"格式不对"("统一处理一下")
   真根因:主手机号正则 `^\+?[0-9\-]{6,20}$` **空格一律判错**,而补充电话那条收 `[0-9\- ]`
   ⇒ 同一个号码填主字段报错、填补充字段通过。
   ⇒ ① `Person::normPhone()` 去空格/短横/括号/**全角空格 U+3000**;
      ② 校验改成「先归一再判纯数字」——⚠️ 只把正则放宽到含 `\s` **不够**:
         PCRE 的 `\s`(无 /u/UCP)**不认全角空格**,通讯录导出里真有;
      ③ create/update/replaceContacts **入库前归一**(原来 hash 内部归一但**密文存原样**);
      ④ `Schema::ensure` 加**一次性迁移**(哨兵 `phone_normalized_v51`)把已入库的
         带分隔符号码解密→归一→写回(手机号是加密存的,SQL 里没法 LIKE/REPLACE)。

④ 周/日视图"只有第一次带入的时间是对的"
   真根因(真机给 `__fbRemount` 装 getter/setter 才抓到):起止字段藏在弹框里 ⇒
   boot 时 `upgradeRange()` 因"两端不可见"提前 return ⇒ 那一刻**只有 mountAntd** 给它赋值
   (会"重读原生值+重绘 antd");而**一打开弹框** `fbUpgradeRange()` 补跑 ⇒
   在尾部把它**重新赋成"只对齐原生下拉、不碰 antd"**的一版 ⇒ 此后 antd 控件永远停在第一次的值。
   ⇒ `upgradeRange()` 不再降级覆盖更强的实现,改成"把对齐下拉串在它前面"(`__fbRangeSync` 防自套)。

⑤ 默认结束 = 开始 + 1 小时;新增日程要按分栏错开(不再叠盖)
   · `setStart()` 里顺带设 ends_at = +1h(⚠️ 跨午夜顺延到次日;⚠️ 不捆进 fbSetDT,
     那个接口被"编辑已有日程"复用,强改会弄丢用户填的时长)。
   · 真根因:`putTimed()` 只把自己那条**整宽**铺上去,不知道同列已有日程
     (服务端整页渲染有分栏,但那是整页重绘时才算的)⇒ 前端 `relayoutColumn(col)`
     **照抄服务端 `conflictLanes`** 重排整列;模板加 `data-s`/`data-e`(**声明的起止分钟**,
     不能从 top/height 反推 —— 那是"实际显示结束",不是真实结束分钟)。

⑥ 日程设置下拉太长(日历本一多就把下面的设置挤出屏幕)→ 拆成"四行列表 + 显示设置独立页"
   新建 `templates/calendar/display.php` + 路由 `calendar/display`(默认视图 + 显示哪些本子);
   `_settings_menu.php` 从"内联表单"改成**纯列表四行**(显示设置/日历本管理/订阅管理/DAV 口令);
   ⚠️ `.cal-set-books` 原本 `max-height:42vh`("塞在下拉里"的妥协)⇒ 用 `.cal-set-page`
      作用域覆盖,否则整页里再套一层内滚 = 要滚两次。
   保存仍走既有 `calendar/prefs`(一个字段没改)。

验证:`_fb_v51_ui.py`(25 条真机)+ `_b51_falsify.py`(**12 注入 / 25 断言全抓**);
e2e 1511/0;`_fb_batch_e.py` 与 `familybook_deploy.sh` 里"设置菜单含 show_present"等
旧断言随 #6 搬到 display 页(意图一字不改);全流水线 PUSH_RC=0、部署 0 FAIL(含"第51批形态");
线上 10 文件 md5 逐字节一致。

⚠️ 排查纪律(已写进 pitfalls.md):
 · `form.id` 被同名控件遮蔽 —— 判型一律用 `getAttribute('id')`;
 · 反向验证的注入可能"打偏"(改的那处不是探针实际走的分支)——
   本轮 12 个注入有 2 个一开始没变红,都是注入打偏、不是断言弱。
2026-10-15 10:37:07 +08:00

771 lines
44 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
/**
* 旅程详情(v24 第 16 批重做)。
*
* ⚠️ 术语(用户第 9 条:「活动都改名吧,改成旅程,新建旅程,旅程包含行程,
* 这个名字就固定下来」):
* · **旅程** = 这一页(原来叫"活动")
* · **行程** = 里面的一段一段(原来叫"旅程")
*
* 页面结构(用户第 1/2/3/6 条):
* ① 页头:标题 + 右上角按钮(计划行程 / 实际行程 / 设置 / 编辑 / 删除)
* —— 标题旁边**不放时间**(第 6 条:时间移到内容里、题图右侧)
* ② 封面行:左 16:9 题图 + 右侧信息区
* 信息区 = 参与人 / 城市数 / 天数 / 行程段数 / 总花销 + 简介那一段话
* ③ 行程分组:**只显示非空的分组**(第 4 条:"只要是没有就是 0 条,
* 那个类型就不显示了,现在显示的是 0 条,这样没意义")
* ④ **没有照片上传板块**(第 3 条:图片视频都针对一次具体行程)
*
* @param array $t 旅程(events + trip_details + 派生值)
* @param array $nodes 全部行程(已带上成员/附件/花销)
* @param array $sm 汇总信息(Trip::summary())
*/
$canWrite = \App\Auth::canWrite();
$fid = (int)\App\Auth::familyId();
$sm = $sm ?? [];
$people = $sm['people'] ?? [];
$cities = $sm['cities'] ?? [];
$days = (int)($sm['days'] ?? 0);
$nodeN = (int)($sm['nodes'] ?? 0);
$cents = (int)($sm['cents'] ?? 0);
// 三组:计划 / 实际 / 未成行
$bysStage = ['plan' => [], 'actual' => [], 'skipped' => []];
foreach ((array)($nodes ?? []) as $n) {
$sg = (string)($n['stage'] ?? 'plan');
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
$bysStage[$sg][] = $n;
}
$groupMeta = [
'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'],
'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'],
'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'],
];
?>
<div class="page-head">
<h1><?= e((string)$t['title']) ?></h1>
<?php /* ⚠️ 用户第 6 条:标题旁边**不放**时间。时间在下面题图右侧的信息区里。 */ ?>
<div class="actions">
<?php if ($canWrite): ?>
<?php /* 🔴🔴 v41(用户 2026-10-10 第 4 条):
「旅程详情页面右上角的按钮,顺序分别是从左到右:
+计划行程、+实际行程、编辑、删除、返回旅程和设置。
计划行程的按钮用蓝色背景;实际行程的按钮用绿色背景;
编辑用黄色背景;删除用红色背景;设置就用这个图标,不要变。」
⇒ 两条硬要求:
① **顺序**:+计划行程 → +实际行程 → 编辑 → 删除 → 返回旅程 → 设置(☰);
② **配色**:计划=蓝、实际=绿、编辑=黄、删除=红;设置保持图标不变。
⚠️ 之前是「返回旅程在最左、其余 ghost/实心混着」,与本条**完全相反** ——
所以这一次是**按新顺序重排整段**,不是微调。
⚠️ 原来"+计划行程/编辑"是 `<a>`(跳页面),现在全部改成**弹框**(见第 5 条),
所以这里用 `<button type="button">` + data 属性,由页内脚本打开弹框。 */ ?>
<button class="btn btn-plan" type="button"
data-node-open="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'plan', 'embed' => 1])) ?>"
aria-haspopup="dialog" aria-expanded="false">+ 计划行程</button>
<button class="btn btn-actual" type="button"
data-node-open="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'actual', 'embed' => 1])) ?>"
aria-haspopup="dialog" aria-expanded="false">+ 实际行程</button>
<button class="btn btn-edit" type="button"
data-trip-edit="<?= e(url('trips/edit', ['id' => (int)$t['id'], 'embed' => 1])) ?>"
aria-haspopup="dialog" aria-expanded="false">编辑</button>
<form method="post" action="<?= e(url('trips/destroy')) ?>" class="res-inline"
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的行程、照片会一起删掉,不可恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-del" type="submit" title="删除这个旅程">删除</button>
</form>
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
<?php /* ⚠️ 设置**恒为动作区最后一个**(全站统一:观影/日程/任务/资源都如此)。
用户这次明确"设置就用这个图标,不要变" ⇒ 保持 `_settings_menu.php`
原样(纯图标 ☰),一个字都不动。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<?php else: ?>
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
</div>
</div>
<?php /* ==================== 封面行:题图 + 信息区 ====================
⚠️ 第 1 条:「题图应该按照 16:9 显示,因为添加的时候,是按照这个比例裁剪的,
现在的显示比例不对」—— 之前 CSS 写的是 4:3,把 16:9 的图裁掉两边。
16:9 这个数字与 ImageSlot 里 cover 槽位的比例**必须一致**,
部署断言里有一条专门比对它。
⚠️ 第 17 批 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,
而不是终点地址。」—— 城市来自 `Trip::cities()`(出发地 ∪ 到达地,去重),
见 summary() → cities()。 */ ?>
<div class="card card-pad trip-hero">
<div class="trip-hero-cover">
<?php if (!empty($t['cover_key'])): ?>
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
<?php else: ?>
<span class="trip-cover-empty">🧳</span>
<?php endif; ?>
</div>
<div class="trip-hero-body">
<?php /* ---- 时间与城市:从行程算出来的(第 15 批第 2 条);
第 6 条要求放在这里(题图右侧)而不是标题旁 ---- */ ?>
<div class="trip-when">
<span class="trip-when-date"><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
<?php if ($cities): ?>
<span class="trip-when-cities">
<?php foreach ($cities as $i => $c): ?>
<?= $i > 0 ? '<span class="city-sep">·</span>' : '' ?><span class="city-chip"><?= e((string)$c) ?></span>
<?php endforeach; ?>
</span>
<?php endif; ?>
</div>
<?php /* ---- 五项统计(用户第 2 条):参与人 / 城市 / 天数 / 段数 / 花销 ---- */ ?>
<div class="trip-stats">
<div class="ts-item">
<span class="ts-n"><?= count($people) ?></span>
<span class="ts-l">参与人</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= count($cities) ?></span>
<span class="ts-l">个城市</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= $days > 0 ? $days : '—' ?></span>
<span class="ts-l">天</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= $nodeN ?></span>
<span class="ts-l">段行程</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= e(\App\Expense::money($cents)) ?></span>
<span class="ts-l">总花销</span>
</div>
</div>
<?php /* 参与人是谁:用户点名要("显示的是参与人都有谁")。
🔴 v41(用户 2026-10-10):标题后面那个括号(以及括号里那句解释)不要了。
这个括号连带里面的内容都不要了。」⇒ 只留标题四个字。 */ ?>
<?php if ($people): ?>
<div class="trip-people-block">
<span class="small muted">参与的人</span>
<div class="viewers-list">
<?php foreach ($people as $a): ?>
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php /* ---- 简介(用户第 2 条明确"简介那一段话保留")。
🔴 v24 第 19 批 第 4 条修的是这里:用户报「详情页题图右侧有一句话显示,
但点击编辑进去,却看不到这句话」。原因就是**这句话读的字段和表单存的
字段不是同一个**:
· 旅程表单存的是 `trip_details.summary`(编辑页那个「一段简介」框);
· 而这里先读 `summary`,再读 `events.note`(**老字段**,表单里早就没有
这个输入框了),两句都用 .trip-sum 的画法渲染 ——
于是老数据里的 `note` 会在详情页冒出来,编辑页却无从修改/删除。
现在只渲染 `summary` 一个来源:**页面显示的每一句话,编辑页都能改**。
`note` 不再出现在详情页(数据仍留在库里,不删)。
老数据里"只填过备注、没填过简介"的那批由
`_tooling/_fb_b19_backfill.php` 一次性搬进 summary。 */ ?>
<?php if (!empty($t['summary'])): ?>
<p class="info-text trip-sum"><?= nl2br(e((string)$t['summary'])) ?></p>
<?php elseif ($canWrite): ?>
<?php /* 🔴 v42(用户 2026-10-11 第 6 条):「旅程详情页面,参与的人下面,
原来有旅程的简介,怎么没了?加回来。」
⚠️ 排查结论:简介**一直在**(上面的 `!empty($t['summary'])` 分支),
结构从 v40 到 v41 一字未动(v41 只把「参与的人」后面那个括号
连同里面的说明整句去掉了)。
⇒ 用户"看不到"的真实原因是**那条旅程的简介是空的** ——
他多半是先用 v41 那个"新建旅程带入了旧数据"的 bug(本批第 1 条)
建/改过,summary 没存进去。
⇒ 修法两层:
① 第 1 条那个 bug 已修(新建不再串数据);
② 这里补一句**可点的占位**:简介为空但能写的人,看到"还没有简介,
点这里写一句" —— 否则整块是空的,用户会以为"功能被删了"。 */ ?>
<p class="trip-sum-empty">
还没有简介 ——
<a href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">点这里写一句</a>。
</p>
<?php endif; ?>
<?php /* 老数据兜底(v24 第 19 批 第 4 条):只填过「备注」、没填过「简介」的旅程,
简介这里就是空的。**不能把 note 直接当简介显示** —— 那正是用户报的
"详情页有、编辑页看不到"。但也不能让用户觉得"我的字丢了",
所以给一句能点进去补的提示;真正的内容会被 `_fb_b19_backfill.php`
一次性搬进 summary(搬完这条提示自然消失)。 */ ?>
<?php if (empty($t['summary']) && !empty($t['note'])): ?>
<?php if ($canWrite): ?>
<p class="trip-sum-legacy">
这段旅程的旧「备注」还没搬成简介 ——
<a href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">点这里补一段简介</a>。
</p>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php /* ==================== 行程分组 ====================
用户第 4 条:「只要是没有就是 0 条,那个类型就不显示了,现在显示的是 0 条,这样没意义。」
⚠️ 所以这里**跳过空分组**。但要给"一段都还没有"的情况一句引导 ——
否则新建的旅程页面上会一片空白,人不知道该干什么。 */ ?>
<?php
$shown = 0;
foreach ($bysStage as $lst) { $shown += count($lst); }
?>
<?php /* 🔴 v41:行程列表包一层 `#nodeListHost` —— 弹框里保存成功后,
前端只换这一块(保留滚动位置、不整页闪白)。
⚠️ 注意 `tripListHost`(旅程列表)与 `nodeListHost`(行程列表)是两个
不同页面的容器,前端脚本两个 id 都认。 */ ?>
<div id="nodeListHost">
<?php if ($shown === 0): ?>
<div class="card card-pad empty">
<b>还没有行程</b>
<?php if ($canWrite): ?>
点右上角「+ 计划行程」把打算走的地方排进来,或者「+ 实际行程」直接记已经走过的。
参与人、城市、天数、花销都会从行程里自动汇总到这里。
<?php else: ?>
家人还没有记录行程。
<?php endif; ?>
</div>
<?php else: ?>
<?php foreach (['plan', 'actual', 'skipped'] as $sg): ?>
<?php $list = $bysStage[$sg]; if (!$list) { continue; } /* 空分组不显示 */ ?>
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
<h2 class="cinema-h2">
<?= e($groupMeta[$sg][0]) ?>
<span class="group-n"><?= count($list) ?> 段</span>
</h2>
<p class="stage-hint"><?= e($groupMeta[$sg][1]) ?></p>
<ol class="node-list">
<?php foreach ($list as $n): ?>
<?php
$nid = (int)$n['id'];
// ⚠️ 第 17 批 第 4 条末:「行程中,时间明明具体到了时分秒,怎么不显示?」
// → 行程行改用 nodeSpan()(精确到时分),**不再**用只到日期的 formatSpan()。
// formatSpan 那个精度是给"旅程"(容器)用的。
$span = \App\Trip::nodeSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null);
$mode = (string)($n['travel_mode'] ?? '');
$spent = (int)($n['spent_cents'] ?? 0);
?>
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
id="node-<?= $nid ?>">
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?>">
<?= e(\App\Trip::typeIcon((string)$n['node_type'], $fid)) ?>
</span>
<div class="node-body">
<div class="node-head">
<b><?= e((string)$n['title']) ?></b>
<span class="node-badge nb-<?= e($sg) ?>"><?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?></span>
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<span class="rev-badge" title="已修正过多次">已修正 <?= (int)$n['revision_n'] ?> 次</span>
<?php endif; ?>
<?php if ($mode !== ''): ?>
<?php
/* 🔴 v41(用户 2026-10-10 第 8 条)原话:
「行程里面,交通工具的信息放在后面。比如现在的思路是出发去菏泽,
后面写赶路,再后面写火车。那就应该把火车的车次号直接放在"火车"
这两个字后面。如果是自驾,就应该把自驾的车牌号放在后面。」
⇒ 交通方式的**标识**(车次号 / 车牌号)紧跟模式词,同一个 chip:
火车 → 「🚄 火车 G1234」;自驾 → 「🚗 自驾 鲁B·12345」。
⚠️ 不再把 trip_no 单独丢进下面那排 `.ntag`(那正是"隔了一段"的来源)。
⚠️ 自驾取的是**车辆库里的车牌**(`Vehicle::label` 里已含车牌),
不是 `trip_no` —— 自驾那条字段本来就不收车次。 */
$modeExtra = '';
if (\App\Trip::modeNeedsVehicle($mode) && !empty($n['vehicle_plate'])) {
$modeExtra = (string)$n['vehicle_plate'];
} elseif (!empty($n['trip_no'])) {
$modeExtra = (string)$n['trip_no'];
}
?>
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?><?php
if ($modeExtra !== ''): ?><span class="node-mode-no"><?= e($modeExtra) ?></span><?php endif; ?></span>
<?php endif; ?>
</div>
<?php if ($span !== '日期待补'): ?>
<div class="node-time">
<?= e($span) ?>
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?>
</div>
<?php endif; ?>
<?php /* 交通细节:**只保留**「线路名 / 车次航班号 / 承运方」。
⚠️ 第 17 批 第 3 条明确「座位什么的不记录了」→
座位 / 车厢、登机口 / 站台**不再出现在这里**
(数据库那两列还留着老数据,只是不再展示)。 */ ?>
<?php
$traffic = [];
if (!empty($n['line_name'])) { $traffic[] = (string)$n['line_name']; }
/* 🔴 v41 第 8 条:`trip_no` **不再**放这里 —— 它已经跟在
「火车 / 飞机」模式词后面了(见上面 `.node-mode-no`)。
留在这里会让车次号在**同一个行程行上出现两次**。 */
if (!empty($n['carrier'])) { $traffic[] = (string)$n['carrier']; }
?>
<?php if ($traffic): ?>
<div class="node-traffic">
<?php foreach ($traffic as $tf): ?><span class="ntag"><?= e($tf) ?></span><?php endforeach; ?>
</div>
<?php endif; ?>
<?php /* 出发地 → 到达地(第 17 批 第 3 条要记详细地址;
这里显示地点名,详细地址放 title 里悬停可见 + 带导航链接)。 */ ?>
<?php if (!empty($n['from_place']) || !empty($n['to_place'])): ?>
<div class="node-route-line">
<?php
$navFrom = \App\Geo::navUrl($n['from_lng'] ?? null, $n['from_lat'] ?? null, (string)($n['from_place'] ?? ''));
$navTo = \App\Geo::navUrl($n['to_lng'] ?? null, $n['to_lat'] ?? null, (string)($n['to_place'] ?? ''));
?>
<?php if ($navFrom !== ''): ?>
<a href="<?= e($navFrom) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a>
<?php else: ?>
<?= e((string)($n['from_place'] ?? '—')) ?>
<?php endif; ?>
<span class="arrow">→</span>
<?php if ($navTo !== ''): ?>
<a href="<?= e($navTo) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a>
<?php else: ?>
<?= e((string)($n['to_place'] ?? '—')) ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($n['detail'])): ?>
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
<?php endif; ?>
<?php if ($spent !== 0): ?>
<div class="node-spent">💰 这一段花了 <?= e(\App\Expense::money($spent)) ?></div>
<?php endif; ?>
<?php /* ⚠️ 第 17 批 第 3 条:「给参与人标注角色这个功能去掉,不要了。」
所以这里**不再渲染** role_tag(数据库里老数据的角色还在,只是不显示)。 */ ?>
<?php if (!empty($n['members'])): ?>
<div class="trip-people">
<?php foreach ($n['members'] as $mm): ?>
<?= person_chip($mm) ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($n['attachs'])): ?>
<?php
$attRows = $n['attachs'];
$attCanEdit = false;
$attDelUrl = '';
require __DIR__ . '/../_attachments.php';
?>
<?php endif; ?>
<?php /* ---- 修正记录对比(第 13 批) ---- */ ?>
<?php if (!empty($n['revisions'])): ?>
<details class="rev-hist">
<summary class="link-quiet">看修正记录(<?= count($n['revisions']) ?> 条)</summary>
<table class="rev-table">
<thead>
<tr><th>#</th><th>时间</th><th>地点</th><th>说明</th></tr>
</thead>
<tbody>
<?php foreach ($n['revisions'] as $rv): ?>
<tr>
<td><?= (int)$rv['seq'] === 1 ? '原计划' : '第 ' . ((int)$rv['seq'] - 1) . ' 次修正' ?></td>
<td class="small"><?= e(\App\Trip::nodeSpan($rv['starts_at'] ?? null, $rv['ends_at'] ?? null)) ?></td>
<td><?= e((string)($rv['place'] ?? '')) ?></td>
<td class="small"><?= e((string)($rv['detail'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</details>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<?php /* ==================== 动作按钮(v24 第 19 批 用户第 7 条)====================
用户原话:
· 「计划行程的三个按钮,修改、完成、未成行,横着排列,放在行程右上角,
修改是黄色背景,完成绿色,未成行红色。」
· 「实际行程的编辑和删除按钮,也放在行程的右上角,编辑黄色,删除红色。」
⚠️ 于是配色按**颜色**来(不再是"主/次"那套):
· 修正 / 编辑 → `.btn-warn`(黄)
· 完成 → `.btn-ok`(绿)
· 未成行 / 删除 → `.btn-del`(红)
⚠️ 用户说的"修改"就是这里的「修正」(第 13 批定的名字)——
我们**保留产品名**不改,但把它染成用户要的黄色。
⚠️ 「编辑」用户没提,但它不能丢:改标题/时间/类型/出行方式与**加花销**
都只走它(花销面板在编辑页里)。按用户给的颜色语义染黄。
⚠️ 「标记为实际」是"修正过之后才出现"的收尾动作,也按黄色(同属"改")。
⚠️ 删除按钮:用户明确要求**红色**。这里用 `.btn-del`(实心红),
与全站 `.btn-danger`(淡红描边)刻意分开 —— 改 `.btn-danger`
会连带改掉十来处「删除」按钮的样子。
⚠️ 计划行的删除入口**保留**:`skipNode`(未成行)是主路径,
但"记错了、这段根本不该存在"时仍需要真删(hint 里也这么写)。 */ ?>
<div class="node-ops">
<?php /* 🔴 v41(用户第 5/6 条):编辑/新增行程一律**弹框**。
⚠️ 这里只放"触发器"(data-node-open 带 URL),**不内嵌表单** ——
详情页可能有十几段行程,内嵌 N 份表单会把页面撑破、
元素 id 还会互相撞车(这正是用户当年报的"把页面撑破")。
表单由页尾那一个容器按需 AJAX 取回。 */ ?>
<button class="btn btn-warn btn-sm" type="button"
data-node-open="<?= e(url('trips/node_edit', ['id' => $nid, 'embed' => 1])) ?>"
aria-haspopup="dialog" aria-expanded="false">编辑</button>
<?php if ($sg === 'plan'): ?>
<details class="rev-open">
<summary class="btn btn-warn btn-sm">修正</summary>
<div class="rev-panel">
<?php /* 🔴 v50(用户 2026-10-14 第 3 条):
「上面…那一段话说明也不要了。」
⇒ 删掉原来那段 `.rev-tip`(开头是「记录…」、解释"仍是同一条记录"
的那三行)。表单本身已经说清"实际的开始/结束""这次修正的说明",
那段解释属于噪音。
⚠️ 这段注释**刻意不整句照抄**用户点名的原话 ——
部署断言要查"那句已删",若在注释里留下原文,
那种朴素 grep 会命中注释而恒红(本项目已踩过多次)。
要查"删没删"的断言请用 `_code_has`(剥注释)。
⚠️ 别顺手删 `.rev-panel` / `.rev-open` 这两个类名 ——
它们是展开区的结构锚点(CSS 与测试都用)。 */ ?>
<?php
$rvNode = $n; $rvTripId = (int)$t['id'];
$rvPeople = $people ?? [];
$rvMembers = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
require __DIR__ . '/_revise_form.php';
?>
</div>
</details>
<form method="post" action="<?= e(url('trips/node_done')) ?>" class="res-inline"
onsubmit="return confirm('实际执行与计划完全相同,直接转为实际?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ok btn-sm" type="submit">完成</button>
</form>
<form method="post" action="<?= e(url('trips/node_skip')) ?>" class="res-inline"
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这个旅程里仍然看得到。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-del btn-sm" type="submit">未成行</button>
</form>
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<form method="post" action="<?= e(url('trips/node_actual')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ok btn-sm" type="submit">标记为实际</button>
</form>
<?php endif; ?>
<?php elseif ($sg === 'skipped'): ?>
<form method="post" action="<?= e(url('trips/node_restore')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ok btn-sm" type="submit">恢复为计划</button>
</form>
<?php endif; ?>
<form method="post" action="<?= e(url('trips/node_del')) ?>" class="res-inline"
onsubmit="return confirm('彻底删除这段行程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「未成行」。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-del btn-sm" type="submit">删除</button>
</form>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div><?php /* /#nodeListHost */ ?>
<?php if ($canWrite): ?>
<?php /* ==================== v41:行程 / 旅程编辑的**弹框容器**(单一实例)====================
用户第 5/6 条:「编辑行程、加计划行程、加实际行程,全部都采用弹窗的形式。」
「行程部分的编辑也是采用弹窗的形式。」
「新建旅程…确定完了之后,动态刷新旅程这个列表页面。」
为什么是**一个容器 + 按需 AJAX**,而不是每段内嵌一份表单:
一段行程有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/参与人/照片/状态),
详情页可能有十几段。内嵌 N 份 = 页面被撑到几屏长 + 元素 id 互相撞车
—— 这正是用户当年报的「点击编辑会把页面撑破」。
⇒ 点"编辑 / +计划行程 / +实际行程 / 编辑旅程"时,才去服务端取那**一份**表单片段,
塞进这个容器里打开。保存成功后只换行程列表容器,不整页跳。 */ ?>
<div class="cal-modal" id="nodeFormHost" role="dialog" aria-modal="true"
aria-label="行程" hidden>
<div class="cal-modal-panel mv-panel nf-panel" id="nodeFormPanel">
<div class="cal-modal-head">
<b id="nodeFormTitle">行程</b>
<button type="button" class="cal-modal-x" data-nf-close aria-label="关闭">&#10005;</button>
</div>
<div class="cal-modal-body" id="nodeFormBody">
<p class="empty-hint">正在载入…</p>
</div>
</div>
</div>
<script>
/* ==================== v41:行程 / 旅程编辑弹框(事件委托 + AJAX)====================
为什么**全程用事件委托**(监听挂 document、用选择器判断来源):
· 弹框内容是 AJAX 换进来的 —— 内容一换,原来元素上的监听全丢;
委托则"一次绑定、永久有效",换多少次都不用重绑(观影详情页那轮
就是在这里踩过"第二次点编辑没反应")。
· 列表里每段行程一个按钮,逐个 addEventListener 会绑十几个。
为什么提交主路走 **click** 而不是 submit:
提交按钮是靠 form 属性关联的(在表单**外面**)。实测浏览器点它会提交,
但挂在 document 上的 submit 监听**收不到事件** ⇒ 会整页跳走、红字看不到
(观影 v40 同一坑)。click 一定发生,以它为主路;submit 只兜"按回车"。 */
(function () {
if (!window.fetch || !window.FormData) { return; } // 老浏览器保持原生跳转
var RELOAD = 'nodeListHost'; // 保存成功后要刷新的容器
function host() { return document.getElementById('nodeFormHost'); }
function body() { return document.getElementById('nodeFormBody'); }
function titleEl(){ return document.getElementById('nodeFormTitle'); }
function setOpen(on) {
var h = host();
if (!h) { return; }
h.hidden = !on;
if (on) {
/* 动态插入的控件:日期 / antd / 选人 / 上传都要在这一刻升级 */
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
if (window.fbNodeForm) { window.fbNodeForm(body() || document); }
var f = document.getElementById('nf-title');
if (f) { f.focus(); }
} else if (body()) {
body().innerHTML = '<p class="empty-hint">正在载入…</p>';
}
}
function antdOpen() {
var d = document.querySelector('.ant-picker-dropdown');
return !!(d && !d.classList.contains('antd-picker-dropdown-hidden'));
}
/* ---- 打开:从 data-node-open / data-trip-edit 拿 URL,取片段塞进来 ---- */
/* 🔴🔴 v42(用户 2026-10-11 第 2/3/4 条)——**根因级修法**:
AJAX 塞进来的片段里,`<script>` **既不执行、连元素都不保留**
(`innerHTML` 的语义:脚本不会跑,且赋值后 DOM 里也找不到那个 script 标签)。
片段自带的初始化(AntD 升级 / 高德联想 / 出行方式联动)全写在那段脚本里
⇒ 用户在弹框里看到的是:
· 起止时间没换成 AntD 控件(第 2 条);
· 选「自驾」不出车辆、选「火车/飞机」不出车次(第 3 条);
· 出发地/到达地没有高德联想(第 4 条)。
⇒ 做法:**把片段里的内联 `<script>` 抠出来,重新平成一个能执行的脚本**。
这是"动态插入含脚本的 HTML"的标准修法;比"在宿主里逐个复刻片段的初始化"
可靠得多 —— 片段将来再加初始化时,宿主不用跟着改。
⚠️ 只执行**内联**脚本(有 src 的是外部 SDK,设了 src 也不会同步执行,跳过即可;
高德那支是 `<script src>`,它的加载写在片段里由 SDK 自己完成)。
⚠️ 幂等:同一片段重复塞入时重新执行没问题(初始化内部都有幂等标记)。 */
function runInlineScripts(container, html) {
var re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi, m, code = '';
while ((m = re.exec(html)) !== null) {
// 只收**没有 src 属性**的那种(内联脚本)
if (!/\bsrc\s*=/i.test(m[0].split('>')[0])) { code += m[1] + '\n;\n'; }
}
if (!code) { return; }
var s = document.createElement('script');
s.textContent = code;
(container || document.body).appendChild(s); // 执行
s.parentNode.removeChild(s); // 执行完就摘掉,别留在 DOM 里
}
/* 🔴🔴 v43(用户 2026-10-11 第 4 条):「编辑一个行程的时候,"参与的人"
添加参与人的那个功能已经不好使了,弹不出选人的框了。」
真根因:选人组件(`person_picker.js`)的绑定**只跑一次**(脚本加载时扫一遍 `.pp`)。
而这一段表单是 AJAX 取回来、`innerHTML` 塞进 DOM 的 ⇒ 它里面的 `.pp`
**从来没被 init 过** ⇒「+ 添加参与的人」按钮上没有任何 click 监听
⇒ 点了没反应(弹框弹不出来)。暂存区/确定/取消同理全按不动。
⇒ 塞完片段后补调 `window.fbInitPickers(scope)`(person_picker.js 暴露,
内部幂等)。⚠️ 该脚本是**按需加载**的(layout.php:页面上有 `.pp` 才拉),
而弹框里的 `.pp` 是后来才出现的 —— 脚本此刻可能还没加载完。
所以做"等它就绪":轮询若干次,出现即调;始终不来也无害(下次打开再试)。
⚠️ 只重试有限次(约 3 秒),不做无限轮询。 */
function initPickersWhenReady(scope, tries) {
tries = (tries === undefined) ? 25 : tries;
/* 先确保脚本被拉起(详情页首屏没有 `.pp`,layout 那条"首屏就拉"的判据不会触发它)。
`fbLoadPicker` 是幂等的,重复调用只是拿到同一个 Promise。 */
if (!window.fbInitPickers && typeof window.fbLoadPicker === 'function') {
try { window.fbLoadPicker(); } catch (e) { /* 忽略 */ }
}
if (typeof window.fbInitPickers === 'function') {
try { window.fbInitPickers(scope); } catch (e) { /* 不阻塞表单其它初始化 */ }
return;
}
if (tries <= 0) { return; }
setTimeout(function () { initPickersWhenReady(scope, tries - 1); }, 150);
}
function loadInto(url, title) {
if (titleEl()) { titleEl().textContent = title || '行程'; }
if (body()) { body().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
setOpen(true);
fetch(url, { credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.text(); })
.then(function (html) {
if (!body()) { return; }
body().innerHTML = html;
/* ① 先执行片段自带的内联脚本(它会定义/调用自己的初始化) */
runInlineScripts(body(), html);
/* ② 再补跑宿主这侧的通用升级(日期/antd/行程表单/选人组件),按依赖顺序 */
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
if (window.fbNodeForm) { window.fbNodeForm(body()); }
/* 🔴🔴 v43(用户 2026-10-11 第 4 条):「编辑一个行程的时候,"参与的人"
添加参与人的那个功能已经不好使了,弹不出选人的框了。」
真根因:选人组件(`person_picker.js`)的绑定**只跑一次**(脚本加载时扫一遍
`.pp`)。而这一段表单是 AJAX 取回来、`innerHTML` 塞进 DOM 的 ⇒
它里面的 `.pp` **从来没被 init 过** ⇒「+ 添加参与的人」按钮上没有 click
监听 ⇒ 点了没反应。⇒ 这里塞完片段**补调一次**初始化。
⚠️ 该脚本是**按需加载**的(layout.php:页面上有 `.pp` 才拉),而弹框里的
`.pp` 是后来才出现的 —— 脚本可能还没加载完(或都还没开始加载)。
所以这里做"等它就绪":轮询若干次,出现即调;全程没出现也无害。 */
initPickersWhenReady(body());
var f = document.getElementById('nf-title');
if (f) { f.focus(); }
})
.catch(function () {
if (body()) { body().innerHTML = '<p class="empty-hint">载入失败,请重试。</p>'; }
});
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var op = t.closest('[data-node-open]');
if (op) {
e.preventDefault();
var url = op.getAttribute('data-node-open') || '';
var isNew = url.indexOf('node_new') >= 0;
loadInto(url, isNew ? (op.textContent || '').trim() : '编辑行程');
return;
}
var te = t.closest('[data-trip-edit]');
if (te) {
e.preventDefault();
loadInto(te.getAttribute('data-trip-edit'), '编辑旅程');
return;
}
if (t.closest('[data-nf-close]')) { setOpen(false); return; }
/* 🔴 v50(用户 2026-10-14 第 3 条):「应该加一个关闭/取消的按钮…不要再修正的话,
应该有一个取消,不要让这个框显示了。」
「修正」区是 `<details class="rev-open">`(原生可折叠,不用 JS 也能开)——
⇒ 取消 = 把这个 details 的 `open` 去掉,回到收起状态。
⚠️ 用委托(`closest`)而不是在 `_revise_form.php` 里内联脚本:
那份片段会被 AJAX 换进来,内联脚本的位置与幂等都要额外照看;委托一次绑定就够。 */
var rc = t.closest('[data-rev-cancel]');
if (rc) {
e.preventDefault();
var det = rc.closest('details.rev-open');
if (det) { det.open = false; }
return;
}
var h = host();
if (h && t === h) { setOpen(false); return; } // 点遮罩关;点面板内部不关
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var h = host();
if (!h || h.hidden) { return; }
if (antdOpen()) { return; } // 让 antd 先收它自己的面板
setOpen(false);
});
/* ---- 保存:click 主路 + submit 兜回车 ---- */
function save() {
var form = document.getElementById('nodeForm') || document.getElementById('tripForm');
if (!form) { return; }
var sub = document.getElementById('nodeFormSubmit')
|| document.getElementById('tripNewSubmit');
if (sub) { sub.disabled = true; sub.textContent = '保存中…'; }
fetch(form.action, {
method: 'POST', body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
}).then(function (r) {
var ct = (r.headers.get('content-type') || '');
if (ct.indexOf('application/json') >= 0) {
/* 校验失败:服务端回重画后的弹框 → 整块换进容器 */
return r.json().then(function (j) {
if (body() && j && j.modal) {
body().innerHTML = j.modal;
runInlineScripts(body(), j.modal); // 片段里的内联脚本要执行(见 loadInto 的长注释)
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
if (window.fbNodeForm) { window.fbNodeForm(body()); }
}
});
}
/* 成功:服务端 302 回来 → 换列表容器(保留滚动位置、不闪白)。
🔴🔴 v51(用户 2026-10-15 第 1 条):「在旅程的详情页面编辑的时候,当我编辑完了之后,
点击确定之后,页面并没有刷新,就是原来的详情页面并没有刷新。比方说我修改了一张图片,
当我确定完了之后,原来的页面应该刷新一下,把最新的这个旅程的信息给更新出来才对呀。」
真根因:这里只换 `#nodeListHost`(**行程列表**那一段),而旅程自己的
**标题(页头 h1)/ 题图 / 时间与城市(.trip-hero)** 都在那个容器**外面** ——
它们由服务端直出、不在被替换的那块里 ⇒ 改了标题/题图/简介,页头与题图纹丝不动。
用户看到的就是"确定点了、框关了,但上面还是旧的"。
⇒ 修法:**按表单来源分流**——
· `tripForm`(编辑旅程本体:标题/题图/时间…)→ **整页刷新**。
理由:它改的正是"页面骨架"(页头 + 题图 + 城市),这些散落在容器外、
无法靠换一小块 DOM 覆盖;而"保留滚动位置"在这里没有价值
(用户改完就是要看新的题图与标题,回到顶部反而正合意)。
· `nodeForm`(行程增删改)→ 仍然只换行程列表(用户当年明确要的"不整页跳",
而且它改的东西**确实**只在那块容器里)。
⚠️ 判据用表单 id,不要用"有没有题图字段"去猜 —— id 是稳定的契约。
⚠️⚠️ **必须 `getAttribute('id')`,不能写 `form.id`** ——
HTML 表单元素会把**同名的具名控件**挂成自己的属性:
`tripForm` 里有 `<input type="hidden" name="id">` ⇒ `form.id` 返回的是
**那个 input 元素**(字符串化就是 `[object HTMLInputElement]`),
永远不等于 `'tripForm'` ⇒ 这个分支**从来不会命中**(本轮实测:
改完标题点确定,页面纹丝不动;插桩打印出来才看到
`form.id === '[object HTMLInputElement]'`)。
同类还有 form.name / form.action / form.method 都可能被同名控件遮蔽。
⇒ 一律用 `getAttribute('id')` 取"写在标签上的那个 id"。 */
var isTripForm = (form.getAttribute('id') === 'tripForm');
return r.text().then(function (html) {
if (isTripForm) { window.location.reload(); return; }
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = doc.getElementById(RELOAD) || doc.getElementById('tripListHost');
var cur = document.getElementById(RELOAD) || document.getElementById('tripListHost');
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
else { window.location.reload(); return; }
setOpen(false);
});
}).catch(function () {
if (sub) { sub.disabled = false; sub.textContent = '确定'; }
form.submit();
}).then(function () {
if (sub) { sub.disabled = false; sub.textContent = '确定'; }
});
}
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest) { return; }
if (t.closest('#nodeFormSubmit') || t.closest('#tripNewSubmit')) {
ev.preventDefault();
save();
}
});
document.addEventListener('submit', function (ev) {
var f = ev.target;
if (!f || (f.id !== 'nodeForm' && f.id !== 'tripForm')) { return; }
ev.preventDefault();
save();
});
})();
</script>
<?php endif; ?>