Files
FamilyBook/templates/trips/node_form.php
T
laoyang f58a128816 行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)
#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊
#48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间
#50 类型点击无反应(@Builder 值传参不建立依赖)
#51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调

API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action
DB 新增:trip_nodes.actual_started_at / actual_ended_at
2026-11-05 12:40:57 +08:00

585 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'] ?? ''),
];
/* 🔴🔴 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 class="is-req" 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>
</div>
<?php /* 🔴 #47(2026-11-05):**「当前状态」不再显示**。
用户:「实际行程、当前状态实际行程,这个地方也不要出现,
因为这是没有意义的。我现在本来就是添加的一个实际行程。」
⇒ 阶段由入口决定(计划/实际两颗按钮),编辑时不该再展示。 */ ?>
<?php /* ---- 标题 ---- */ ?>
<div class="field full">
<label class="is-req" 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'; ?>
</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">
</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>
<?php /* 🔴 #47(2026-11-05):**「执行状态」不再由表单选**。
用户:「这个里面不要让它手动去动。只要是添加的计划行程,
它的状态就是计划中。在计划行程里面点击开始执行,
它的状态自然变成进行中…所以编辑这个地方不要出现
计划中、进行中、已完成、已取消。」
⇒ 状态由 show.php 节点卡片上的动作按钮改(#47 新增的 API)。
表单**不提交 status**,服务端 saveNode 的 PATCH 语义会沿用原值
(见 Trip::saveNode 里 `array_key_exists` 的判定)。 */ ?>
<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 /* ==================== 地址联想(v47 起改用全站共用模块)====================
🔴 v47(用户 2026-10-13):原来这里是**一段本页内联脚本**(自己 new AMap.AutoComplete)。
这次影院名称、日程地点也要联想 ⇒ 再各写一份必然出现"某个入口忘了补"。
所以抽到 `assets/js/addr_suggest.js`,由 layout.php 按需加载(页面上有 `[data-addr]`
才拉高德 SDK 与那份脚本)。本页只负责:
· 给 from/to 两个输入框打上 `data-addr` 标记(回填 addr/lng/lat/city);
· 保留"手敲也要更新详细地址那行字 + 导航链接"的本地逻辑(不依赖 SDK)。
⚠️ 与 v42 同一条约束:这段表单会被 AJAX 片段塞进 DOM(详情页弹框),
内联 script 标签那时不会执行 ⇒ 初始化统一走 `window.fbNodeForm(scope)`,
它内部会调 `window.fbLoadAddr(scope)` 把联想按需拉起。 */ ?>
<script>
(function () {
'use strict';
/* 与我的关系(与 App\Trip::DRIVE_MODES 无关的那部分)—— 见下 bindLoc 的说明 */
/**
* 出发地 / 到达地:**只做"不依赖 SDK"的那半**(详细地址那行字 + 导航链接)。
* 联想与字段回填由共用模块负责(它在 input 上打 data-addr 标记即可)。
*/
function bindLoc(prefix, scope) {
var input = (scope || document).querySelector('#loc-' + prefix + '-input');
if (!input) { return; }
// ⚠️ 幂等:同一段表单被反复塞进 DOM(弹框开关)时不能重复绑事件
if (input.getAttribute('data-loc-bound') === '1') { return; }
input.setAttribute('data-loc-bound', '1');
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 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);
// 共用模块选中候选后会派发这个事件 —— 那时 addr/lng/lat 刚被写好,重画一次
input.addEventListener('fb-addr-picked', paint);
paint();
}
/* ---- 出行方式 → 显隐车辆 / 车次(用户第 17 批 第 3 条)----
抽成函数:弹框里重新塞进一段表单时要能再跑一次。
⚠️ 幂等:`change` 只绑一次(`data-nf-bound`),`sync()` 每次都可重跑。 */
function bindMode(scope) {
var modeSel = (scope || document).querySelector('[data-mode-select]');
if (!modeSel) { return; }
var vehBox = (scope || document).querySelector('[data-mode-vehicle]');
var tikBox = (scope || 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; }
};
if (modeSel.getAttribute('data-nf-bound') !== '1') {
modeSel.setAttribute('data-nf-bound', '1');
modeSel.addEventListener('change', sync);
}
sync();
}
/* 整个表单的初始化(可重复调用;scope 缺省 = document) */
function initNodeForm(scope) {
var root = scope || document;
bindLoc('from', root);
bindLoc('to', root);
bindMode(root);
/* 🔴 v47:地址联想走共用模块。**必须在这里调**(而不是只靠 layout 首屏那一次)——
AJAX 片段塞进 DOM 时,layout 的 loader 早就跑过了,新加的输入框没人绑。 */
if (typeof window.fbLoadAddr === 'function') {
window.fbLoadAddr(root);
} else {
// 还没加载出来(首屏竞态)→ 等它上线后再补一次
window.addEventListener('fb-addr-ready', function once() {
window.removeEventListener('fb-addr-ready', once);
if (typeof window.fbAddr === 'function') { window.fbAddr(root); }
});
}
}
// 首屏跑一次(整页形态)
initNodeForm(document);
// 暴露给"片段被塞进 DOM 之后"的调用方(详情页弹框)
window.fbNodeForm = initNodeForm;
})();
</script>