v24 第 16 批:旅程 / 行程两级(术语固定 · 题图16:9 · 五项统计 · 独立行程页 · 设置三页)

用户十条,逐条落地:

1. 「题图应该按照 16:9 显示,因为添加的时候是按这个比例裁剪的,现在的显示比例不对。」
   → 详情页题图容器从 4:3 改成 16:9,与 ImageSlot 的 cover 槽位同一个数字
     (部署断言比对这两处,避免哪天改一处漏一处)。

2. 「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、一共几段行程,
     一共花了多少钱,这些信息,都从该活动下的所有行程统计总结,简介那一段话保留。」
   → 新增 Trip::summary():参与人(各段并集)/ 城市(去重)/ 天数 / 段数 / 总花销,
     全部读时算(与"时间城市派生"同一条原则,不存冗余)。
     天数按**日历天**含首尾(8/1→8/3 = 3 天,不是 24 小时整除的 2 天)。
     城市与时间从标题旁挪进信息区,简介保留。

3. 「活动页面,不需要上传图片,图片视频,都是针对一次具体的行程的。」
   → 删掉旅程页的照片上传板块与照片墙;照片一律挂在**行程**上
     (行程表单里有上传,附件 target_type='trip_node')。

4. 「计划行程、未成行的行程,都不要显示……只要是没有就是 0 条,那个类型就不显示了,
     现在显示的是 0 条,这样没意义。」
   → 空分组直接跳过不渲染;全空时给一句引导(否则新建旅程页面一片空白)。
     有内容的分组照常显示(计划 / 实际 / 未成行三种)。

5. 「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
   → 行程的新增与编辑改成**独立页面**(trips/node_new / trips/node_edit)。
     原来内联在详情页:N 份表单互相撞元素 id、密码管理器逐个扫描变卡,
     正是"把页面撑破"的成因。

6. 「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
   → 详情页右上角两个按钮(+ 计划行程 / + 实际行程),指向独立新增页。
     顺带:标题旁边不再放时间(时间移到题图右侧信息区)。

7. 「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
   → 字段全量照搬 TripLog 的 nodes/form.php:
     类型 / 标题 / 起止时间 / 出行方式 + 车辆 / 线路名 / 承运方 / 车次航班号 /
     座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(含角色)/ 照片 / 花销。
     交通字段不做条件列(先填高铁车次、后来改成自驾,填过的东西不该丢)。

8. 「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
     注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加那里面的车辆,
     直接读取就可以,但是可以添加其他的车辆,比如租的车,借别人的车。」
   → 新增旅程设置菜单(三页):车辆管理 / 出行方式 / 节点类型。
     车辆**两个来源刻意不合并**:财务里 kind='car' 的账目(那是资产,没有车牌车型)
     由 Vehicle::fromLedger() 只读列出,选车下拉里标「来自财务」;
     自家 / 租的 / 借的车录进 vehicles 表。不复制财务的车(避免同一台车两处维护)。
     节点类型做成**可自定义**的表(TripLog 同款),内置 5 种不可删;
     行程里存类型**名字**,所以改类型名会级联更新已用它的行程。

9. 「活动都改名吧,改成旅程,新建旅程,旅程包含行程,这个名字就固定下来。」
   → 术语固定:**旅程**(容器)包含**行程**(单元)。
     顶层按钮「新建旅程」、详情页三组叫「计划的行程 / 实际行程 / 未成行」、
     列表「N 段行程」、设置菜单「旅程设置」。上一批的"活动"叫法全部换掉。

10. 「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,这些都是从行程里面
      总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
   → 旅程表单只剩:标题 → 题图 → 简介 + 公开开关。
     备注与同行的人全部移除(参与人 = 各段行程参与人的合集)。
     ⚠️ 但**老数据里活动级那份参与人名单不能被清空**(那时还没有行程级参与人),
     所以路由"没传这个键就不动",并加了断言钉住这一点。

数据结构(Schema v21,老库自动升级):
· 新表 vehicles(自家/租的/借的)、node_types(可自定义)、trip_expenses(行程级花销)
· trip_nodes 补 9 列:出行方式 / 车辆 / 线路名 / 承运方 / 车次 / 座位 / 登机口 / 起终点
· trip_node_members 补 role_tag(司机 / 摄影担当这类角色)
· 花销金额存**分**,解析走字符串拆小数点(128.55*100 在浮点下会差一分)

验证:e2e 891/0(+46 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 交互 92/0 ·
窄屏溢出 30/0(扩到行程表单 + 设置三页 + 详情页)· 题图点击 4/0 · 静态体检全绿。
**反向验证 6/6**:题图退回 4:3、标题旁加回时间、空分组照旧渲染、编辑退回非独立页、
车辆不读财务、总花销不汇总 —— 六种坏法全部被断言抓住。
部署静态断言新增第 16 批一组(含"空分组不许渲染""行程页不许有照片上传"
"旅程表单不许有备注与同行的人""题图必须 16:9"等反退化防线)。
部署后服务器自检 19/19,六个关键文件 md5 与线上逐一核对一致。
This commit is contained in:
2026-10-05 13:26:49 +08:00
parent 7138314dc7
commit 2e51d3f5e4
16 changed files with 2265 additions and 456 deletions
+48 -105
View File
@@ -1,48 +1,31 @@
<?php
/**
* 记一次活动 / 编辑活动(v24 第 15 批重排)。
* 新建 / 编辑旅程(v24 第 16 批精简)。
*
* ⚠️ 用户原话(2026-10-05 第 15 批):
* 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
* 「活动不需要开始和结束时间,是根据活动中的旅程…计算出来的。目的地也不需要…
* 去的城市,也是根据旅程,得到的。」
* 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
* ⚠️ 术语(用户第 9 条):这一层是**旅程**(容器),里面一段一段的是**行程**。
*
* 所以表单**主区就三样**(顺序就是用户说的顺序):
* ① 标题 ② 题图(上传 + 16:9 裁剪)
* ③ 简介
* 时间 / 城市**不在表单里** —— 它们是"旅程算出来的",不是"这里填进去的"。
* 页面上只做一句说明,让人知道这两个东西从哪来(不然会去找"怎么没地方填")。
* 用户第 10 条原话:「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,
* 这些都是从行程里面总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
* 上一批(第 4 条)已经定过主区就三项:标题 → 题图 → 简介。
* 所以现在这张表单**只剩这三项 + 公开开关**:
* · 同行的人 —— 删掉(参与人 = 各段行程参与人的合集,在详情页算出来)
* · 备注 —— 删掉(用户明确"都不要了")
* · 时间 / 城市 —— 本来就不是输入项(从行程派生)
* 「公开」保留:它是"这个旅程要不要出现在列表与日历上"的**隐私开关**,
* 不是从行程里能总结出来的信息。
*
* 「备注 / 公开 / 同行的人」功能没丢,收进「更多设置」折叠区:
* 用户说的是"就可以了"(主流程够短),不是"把这些删掉"。
* ⚠️ 但它们在同一个 <form> 里、只是视觉上折叠 —— 少了哪个字段都会静默丢数据。
*
* ⚠️ 同行的人:活动上登记的这份名单**仍然有用**(参与人 = 活动登记 ∪ 各段旅程成员)。
* 但既然活动现在"没有时间也没有地点",多数人会直接在每段旅程里填成员;
* 所以它退到折叠区,默认不占地方。
* @param array|null $t 编辑时的旅程;null = 新建
* @param array $errors 校验错误
*/
$isEdit = !empty($t);
$canWrite = \App\Auth::canWrite();
$selIds = [];
if ($isEdit) {
foreach ((array)($t['attendees'] ?? []) as $a) {
$selIds[] = (int)$a['id'];
}
} else {
$selIds = array_map('intval', (array)($presetAttendees ?? []));
}
$cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900,
'label' => '题图', 'hint' => ''];
$hasCover = $isEdit && !empty($t['cover_key']);
// 折叠区里"有没有填过东西" —— 有就默认展开,别让改过的人还要再点一次
$moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
|| (string)($t['is_public'] ?? '1') === '0'
|| $selIds);
?>
<div class="page-head">
<h1><?= $isEdit ? '编辑活动' : '记一次活动' ?></h1>
<span class="sub">活动是容器;里面一段一段的旅程才是日程</span>
<h1><?= $isEdit ? '编辑旅程' : '新建旅程' ?></h1>
<span class="sub">旅程里装行程;参与人、城市、天数、花销都从行程里汇总</span>
<div class="actions">
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回</a>
@@ -59,16 +42,15 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
<?php /* ==================== ① 标题 ==================== */ ?>
<div class="field full">
<label for="t-title">这次活动叫什么 *</label>
<label for="t-title">这个旅程叫什么 *</label>
<input class="input" id="t-title" name="title" required autofocus
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛之行 · 2026 暑假">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* ==================== ② 题图(上传 + 裁剪)====================
⚠️ 用声明式绑定:`data-pick-input="#t-cover"` 让 app.js 的通用逻辑接管
"点图 = 打开文件选择框"。上一批这里**没有**这段绑定(JS 只写在
people/form.php 里),于是点图毫无反应 —— 用户当轮就报了这个问题。 */ ?>
<?php /* ==================== ② 题图(上传 + 16:9 裁剪)====================
⚠️ data-pick-input 让 app.js 的通用逻辑接管"点图 = 开文件框" ——
少了它会"看着能点、点了没反应"(第 15 批用户报过的那个 bug)。 */ ?>
<div class="field full">
<label>题图</label>
<div class="cover-picker">
@@ -88,8 +70,7 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
<input type="hidden" name="cover_crop" value="">
<span class="avatar-hint small muted">
点左边那块选题图,选完弹裁剪框。<br>
题图是 16:9 的宽幅横图,多大都能传,保存时自动缩成
<?= (int)$cvSlot['w'] ?>×<?= (int)$cvSlot['h'] ?>。
题图是 16:9 的宽幅横图,详情页也按 16:9 显示。
<?php if ($hasCover): ?>不换图就不用管这里。<?php endif; ?>
</span>
</div>
@@ -102,79 +83,41 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
placeholder="这趟玩得怎么样、为什么想记下来"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
</div>
<?php if (!$isEdit): ?>
<?php /* 时间与城市**不是输入项** —— 说清楚它们从哪来,
否则用户会满页找"怎么没地方填时间"。 */ ?>
<p class="form-note">
时间与去过的城市不用填:加进一段段旅程之后,会自动取<b>最早的一段</b>、
<b>最晚的一段</b>和<b>各段去的地方</b>算出来。
</p>
<?php else: ?>
<?php /* ---- 公开开关(隐私开关,不是"从行程总结"出来的信息)----
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",而不是"这个字段没出现"
(后者会被当成"没传"而默认公开,于是"取消勾选"看起来毫无作用)。 */ ?>
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
<div class="field full">
<label>公开</label>
<label class="chk-inline">
<input type="hidden" name="is_public" value="0">
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
<span>在旅程列表与日历里显示这个旅程</span>
</label>
<span class="tip">不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。</span>
</div>
<?php if ($isEdit): ?>
<?php
/* 编辑时把"已经算出来什么"直接摆出来:
不显示的话,改完标题保存会以为"我把时间弄丢了"。 */
$tSpan = \App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null);
$tCity = (string)($t['city'] ?? '');
/* 编辑时把"已经算出来什么"摆出来:不显示的话,改完标题保存会以为
"我把参与人/时间弄丢了"。 */
$tSpan = \App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null);
$tCity = (string)($t['city'] ?? '');
$tN = count((array)($t['attendees'] ?? []));
?>
<p class="form-note">
当前时间:<b><?= e($tSpan) ?></b>
<?php if ($tCity !== ''): ?>· 去过:<b><?= e($tCity) ?></b><?php endif; ?>
<br>这两样都是从下面各段旅程算出来的,改这里没用;要改就去改对应的那段旅程。
当前:<b><?= e($tSpan) ?></b><?php if ($tCity !== ''): ?> · 去过 <b><?= e($tCity) ?></b><?php endif; ?>
<?php if ($tN > 0): ?> · <b><?= $tN ?></b> 人参与<?php endif; ?>
<br>这些都是从下面各段行程算出来的,这里改不了 —— 要改就去改对应的那段行程。
</p>
<?php else: ?>
<p class="form-note">
参与人、城市、天数、花销都不用填:加进一段段<b>行程</b>之后会自动汇总。
</p>
<?php endif; ?>
<?php /* ==================== 更多设置(折叠)====================
功能一个都没删,只是主流程只留用户点名的那三项。 */ ?>
<details class="more-set"<?= $moreOpen ? ' open' : '' ?>>
<summary>更多设置(备注 / 公开 / 同行的人)</summary>
<div class="form-grid">
<div class="field full">
<label for="t-note">备注</label>
<textarea class="input" id="t-note" name="note" rows="3"
placeholder="选填。不显示在列表里,只在这趟活动的页面上"><?= e(old('note', $t['note'] ?? '')) ?></textarea>
</div>
<?php /* 用户 2026-10-03 第 4 条:不公开的活动只留着自己看。
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",
而不是"这个字段没出现"(后者会被当成"没传"而默认公开)。 */ ?>
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
<div class="field full">
<label>公开</label>
<label class="chk-inline">
<input type="hidden" name="is_public" value="0">
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
<span>在活动列表与日历里显示这次活动</span>
</label>
<span class="tip">不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。</span>
</div>
<div class="field full">
<label>同行的人</label>
<?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<span class="tip">
这里登记的是"整趟活动都在的人"。只在某一段旅程里的人,
到那段旅程里勾就行 —— 活动页上的名单是两边合起来算的。
</span>
<?php
$ppName = 'attendees[]';
$ppPeople = $people;
$ppSelected = $selIds;
$ppMode = 'multi';
$ppOpenBtn = '+ 添加同行人';
$ppTitle = '选择同行的人';
$ppTip = '';
require __DIR__ . '/../_person_picker.php';
?>
<?php endif; ?>
</div>
</div>
</details>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '记下来' ?></button>
<button class="btn" type="submit"><?= $isEdit ? '保存' : '建好,去加行程' ?></button>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">取消</a>
</div>