Files
FamilyBook/templates/trips/node_form.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

594 lines
32 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 条;
* 第 17 批按用户第 3 条再改一轮)。
*
* 用户原话:
* · 第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
* · 第 6 条:「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
* · 第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
* · 第 17 批 第 3 条(本次):
* ① 「出行方式,**只有选择自驾**,才让选车辆」;
* ② 「飞机、铁路,只记录**车次、航班号**就可以…**座位什么的不记录了**」;
* ③ 「出发地和到达地,都用**高德地图的接口,记录详细地址**」;
* ④ 「参与人那里,和记录电影人一样,**弹框选择**,不要全列出来,太多了」;
* ⑤ 「给参与人标注角色这个功能**去掉**,不要了」。
*
* ⚠️ 为什么必须独立成页(而不是继续用 `<details>` 内联):
* TripLog 的节点表单有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/
* 参与人/照片/状态),内联在主页面里会把整个详情页撑得极长;
* 更要命的是**编辑**时每个行程内联一份表单 → 页面上 N 份表单互相干扰
* (元素 id 撞车、密码管理器逐个扫描变卡)。这正是用户说的"把页面撑破"。
*
* 字段顺序照 TripLog 的 nodes/form.php(第 17 批又砍掉了两处冗余):
* 类型 → 标题 → 起止时间 → 出行方式(自驾才有车辆)→ 线路/车次/承运方
* → 出发地 → 到达地 → 内容 → 参与人 → 照片 →(编辑时)状态与花销。
*
* 变量(require 前设好):
* $ev 所属旅程(events 行,带 title)
* $n 被编辑的行程;null = 新增
* $stage 'plan'|'actual'(新增时由按钮决定;编辑时取行程自己的)
* $people 名册(**只用于弹框组件**,不再平铺成一长串勾选框)
* $types 节点类型(NodeType::all)
* $vehicles 可挑的车(Vehicle::pickable —— 第 17 批起**只有自家车辆库**)
* $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'));
/* 🔴 v41(用户 2026-10-10 第 7 条):「参与的人那里默认带上自己。」
⚠️ 只对**新建**生效:编辑时必须如实反映已存的那批人
(硬塞自己进去会让"我把自己取消了"这个操作永远无效)。
与观影"同行的人默认带上自己"(v38)同一口径。 */
if (!$isEdit && !$picked) {
$mine = (int)\App\Auth::personId();
if ($mine > 0) { $picked = [$mine]; }
}
$back = url('trips/show', ['id' => (int)$ev['id']]);
// 车辆下拉:只有**自驾**才显示(用户第 3 条)。服务端先按已存值决定初始显隐,
// 之后由 app.js 里的 data-mode-select 跟着下拉变。
$needsVehicle = \App\Trip::modeNeedsVehicle($curMode);
// 车次/航班号:只有火车与飞机才显示(用户第 3 条)
$needsTicket = in_array($curMode, \App\Trip::TICKET_MODES, true);
// 起终点(编辑时从库里回填;老数据列是 varchar,坐标是 decimal)
$fromRow = [
'name' => (string)($n['from_place'] ?? ''),
'address' => (string)($n['from_addr'] ?? ''),
'lng' => (string)($n['from_lng'] ?? ''),
'lat' => (string)($n['from_lat'] ?? ''),
'city' => (string)($n['from_city'] ?? ''),
];
$toRow = [
'name' => (string)($n['to_place'] ?? ''),
'address' => (string)($n['to_addr'] ?? ''),
'lng' => (string)($n['to_lng'] ?? ''),
'lat' => (string)($n['to_lat'] ?? ''),
'city' => (string)($n['to_city'] ?? ''),
];
$amap = \App\Geo::jsConfig();
/* 🔴🔴 v41(用户 2026-10-10 第 5/6 条):
「编辑行程、加计划行程、加实际行程,全部都采用弹窗的形式。其排版的方式和添加电影
是一样的,前面是标题冒号,后面是输入框,和观影模块一样,注意排版。」
「行程部分的编辑也是采用弹窗的形式。」
⇒ 这份表单现在**既能当整页、也能当弹框**(`$nfModal` 控制),三种入口共用它。
⚠️🔴 关键设计:**详情页里只放一个弹框容器**,表单内容按需(点击时)由服务端
AJAX 取回。**不要**为每段行程内嵌一份表单 —— 一段行程十几组字段、
详情页可能有十几段,内嵌 N 份会把页面撑到几屏长,而且元素 id 会互相撞车。
**那正是用户当年说"编辑行程把页面撑破"的成因**(见本文件头部注释)。
做法与观影详情页的编辑弹框一致:容器 + fetch 片段 + 事件委托。 */
$nfModal = !isset($nfModal) ? false : (bool)$nfModal;
$nfModalOnly = !empty($nfModalOnly);
$nfOpen = !empty($nfOpen);
$nfReload = (string)($nfReload ?? ''); // 成功后要刷新的容器 id(弹框场景用)
/* 🔴🔴 `$nfShell`(v41 关键):
· 整页 `trips/node_new` 直达、且要显示成弹框 → **要壳**(`.cal-modal` 包住自己);
· 详情页弹框里 **fetch 片段**(`?embed=1`)→ **不要壳**!
因为详情页已经有一个 `#nodeFormHost` 弹框容器(含遮罩 + 标题栏),
片段只需吐"表单本身",再由容器把它塞进 `#nodeFormBody`。
片段再自带一层 `.cal-modal` 会变成"弹框里再套一个弹框"
(遮罩叠遮罩、标题栏双份、高度算错)—— 这是最容易犯的错。
⇒ 由路由传 `$nfShell` 明确区分,**不靠猜**。 */
$nfShell = !isset($nfShell) ? $nfModal : (bool)$nfShell;
/* `$nfEmbed`:是否"只吐表单片段"(详情页弹框 fetch 用)。
⇒ 片段形态既不输出页头、也不输出弹框壳(壳由详情页容器提供)。 */
$nfEmbed = !empty($nfEmbed);
?>
<?php if (!$nfEmbed): ?>
<?php /* 整页形态:给一个页头(标题 + 返回),弹框本体随后以**打开**状态渲染。
⚠️ `?embed=1`(详情页弹框 fetch 片段)时**整段跳过** —— 那一路只要表单本身。 */ ?>
<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>
<?php endif; ?>
<?php if ($nfShell): ?>
<div class="cal-modal" id="nodeFormCard" role="dialog" aria-modal="true"
aria-label="<?= $isEdit ? '编辑行程' : '添加行程' ?>"<?= $nfOpen ? '' : ' hidden' ?>>
<div class="cal-modal-panel mv-panel nf-panel">
<div class="cal-modal-head">
<b><?= $isEdit ? '编辑行程' : ('添加' . ($stage === 'actual' ? '实际' : '计划') . '行程') ?></b>
<button type="button" class="cal-modal-x" data-nf-close aria-label="关闭">&#10005;</button>
</div>
<?php else: ?>
<div class="nf-wrap<?= $isEdit ? ' is-edit' : '' ?>">
<?php endif; ?>
<form method="post" enctype="multipart/form-data" id="nodeForm"
class="nf-form"
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; ?>
<?php if ($nfModal && $nfReload !== ''): ?>
<?php /* 弹框里保存成功 → 服务端回列表容器,由前端换上(见详情页脚本) */ ?>
<input type="hidden" name="nf_reload" value="<?= e($nfReload) ?>">
<?php endif; ?>
<div class="mv-form-inner">
<div class="form-grid">
<?php /* ---- 类型(TripLog 的第一个字段;第 17 批 第 2 条已补齐到 8 种)---- */ ?>
<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 /* ---- 起止时间:一个范围控件(v41 用户第 7 条起改用 **AntD 最新控件**)----
用户原话:「添加计划行程和实际行程,里面的时间都按照我们最新的控件,
精确到时和分就可以。」
⇒ `$dtPlugin='antd'`(与观影同一套):面板里有可点的时/分列 + 中文「确定」;
`$dtSeconds` 保持默认 false = **精确到分**(正是用户要的粒度)。
⚠️ 隐藏字段仍是 `*_date/_hour/_minute`,提交契约一字未改;
antd 拉不动时自动退回自建下拉(渐进增强)。 */ ?>
<div class="field dt-range full">
<label>开始 / 结束</label>
<?php $dtName = 'starts_at'; $dtValue = $n['starts_at'] ?? '';
$dtId = 'nf-s'; $dtRange = 'start'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
</div>
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
<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>
<?php /* ---- 车辆:**只有自驾**才出现(用户第 17 批 第 3 条)----
用户原话:「出行方式,只有选择自驾,才让选车辆,车辆下拉框,
不需要出现什么财务里登记的车,只把车辆列出来就行了。」 */ ?>
<div class="field" data-mode-vehicle<?= $needsVehicle ? '' : ' 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); ?>
<?php foreach ($vehicles as $v): ?>
<option value="<?= (int)$v['id'] ?>"<?= $curVeh === (int)$v['id'] ? ' selected' : '' ?>>
<?= e(\App\Vehicle::label($v)) ?>
</option>
<?php endforeach; ?>
</select>
<span class="tip">
<?php if (!$vehicles): ?>
车辆库还是空的 —— 到「设置 → 车辆管理」加一台(自家 / 租的 / 借的都能加)。
<?php else: ?>
租的车、借别人的车也到「设置 → 车辆管理」里加一台。
<?php endif; ?>
</span>
</div>
<?php /* ---- 交通细节:**只有火车/飞机**才需要车次航班号(用户第 17 批 第 3 条)----
用户原话:「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
因为可能是多个人同行,座位什么的不记录了。」
⚠️ 所以这里**没有**「座位 / 车厢」与「登机口 / 站台」两个输入框
—— 它们对"一家人出行"没有意义(五六张票座位不在一起,
登机口出发当天才知道)。数据库那两列保留(老数据不丢),界面不再出现。 */ ?>
<div class="field" data-mode-ticket<?= $needsTicket ? '' : ' hidden' ?>>
<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">
<span class="tip">座位这类同团不同座的信息就不记了。</span>
</div>
<?php /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」——
原话:「添加行程,线路名、承运方,都不要了。」
⚠️ 数据库那两列**保留**(`line_name` / `carrier`):TripLog 迁过来的
老数据还在里面(第 19 批补迁又填了一批),删列不可逆。
这里只是**界面不再收/不再显示**;saveNode 收到的空值会
保持原值不动(见 Trip::saveNode 里对这两列的说明)。 */ ?>
<?php /* ---- 出发地 / 到达地:高德联想 + 详细地址(用户第 17 批 第 3 条)----
用户原话:「出发地和到达地,都用高德地图的接口,记录详细地址」。
v24 第 20 批 第 5 条:「出发地和到达地,应该在同一行。」
⚠️ 做法:把两个 .field 包进一个 `.loc-row`(`grid-column: 1/-1` 占满整行),
行内再分两列 —— 不能只把两个 field 相邻放着"指望它们拼一行":
`.form-grid` 是两列网格,每个 .field 各占一格,
两格之间会有 gap、且窄屏折行后顺序会乱。
包一层显式的一行两列,宽窄屏行为都可预期。 */ ?>
<div class="field full">
<div class="loc-row">
<div class="field">
<?php $locPrefix = 'from'; $locLabel = '出发地'; $locIcon = '🚩';
$locPlaceholder = '输入地址,如「家里」「青岛北站」'; $locRow = $fromRow;
require __DIR__ . '/_loc_field.php'; ?>
</div>
<div class="field">
<?php $locPrefix = 'to'; $locLabel = '到达地'; $locIcon = '🏁';
$locPlaceholder = '输入地址,如「青岛北站」「栈桥」'; $locRow = $toRow;
require __DIR__ . '/_loc_field.php'; ?>
</div>
</div>
</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 /* ---- 参与人:**弹框选人**(用户第 17 批 第 3 条)----
用户原话:「参与人那里,和记录电影人一样,弹框选择,不要全列出来,太多了。
给参与人标注角色这个功能去掉,不要了。」
⚠️ 所以:① 用全站统一的选人组件(_person_picker.php,与观影同一个);
② **没有**角色输入框了(上一批那个 <details> 折叠区删掉了)。 */ ?>
<div class="field full">
<label>参与的人</label>
<?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<?php
$ppName = 'members[]';
$ppPeople = $people;
$ppSelected = $picked;
$ppMode = 'multi';
$ppOpenBtn = '+ 添加参与的人';
$ppTitle = '这一段里有谁';
$ppTip = '不勾 = 这段没单独记参与人(旅程页仍会按各段汇总)。';
require __DIR__ . '/../_person_picker.php';
?>
<?php endif; ?>
</div>
<?php /* ---- 照片(用户第 16 批 第 3 条:图片视频都针对一次具体行程)----
🔴 v24 第 20 批 第 3 条修的是这里:用户报「编辑行程,点击保存没有反应」。
根因是**这里原来每个缩略图上套了一个 `<form>`**,而整块又在外面那个
行程表单**里面** —— HTML 不允许 form 嵌套:浏览器解析到内层 `</form>` 时
会把**外层表单提前关掉**,于是紧随其后的「保存」按钮**掉出了表单**
(真机实测 `button.form === null`),点了自然毫无反应。
⚠️ 源码里完全看不出来(标签看着都对),只有真浏览器量 `btn.form` 才现形;
而且**只有这段行程已经有照片时**才触发 —— 所以"有时候好、有时候坏"。
修法:用 HTML5 的 `form="id"` 属性。删除按钮**留在原位**(外观、布局都不变),
但把它**关联到页面末尾那个独立表单**上 —— 不需要真的套一层 <form>,
也就不会破坏外层表单。(比"把 <form> 搬到外面再用绝对定位摆回去"稳得多:
那种做法一旦父容器有 transform/filter,定位元素会改锚点、按钮就飘了。)
⚠️ 每个缩略图的 `photo_id` 不同 → 每张一个独立的删除表单(id 带 $ph['id'])。*/ ?>
<div class="field full">
<label for="nf-photo">这一段行程的照片</label>
<?php $delForms = []; ?>
<?php if ($isEdit && !empty($n['attachs'])): ?>
<div class="photo-grid">
<?php foreach ($n['attachs'] as $ph): ?>
<?php $phDelId = 'nf-phdel-' . (int)$ph['id'];
$delForms[] = $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>
<button class="btn btn-ghost btn-sm" type="submit"
form="<?= e($phDelId) ?>"
onclick="return confirm('删掉这张照片?')">删除</button>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框(可多次)。 */ ?>
<?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
</div>
</div><?php /* /.form-grid */ ?>
</div><?php /* /.mv-form-inner(⚠️ 只关这一次) */ ?>
<?php if ($nfShell): ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="nodeFormTip"></span>
<button class="btn btn-sm" type="submit" form="nodeForm" id="nodeFormSubmit">确定</button>
<button class="btn btn-ghost btn-sm" type="button" data-nf-close>取消</button>
</div>
<?php else: ?>
<div class="btn-row">
<button class="btn" type="submit" id="nodeFormSubmit">确定</button>
<button class="btn btn-ghost" type="button" data-nf-close>取消</button>
</div>
<?php endif; ?>
</form>
<?php /* ⚠️🔴 两个形态**各收各的外层容器**(2026-10-10 实测:只写一支会让
另一支多/少一个 `</div>`,元素跑到容器外)。 */ ?>
<?php if (!$nfShell): ?>
</div><?php /* /.nf-wrap(整页 / 片段形态) */ ?>
<?php endif; ?>
<?php if ($nfShell): ?>
</div><?php /* /.cal-modal-panel */ ?>
</div><?php /* /.cal-modal */ ?>
<?php endif; ?>
<?php /* ---- 删除照片用的**独立表单**(在外层行程表单之外)----
见上面 photo-grid 的说明:用 `form="id"` 把按钮关联到这里,
既保住"不嵌套",又保住按钮的视觉位置。
⚠️ 关联表单必须**自带全部字段**(csrf / 宿主的 id / photo_id)——
按 `form=` 关联时,浏览器提交的是**那个表单自己的**字段,
不会把外层表单的字段带过去(少了 csrf 会被 csrf_check 挡下,
少了 id 会定位不到旅程)。所以这里逐个写全。
⚠️ 平时是全空的(没有照片就没有这些表单)。 */ ?>
<?php foreach (($delForms ?? []) as $ph): ?>
<form id="nf-phdel-<?= (int)$ph['id'] ?>" method="post"
action="<?= e(url('trips/photo_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
</form>
<?php endforeach; ?>
<?php unset($delForms); ?>
<?php /* ==================== 花销(只在编辑时出现)====================
用户第 2 条要"一共花了多少钱"、第 16 批确认「行程级花销记录」。
⚠️ 新增时不给花销面板:那时行程 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; ?>
<?php /* ==================== 高德 SDK(按需加载,与 TripLog 同一套做法)====================
⚠️ SDK 加载**失败也必须能用**:文本框就是普通输入框,用户手敲地名照样能存。
所以这段脚本里所有 AMap 的用法都放在 `__AMAP_LOADED` 判断之后,
并且给"搜索这个地名"兜底链接单独一条路径(不依赖 SDK)。 */ ?>
<?php if ($amap['key'] !== ''): ?>
<script>
window._AMapSecurityConfig = { securityJsCode: '<?= e($amap['security']) ?>' };
window.__AMAP_LOADED = false;
</script>
<script src="https://webapi.amap.com/maps?v=2.0&key=<?= e($amap['key']) ?>&plugin=AMap.AutoComplete"
onload="window.__AMAP_LOADED = true;"
onerror="window.__AMAP_ERROR = 'script load failed';"></script>
<?php endif; ?>
<script>
/* ============================================================
地址联想(v24 第 17 批 用户第 3 条)
照 TripLog 的做法:AMap.AutoComplete 直接挂在 input 上,边打边出候选;
选中后把 name / address / lng / lat / city **同时**写进五个字段。
⚠️ 高德没加载时整个块跳过,input 仍是普通文本框(降级可用)。
⚠️ 用 IIFE 包起来:这个页面只有一份表单,但函数名别污染全局
(app.js 里也有通用初始化,命名撞了会互相覆盖)。
============================================================ */
(function () {
'use strict';
function bindLoc(prefix) {
var input = document.getElementById('loc-' + prefix + '-input');
if (!input) { return; }
var wrap = input.closest('.loc-field');
if (!wrap) { return; }
var fAddr = wrap.querySelector('input[name="' + prefix + '_addr"]');
var fLng = wrap.querySelector('input[name="' + prefix + '_lng"]');
var fLat = wrap.querySelector('input[name="' + prefix + '_lat"]');
var fCity = wrap.querySelector('input[name="' + prefix + '_city"]');
var dTail = wrap.querySelector('[data-loc-detail]');
var navEl = wrap.querySelector('[data-loc-nav]');
/* 人选完之后(或手敲完)更新"详细地址"那行字与导航链接。
导航链接用高德 URI API —— **没有 SDK 也能用**,所以不放在 __AMAP_LOADED 里。 */
function paint() {
var name = input.value.trim();
var addr = fAddr ? fAddr.value : '';
var lng = fLng ? fLng.value : '';
var lat = fLat ? fLat.value : '';
if (dTail) { dTail.textContent = (addr && addr !== name) ? addr : ''; }
if (navEl) {
if (lng !== '' && lat !== '') {
navEl.href = 'https://uri.amap.com/navigation?to=' + encodeURIComponent(lng + ',' + lat + (name ? ',' + name : ''))
+ '&mode=car&policy=1&coordinate=gaode&callnative=1';
navEl.hidden = false;
} else if (name !== '') {
navEl.href = 'https://uri.amap.com/search?keyword=' + encodeURIComponent(name);
navEl.hidden = false;
} else {
navEl.hidden = true;
}
}
}
// 手敲也要更新(用户可能不用联想,直接打字)
input.addEventListener('change', paint);
input.addEventListener('blur', paint);
if (!window.__AMAP_LOADED || typeof AMap === 'undefined' || !AMap.AutoComplete) {
return; /* 降级:没有联想,但输入与保存都正常 */
}
try {
var ac = new AMap.AutoComplete({ input: input });
ac.on('select', function (e) {
var d = (e && e.poi) ? e.poi : null;
if (!d) { return; }
var loc = d.location || null; // 可能是 AMap.LngLat,也可能是 {lng,lat}
var lng = loc ? (loc.lng !== undefined ? loc.lng : (loc.getLng ? loc.getLng() : '')) : '';
var lat = loc ? (loc.lat !== undefined ? loc.lat : (loc.getLat ? loc.getLat() : '')) : '';
if (fAddr) { fAddr.value = d.address || ''; }
if (fLng) { fLng.value = (lng === null || lng === undefined) ? '' : lng; }
if (fLat) { fLat.value = (lat === null || lat === undefined) ? '' : lat; }
/* 高德把城市放在 addressComponent / pname+cityname 上 —— 尽量带回城市,
省掉服务端一次逆地理请求(用户第 1 条要的就是城市)。 */
if (fCity) {
if (d.addressComponent && d.addressComponent.city) {
fCity.value = d.addressComponent.city;
} else if (d.cityname) {
fCity.value = d.cityname;
} else {
fCity.value = '';
}
}
paint();
});
} catch (err) {
/* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级,不打扰用户 */
}
}
bindLoc('from');
bindLoc('to');
/* 出行方式 → 显隐车辆 / 车次(用户第 17 批 第 3 条) */
var modeSel = document.querySelector('[data-mode-select]');
if (modeSel) {
var vehBox = document.querySelector('[data-mode-vehicle]');
var tikBox = document.querySelector('[data-mode-ticket]');
var DRIVE = ['drive']; // 与 Trip::DRIVE_MODES 一致
var TICKET = ['train', 'plane']; // 与 Trip::TICKET_MODES 一致
var sync = function () {
var v = modeSel.value;
if (vehBox) { vehBox.hidden = DRIVE.indexOf(v) === -1; }
if (tikBox) { tikBox.hidden = TICKET.indexOf(v) === -1; }
};
modeSel.addEventListener('change', sync);
sync();
}
})();
</script>