Files
laoyang d6af211a8e 图片走 OSS 实时压缩:取图带 x-oss-process,本地存储不受影响 (#56)
用户 2026-11-05:「如果图片是放在本地服务器的…就不管它。
如果是传到 OSS 里面的…就用 OSS 的压缩就行了。」

查证:OSS 图片处理确实存在,取图 URL 加 x-oss-process 即可实时压缩,
原图不动、不占额外存储。

三处改动(缺一个都会出错):

1. OssDriver
   - get() 新增 $process 参数
   - authorization() 新增 $subresource:x-oss-process 属于子资源,
     必须计入 CanonicalizedResource,否则 403 SignatureDoesNotMatch
   - URL 拼接带上 ?x-oss-process=...

2. Storage::read()
   - 新增 $process,仅当驱动是 oss 时透传
   - 本地驱动忽略(用户明确要求「本地不管它」)
   - 带 process 时跳过"本地优先"缓存(那个缓存里是原图,
     拿原图当压缩版返回等于压缩没生效)

3. media.php
   - 新增 ?w= 参数,白名单三档 400/800/1600
   - 按尺寸分档缓存到 _proc/<w>/<key>,避免互相覆盖
   - 无 w 时行为完全不变(300+ 处既有 img 不受影响)

白名单而非透传的理由:OSS 图片处理按次计费,
放任 ?w= 任意值会让 ?w=1、?w=9999 这类请求把处理次数打上去。

实测(一张 6602 KB 的图):
  原图  6602 KB
  w=400    26 KB   省 99.6%
  w=800   108 KB   省 98%
  w=1600  468 KB   省 93%

应用位置(保守,先动最确定的):
  _attachments.php 照片墙缩略图 → w=400
  trips/show.php   行程题图     → w=1600
  下载链接一律不加 w(用户要原文件,不能偷偷降质)
2026-11-05 13:06:29 +08:00

844 lines
49 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
/**
* 旅程详情(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;
}
/* 🔴 #48(2026-11-05):**提示语删掉**(用户:「已走过 —— 也出现在日历与首页
『往年今日』,这句话删除」)。不只删这一句 —— 三个分组的提示语一律去掉:
组头只留名称和条数,界面更干净。
⚠️ 保留数组结构(只用 [0] 名称),改动面最小。 */
$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'])): ?>
<?php /* #56:题图是大图位置,给 1600 档(实测 6.6MB → 468KB) */ ?>
<img src="<?= e(media_url((string)$t['cover_key'], ['w' => 1600])) ?>" 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 /* 🔴 #48:**实际行程放最前** —— 用户:「到最后整个旅程结束之后,
我们最终看到的是实际行程。」⇒ actual → plan → skipped(未成行沉底)。 */ ?>
<?php foreach (['actual', 'plan', '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>
<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 ?>">
<?php /* 🔴 2026-11-05(用户:「明明选的是坐火车,网页版的图标显示的还是一辆汽车」):
左侧图标改成**出行方式优先** —— 坐火车就显 🚄,
不再一律显节点类型的 🚗。与端上 `fbNodeLeadIcon()` 同一逻辑。 */ ?>
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)
. ($mode !== '' ? ' · ' . \App\Trip::modeWord($mode) : '')) ?>">
<?= e(\App\Trip::leadIcon((string)$n['node_type'], $mode, $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">
<?php /* 🔴 #48:**计划时间与实际时间同时显示**。
用户:「原来计划的时间和现在真正开始的时间都要记录。」
⇒ 计划用常规色,实际用主题色并标注「实际」。 */ ?>
<?php if ($sg === 'actual'): ?>
<span class="t-plan">计划 <?= e($span) ?></span>
<?php
$aStart = (string)($n['actual_started_at'] ?? '');
$aEnd = (string)($n['actual_ended_at'] ?? '');
if ($aStart !== ''):
$aSpan = \App\Trip::nodeSpan($aStart, $aEnd !== '' ? $aEnd : null);
?>
<span class="t-actual">实际 <?= e($aSpan) ?></span>
<?php endif; ?>
<?php else: ?>
<?= e($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 /* 🔴 2026-11-05(用户:「直接用个标签,然后右边一个箭头,
到北京南站就得了,不用写从哪到哪」):
两个地名都做成**胶囊标签**,箭头只作分隔。
有坐标的套 <a>(可点开导航),没坐标的套 span ——
**两种情况都用同一个类**,样式才一致(原来无链接时
是裸文本,和另一侧的胶囊对不齐,这正是"位都没对齐"的观感来源)。 */ ?>
<?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: ?>
<span class="route-stop"><?= e((string)($n['from_place'] ?? '—')) ?></span>
<?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: ?>
<span class="route-stop"><?= e((string)($n['to_place'] ?? '—')) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- 🔴🔴 #48(2026-11-05)行程动作按钮组。
用户原话:
「计划行程右上角就不是两个按钮了,而是至少四个按钮:
一个是"开始",一个是"完成",一个是"编辑",一个是"删除"。
在删除前面还应该有一个按钮,这个按钮是取消,
指的就是不去了,但这个事情我并不把它删掉。」
⇒ 计划行程:开始 / 完成 / 编辑 / 取消 / 删除
⇒ 实际行程:编辑 / 删除(无状态、无开始无完成)
⇒ 未成行: 编辑 / 删除(已取消,不再转)
⚠️ 当前状态用实心 chip 表示,与端上 pillBtn(selected) 同一视觉语言。
⚠️ 按钮规则来自 Trip::nodeAvailableActions() —— 前后端共用一份。
⚠️ 这里用 HTML 注释而不是 PHP 注释:PHP 的 /* */ 注释里遇到 `?>`
会提前结束 PHP 段(词法层面 `?>` 优先于注释),导致解析错误。 -->
<?php
$stageNow = (string)($n['stage'] ?? 'plan');
$statusNow = (string)($n['status'] ?? 'planned');
$acts = \App\Trip::nodeAvailableActions($stageNow, $statusNow);
$isActual = $stageNow === 'actual';
?>
<div class="node-actions">
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
<span class="node-act is-on"><?= e(\App\Trip::NODE_STATUS[$statusNow] ?? '计划中') ?></span>
<?php endif; ?>
<?php foreach ($acts as $a): ?>
<?php
$label = $a === 'start' ? '开始' : '完成';
$cls = $a === 'start' ? 'go' : 'ok';
?>
<button type="button" class="node-act node-act-<?= e($cls) ?>"
data-node-act="<?= e($a) ?>"
data-node="<?= e($nid) ?>"><?= e($label) ?></button>
<?php endforeach; ?>
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
<button type="button" class="node-act node-act-warn"
data-node-act="cancel"
data-node="<?= e($nid) ?>"
title="不去了,但数据保留">取消</button>
<?php endif; ?>
</div>
<?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; ?>