Files
FamilyBook/templates/trips/show.php
T
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00

634 lines
34 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 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">
<p class="rev-tip">
记录实际执行情况。这仍与上面那条计划是<b>同一条记录</b>,
原来的计划数据会保留在「修正记录」里,方便日后对比。
点完这里<b>这段行程仍是计划</b>;确认无误后再点「完成」。
</p>
<?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(); }
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,取片段塞进来 ---- */
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;
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
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; }
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;
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
}
});
}
/* 成功:服务端 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; ?>