Files
FamilyBook/templates/trips/node_form.php
T
laoyang 2e51d3f5e4 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 与线上逐一核对一致。
2026-10-05 13:26:49 +08:00

364 lines
17 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 批 用户第 5 / 6 / 7 条)。
*
* 用户原话:
* · 第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
* · 第 6 条:「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
* · 第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
*
* ⚠️ 为什么必须独立成页(而不是继续用 `<details>` 内联):
* TripLog 的节点表单有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/
* 参与人/照片/状态),内联在主页面里会把整个详情页撑得极长;
* 更要命的是**编辑**时每个行程内联一份表单 → 页面上 N 份表单互相干扰
* (元素 id 撞车、密码管理器逐个扫描变卡)。这正是用户说的"把页面撑破"。
*
* 字段顺序照 TripLog 的 nodes/form.php:
* 类型 → 起止时间 → 标题 → 出行方式(+车辆+交通细节)→ 起点 → 终点
* → 内容 → 参与人 → 照片 →(编辑时)状态与花销。
*
* 变量(require 前设好):
* $ev 所属旅程(events 行,带 title)
* $n 被编辑的行程;null = 新增
* $stage 'plan'|'actual'(新增时由按钮决定;编辑时取行程自己的)
* $people 名册
* $types 节点类型(NodeType::all)
* $vehicles 可挑的车(Vehicle::pickable,含"来自财务"的只读候选)
* $expenses 已有花销(编辑时)
* $errors 校验错误
*/
$isEdit = !empty($n);
$canWrite = \App\Auth::canWrite();
$fid = (int)\App\Auth::familyId();
$people = $people ?? [];
$types = $types ?? [];
$vehicles = $vehicles ?? [];
$expenses = $expenses ?? [];
$errors = $errors ?? [];
$stage = (string)($stage ?? ($n['stage'] ?? 'plan'));
if (!isset(\App\Trip::NODE_STAGES[$stage])) { $stage = 'plan'; }
$curType = (string)($n['node_type'] ?? '');
$curMode = (string)($n['travel_mode'] ?? '');
$picked = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
// 参与人角色:person_id => role
$roles = [];
foreach ((array)($n['members'] ?? []) as $m) {
if (!empty($m['role_tag'])) {
$roles[(int)$m['id']] = (string)$m['role_tag'];
}
}
$back = url('trips/show', ['id' => (int)$ev['id']]);
?>
<div class="page-head">
<h1>
<?= $isEdit ? '编辑行程' : ('添加' . ($stage === 'actual' ? '实际' : '计划') . '行程') ?>
</h1>
<span class="sub">
<?= e((string)$ev['title']) ?>
<?php if ($isEdit): ?> · <?= e(\App\Trip::stageWord((string)($n['stage'] ?? $stage))) ?><?php endif; ?>
</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($back) ?>">返回旅程</a>
</div>
</div>
<div class="card card-pad">
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEdit ? 'trips/node_update' : 'trips/node_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="trip_id" value="<?= (int)$ev['id'] ?>">
<?php /* 新增时阶段由按钮决定;编辑时是"当前状态"(只读展示,改状态走详情页那几个动作) */ ?>
<input type="hidden" name="stage" value="<?= e($stage) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<?php /* ---- 类型(TripLog 的第一个字段)---- */ ?>
<div class="field">
<label for="nf-type">类型 *</label>
<select class="select" id="nf-type" name="node_type">
<?php foreach ($types as $t): ?>
<?php $tn = (string)$t['name']; ?>
<option value="<?= e($tn) ?>"<?= $curType === $tn ? ' selected' : '' ?>>
<?= e(($t['icon'] ?? '') . ' ' . $tn) ?>
</option>
<?php endforeach; ?>
<?php /* 老数据的英文 key 不在类型表里 → 补一个当前值,否则一保存就被改成别的 */ ?>
<?php if ($curType !== '' && !in_array($curType, array_column($types, 'name'), true)): ?>
<option value="<?= e($curType) ?>" selected>
<?= e(\App\Trip::typeIcon($curType, $fid) . ' ' . \App\Trip::typeWord($curType, $fid)) ?>
</option>
<?php endif; ?>
</select>
<span class="tip">在右上角「设置 → 节点类型」里可以增删自定义。</span>
</div>
<div class="field">
<label>当前状态</label>
<div class="node-stage-ro"><?= e(\App\Trip::stageWord($stage)) ?></div>
</div>
<?php /* ---- 标题 ---- */ ?>
<div class="field full">
<label for="nf-title">标题 *</label>
<input class="input" id="nf-title" name="title" required
value="<?= e(old('title', $n['title'] ?? '')) ?>" placeholder="例如:出发去栈桥">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* ---- 起止时间:一个范围控件 ---- */ ?>
<div class="field dt-range full">
<label>开始 / 结束</label>
<?php $dtName = 'starts_at'; $dtValue = $n['starts_at'] ?? '';
$dtId = 'nf-s'; $dtSeconds = true; $dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
$dtId = 'nf-e'; $dtSeconds = true; $dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填。</span>
</div>
<?php /* ---- 出行方式 + 车辆(TripLog 的 travel_modes)----
用户第 8 条:车辆从财务里**读**,也可以加自己的(租的 / 借的)。 */ ?>
<div class="field">
<label for="nf-mode">出行方式</label>
<select class="select" id="nf-mode" name="travel_mode" data-mode-select>
<option value="">— 不填 —</option>
<?php foreach (\App\Trip::TRAVEL_MODES as $mk => $mv): ?>
<option value="<?= e($mk) ?>"<?= $curMode === $mk ? ' selected' : '' ?>>
<?= e($mv[1] . ' ' . $mv[0]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field" data-mode-vehicle hidden>
<label for="nf-veh">车辆</label>
<select class="select" id="nf-veh" name="vehicle_id">
<option value="">— 不指定 —</option>
<?php
$curVeh = (int)($n['vehicle_id'] ?? 0);
$ledgerShown = false;
foreach ($vehicles as $v):
$isLedger = (($v['from'] ?? '') === 'ledger');
if ($isLedger && !$ledgerShown) {
echo '<optgroup label="财务里已登记的车">';
$ledgerShown = true;
}
?>
<option value="<?= $isLedger ? (int)$v['id'] : (int)$v['id'] ?>"
<?= $curVeh === (int)$v['id'] ? ' selected' : '' ?>>
<?= e(\App\Vehicle::label($v) . ($isLedger ? '(来自财务)' : '')) ?>
</option>
<?php endforeach; ?>
<?php if ($ledgerShown): ?></optgroup><?php endif; ?>
</select>
<span class="tip">
财务里登记过的车子会直接列在这里;租的车、借别人的车到
「设置 → 车辆管理」加一台。
</span>
</div>
<?php /* ---- 交通细节(照 TripLog:线路名 / 车次 / 承运方 / 座位 / 登机口)---- */ ?>
<div class="field">
<label for="nf-line">线路名</label>
<input class="input" id="nf-line" name="line_name"
value="<?= e(old('line_name', $n['line_name'] ?? '')) ?>" placeholder="如:京沪高铁、地铁 3 号线">
</div>
<div class="field">
<label for="nf-tripno">车次 / 航班号</label>
<input class="input" id="nf-tripno" name="trip_no"
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
</div>
<div class="field">
<label for="nf-carrier">承运方</label>
<input class="input" id="nf-carrier" name="carrier"
value="<?= e(old('carrier', $n['carrier'] ?? '')) ?>" placeholder="如:中国铁路、滴滴">
</div>
<div class="field">
<label for="nf-seat">座位 / 车厢</label>
<input class="input" id="nf-seat" name="seat_info"
value="<?= e(old('seat_info', $n['seat_info'] ?? '')) ?>" placeholder="如:7 车 12F">
</div>
<div class="field">
<label for="nf-gate">登机口 / 站台</label>
<input class="input" id="nf-gate" name="gate"
value="<?= e(old('gate', $n['gate'] ?? '')) ?>" placeholder="如:T3 登机口 B12">
</div>
<?php /* ---- 起点 / 终点(TripLog 的 start/end_location)---- */ ?>
<div class="field">
<label for="nf-from">出发地</label>
<input class="input" id="nf-from" name="from_place"
value="<?= e(old('from_place', $n['from_place'] ?? '')) ?>" placeholder="如:家里">
</div>
<div class="field">
<label for="nf-to">到达地</label>
<input class="input" id="nf-to" name="to_place"
value="<?= e(old('to_place', $n['to_place'] ?? '')) ?>" placeholder="如:青岛北站">
</div>
<div class="field">
<label for="nf-place">地点</label>
<input class="input" id="nf-place" name="place"
value="<?= e(old('place', $n['place'] ?? '')) ?>" placeholder="这一段主要在哪(用于统计城市)">
<span class="tip">旅程页上的"去了几个城市"就是按这里统计的。</span>
</div>
<div class="field">
<label for="nf-status">执行状态</label>
<select class="select" id="nf-status" name="status">
<?php foreach (\App\Trip::NODE_STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>"<?= ($n['status'] ?? 'planned') === $sk ? ' selected' : '' ?>>
<?= e($sv) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field full">
<label for="nf-detail">记录</label>
<textarea class="input" id="nf-detail" name="detail" rows="4"
placeholder="这一段发生了什么"><?= e(old('detail', $n['detail'] ?? '')) ?></textarea>
</div>
<?php /* ---- 参与人(+角色,TripLog 的 role_tag)---- */ ?>
<div class="field full">
<label>参与的人</label>
<?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<div class="nf-members">
<?php foreach ($people as $p): ?>
<?php $pid = (int)$p['id']; ?>
<label class="check-item">
<input type="checkbox" name="members[]" value="<?= $pid ?>"
<?= in_array($pid, $picked, true) ? 'checked' : '' ?>>
<span><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
<details class="role-set">
<summary class="link-quiet">给参与的人标个角色(可选)</summary>
<div class="role-grid">
<?php foreach ($people as $p): ?>
<?php $pid = (int)$p['id']; ?>
<div class="role-row">
<span class="role-who"><?= e(person_name($p)) ?></span>
<input class="input" name="roles[<?= $pid ?>]" maxlength="30"
value="<?= e($roles[$pid] ?? '') ?>" placeholder="如:司机 / 摄影">
</div>
<?php endforeach; ?>
</div>
<span class="tip">不填也没关系;填了会显示在那个人名字旁边。</span>
</details>
<?php endif; ?>
</div>
<?php /* ---- 照片(用户第 3 条:图片视频都针对一次具体行程)---- */ ?>
<div class="field full">
<label for="nf-photo">这一段行程的照片</label>
<?php if ($isEdit && !empty($n['attachs'])): ?>
<div class="photo-grid">
<?php foreach ($n['attachs'] as $ph): ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$ph['key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$ph['key'])) ?>"
data-dl="<?= e(media_url((string)$ph['key'], ['dl' => 1])) ?>"
data-name="<?= e((string)($ph['name'] ?? '照片')) ?>">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="">
</a>
<form method="post" action="<?= e(url('trips/photo_del')) ?>" class="photo-del">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这张照片?')">删除</button>
</form>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<input class="input" type="file" id="nf-photo" name="photo[]" accept="image/*" multiple>
<span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加这段行程' ?></button>
<a class="btn btn-ghost" href="<?= e($back) ?>">取消</a>
</div>
</form>
</div>
<?php /* ==================== 花销(只在编辑时出现)====================
用户第 2 条要"一共花了多少钱"、本次确认「行程级花销记录」。
⚠️ 新增时不给花销面板:那时行程 id 还不存在,花销挂不上(先存行程再加花销,
少一种"提交了但没存上"的状态)。 */ ?>
<?php if ($isEdit): ?>
<div class="card card-pad">
<h2 class="cinema-h2">
花销(<?= count($expenses) ?> 笔)
<span class="hint">这一段花了多少钱;旅程页的"总花销"是各段之和</span>
</h2>
<?php if (!$expenses): ?>
<p class="empty-hint">还没记花销。</p>
<?php else: ?>
<ul class="exp-list">
<?php foreach ($expenses as $ex): ?>
<li class="exp-row">
<span class="exp-cat"><?= e(\App\Expense::CATS[(string)$ex['category']] ?? '其他') ?></span>
<span class="exp-title"><?= e((string)($ex['title'] ?? '')) ?></span>
<?php if (!empty($ex['person_name'])): ?>
<span class="exp-who small muted"><?= e((string)$ex['person_name']) ?> 付</span>
<?php endif; ?>
<span class="exp-amt"><?= e(\App\Expense::money((int)$ex['amount_cents'])) ?></span>
<form method="post" action="<?= e(url('trips/expense_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="node_id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="expense_id" value="<?= (int)$ex['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这笔花销?')">删</button>
</form>
</li>
<?php endforeach; ?>
</ul>
<p class="exp-total">合计 <b><?= e(\App\Expense::money(\App\Expense::sumOfNode((int)$n['id']))) ?></b></p>
<?php endif; ?>
<form method="post" action="<?= e(url('trips/expense_add')) ?>" class="exp-form">
<?= csrf_field() ?>
<input type="hidden" name="node_id" value="<?= (int)$n['id'] ?>">
<div class="exp-form-row">
<input class="input" name="amount" required placeholder="金额,如 128.50" inputmode="decimal">
<select class="select" name="category" aria-label="类别">
<?php foreach (\App\Expense::CATS as $ck => $cv): ?>
<option value="<?= e($ck) ?>"><?= e($cv) ?></option>
<?php endforeach; ?>
</select>
<?php /* ⚠️ 「谁付的」也是选人 —— 必须走**全局选人组件**(v24 第 8 条:
全站的选人一律弹窗 + 头像 + 拼音联想,不许再写裸 <select>)。
裸下拉的判据在部署断言里,写完空跑会直接红。 */ ?>
<div class="exp-who">
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '谁付的(可不选)';
$ppTitle = '这笔是谁付的';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<input class="input" name="title" placeholder="花在哪(可空)">
<button class="btn btn-sm" type="submit">加一笔</button>
</div>
</form>
</div>
<?php endif; ?>