Files
FamilyBook/templates/trips/show.php
T
laoyang c30ca99d22 v24 第 17 批:城市聚合 / 高德地址 / 表单精简(用户 10 条)
用户 10 条原话要点与落地:
- ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程,
  都有青岛作为出发地或者目的地,那么就只记一次」
  → trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成
    「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。
    详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市,
    而不是终点地址」)。
- ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」
  → NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。
- ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车,
  只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive'];
  Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。
- ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
  → seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢;
    saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。
- ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」
  +「给参与人标注角色这个功能去掉」
  → 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 +
    导航链接,没配 key 也能手输);参与人改用全局弹框选人组件;
    角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。
- ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」
  → 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、
    车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。
- ④「行程中,时间明明具体到了时分秒,怎么不显示?」
  → Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、
    两头都没时分就不写 00:00)。

其他:
- App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。
  中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、
  离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。
- config.example.php 加 amap 段(key / security_js / nav_src)。

验证:
- e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)·
  城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)·
  桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。
- 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 /
  打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。
- 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。
2026-10-05 16:45:10 +08:00

373 lines
19 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 /* 用户第 6 条:添加计划行程 / 实际行程的按钮都放**右上角**,
点击打开**新页面**(第 5 条:不再内联,内联会把页面撑破)。 */ ?>
<a class="btn btn-ghost" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'plan'])) ?>">
+ 计划行程
</a>
<a class="btn" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'actual'])) ?>">
+ 实际行程
</a>
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
<?php /* 用户第 8 条:右上角增加一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。
按全站规矩:设置按钮只有图标(三条横线)、无文字、贴最右。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<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-ghost btn-icon" type="submit" title="删除这个旅程" aria-label="删除">🗑</button>
</form>
<?php else: ?>
<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 /* 参与人是谁:用户点名要("显示的是参与人都有谁") */ ?>
<?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 条明确"简介那一段话保留" ---- */ ?>
<?php if (!empty($t['summary'])): ?>
<p class="info-text trip-sum"><?= nl2br(e((string)$t['summary'])) ?></p>
<?php endif; ?>
<?php if (!empty($t['note'])): ?>
<p class="remark-text"><?= nl2br(e((string)$t['note'])) ?></p>
<?php endif; ?>
</div>
</div>
<?php /* ==================== 行程分组 ====================
用户第 4 条:「只要是没有就是 0 条,那个类型就不显示了,现在显示的是 0 条,这样没意义。」
⚠️ 所以这里**跳过空分组**。但要给"一段都还没有"的情况一句引导 ——
否则新建的旅程页面上会一片空白,人不知道该干什么。 */ ?>
<?php
$shown = 0;
foreach ($bysStage as $lst) { $shown += count($lst); }
?>
<?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 !== ''): ?>
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?></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']; }
if (!empty($n['trip_no'])) { $traffic[] = (string)$n['trip_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): ?>
<div class="node-ops">
<?php /* 用户第 5 条:**编辑行程打开新页面**(内联的编辑面板会把页面撑破) */ ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('trips/node_edit', ['id' => $nid])) ?>">编辑</a>
<?php if ($sg === 'plan'): ?>
<details class="rev-open">
<summary class="btn btn-ghost 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-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-ghost 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-sm btn-primary" 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-ghost 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-ghost btn-sm" type="submit">删除</button>
</form>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</div>
<?php endforeach; ?>
<?php endif; ?>