Files
FamilyBook/templates/trips/show.php
T
laoyang 252812ed93 v50:旅程题图裁剪修好(AJAX 片段漏绑)/ 修正表单换最新控件+取消 / 打标签误报修复 / 名册按钮编辑蓝删除红
用户 2026-10-14 四条。

① 旅程详情点点「编辑」,里面的图片改不了、拖入/选中都不弹裁剪框
   用户原话:「应该弹出来一个让我裁剪的那个框。但是现在却没有那个裁剪的框。」
   **真根因**(assets/js/app.js):裁剪那段 IIFE 是**首屏一次性**的 ——
     `var inputs = querySelectorAll('[data-crop-ratio]'); if (!inputs.length) return;`
     在脚本加载那一刻扫一遍,扫不到就整段放弃。而「编辑旅程」是 **AJAX 取片段、
     innerHTML 塞进弹框**的(templates/trips/show.php 的 loadInto)⇒ 详情页首屏
     **没有**题图框 ⇒ 当场 return ⇒ 片段里的框**从来没有 change 监听**
     ⇒ 拖入/选中什么都不发生。
   修法:拆成 `bindOne(input)`(幂等,打 `data-crop-bound` 标记)+ `scan(scope)`;
     首屏调一次 + **MutationObserver 补挂**;库的按需加载改成"首次真要用时才拉"。
   ⚠️ 与「拖动上传」那段(bindInput/scan/MutationObserver)**同一手法** ——
      它早就解决了"动态插进来的文件框也要接得住",裁剪这段当年漏了这层。

② 旅程「修正」表单(templates/trips/_revise_form.php)
   用户三条:「开始和结束时间…应该改成我们最新的控件样式」「上面记录实际执行情况
   那一段话说明也不要了」「记录这次修正的按钮应该放在右边,另外应该加一个取消」。
   · 起止换 `$dtPlugin = 'antd'`(与 node_form.php 同款;当年 v41 刻意没换,现按用户要求统一)
   · 删 show.php 里 `.rev-tip` 整段
   · 按钮行 `rev-foot` 靠右 + 新增 `[data-rev-cancel]`;取消由 show.php 的**事件委托**
     处理(`details.rev-open` 收起)—— 不用内联脚本(片段会被 AJAX 换进来)

③ 名册打标签误报「这个人不在你的名册里」
   用户原话:「我搜索出来一个人…给他打标签,它会显示说这个人不在我的名册里,
   其实他是在我的名册里的。」
   **真根因**(index.php 的 people/tags):`isset(PersonRoster::visibleIds(...)[$pid])`,
   而 `visibleIds()` 返回的是 **id 值列表** `[2,3,4,5]`(键是 0,1,2,3)——
   把值列表当 id→值 映射用。只有 `$pid` 恰好等于数组下标时才为真:名册里人少时
   (下标<pid)**谁都判"不在名册"**;一般情况只有"第 pid+1 个"碰巧过。
   修法:改用 `in_array(..., true)`(与同文件 `inMyRoster` 写法一致)。
   ⚠️ **没改 `visibleIds()` 的返回形状** —— 另一处调用已是正确的 in_array,改形状会
      静默弄坏它。陷阱:`PersonGroup::visibleIds()` 是集合、`PersonRoster::visibleIds()`
      是值列表,**同名不同形状**,已在两处注释里写明。
   (附带确认:不在名册的人本来就不出现在列表里,用户"不在名册就该搜不出来"本就成立。)

④ 名册列表按钮:编辑蓝底、删除红底
   用**作用域类** `.person-edit`(var(--blue)) / `.person-del`(var(--red)) 三处形态都写,
   不动全局 .btn-edit/.btn-danger 的语义(v41 踩过"裸类改色波及影院管理页")。
   hover 在这里补 `filter: brightness(.92)`(文末"全站悬停统一"段是白名单写的)。

验证(新增 2 套件 + 更新 1 处旧断言):
 · _fb_v50_ui.py(22 条真机断言:题图绑定/选中与拖入都弹裁剪框 16:9、修正 antd+无说明+
   靠右+取消能收起、名册内 3 人打标签均成功且不误报、编辑蓝删除红)
 · _b50_falsify.py(**10 注入 / 21 断言全抓**)
 · _fb_e2e.py 的 ★第28-6条断言随 v50-4 升级(原查 var(--brand-soft),现查蓝+红作用域类)
 · familybook_deploy.sh 加 v50 静态断言段(含两条"旧写法必须已不在"的反向断言)
 · e2e 1511/0、几何、各批反向验证全绿;**v50 四条 22/0**、**v50 反向验证 21/0**;
   全流水线 **PUSH_RC=0**,部署脚本 0 FAIL(含"第50批形态");线上 6 文件 md5 逐字节一致;
   线上门禁诊断 16 项全 YES,临时页已删并复验 404

⚠️ 收尾时发现并修掉一个**自设陷阱**:我在 show.php 的删除说明里**整句引用了**用户点名要删的
   那句原话 ⇒ 朴素 strpos 判"已删"时会命中我自己的注释(线上诊断 C8=NO)。
   部署断言用 `_code_has`(剥注释)所以是对的,但这类字串留在注释里迟早坑到别的检查
   ⇒ 已改成不整句照抄。**改注释也改了文件哈希 ⇒ 又重跑一次全量部署**。
⚠️ 流水线末段唯一"失败"同 v49:沙箱安全删除门禁(按轮次累计删满 50 就终止进程);
   单独重跑 _fb_v46_carddav.py = 29/0 ⇒ 环境护栏,与本次改动无关。
2026-10-15 02:57:19 +08:00

743 lines
42 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 回来 → 只换列表容器(保留滚动位置、不闪白) */
return r.text().then(function (html) {
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; ?>