Files
FamilyBook/templates/trips/node_form.php
T
laoyang 32e9e9ab84 v47:地址联想三处接入 / 读书页慢的真根因(会话锁)/ 笔记两处 / 日程状态下线
用户 2026-10-13 七条。拍板:影院只记地址、不存经纬度(后续用高德反查);
日程状态只删界面与展示、数据库列保留(「后续会有一个整体清理的工作」)。

①🔴 读书页慢 —— 真根因是**会话锁**,不是封面(我先判断错了方向)
  用户原话:「读书现在还是非常慢,就是进入读书这个页面,刚开始还是非常的慢。」
  线上实测(真公网、同一会话连打 3 次):
      第 1 次 TTFB 0.08s  ← 触发微信读书同步
      第 2 次 TTFB **8.73s**  ← 用户说的"刚开始非常慢"
      第 3 次 TTFB 0.07s
  插桩拿到的服务端时间线把因果钉死:
      21:18:05.483 QUEUED        ← 第 1 个请求排队
      21:18:05.501 FINISH  2ms   ← 响应确实提前发出去了(这步是好的)
      21:18:14.674 SYNC    9.2s  ← 但它仍占着这个人的会话文件锁跑完 9.2 秒
      21:18:14.682 QUEUED        ← 第 2 个请求此刻才真正开始执行
  ⇒ **`fastcgi_finish_request()` 只断开客户端连接、不释放 PHP 文件会话锁。**
    后台同步要 9.2 秒(真打微信读书接口),同一个人的下一个请求一路等到它跑完。
  修法(缺一不可):
    · `WeRead::queueBackgroundSync()` 回调里 **先 `session_write_close()`、
      再 `fastcgi_finish_request()`**(顺序不能反);
    · `index.php` 两处"渲染前排同步"的入口排完**立刻放锁**
      (shutdown 回调不一定执行:客户端中断/服务器超时才不放过);
    · 顺带修 **`Holiday::queueBackgroundFetch()` 的同型问题**(联网 12 秒)。
  为什么以前没发现:v29 引入"后台化"时只验证了"响应是不是提前发出去",
  **单次请求测量永远看不到这个 bug** —— 必须同一会话连打两次才现形。
  线上复验:修复后连打 4 次 = 0.15 / 0.15 / 0.09 / 0.15 秒(修复前第 2 次 8.73s);
  后台同步仍正常(`weread_sync_at` 已更新)。

② 封面:顺手也修了(但这不是用户说的那个问题)
  · `media.php` 读 OSS 前先放会话锁(原来从不放,35 张封面串行);
  · 新增 `Storage::cacheLocally()` 磁盘二级缓存(原来 `read()` 只读不写回);
  · 让 304 不再先拉整张图(原来先取回 body、再判 `If-None-Match`)。
  线上实测:冷取 149ms(走 OSS)→ 热取 35ms(本地缓存、字节一致)
  → 带 `If-None-Match` 返回 **304 / 19.8ms / 空体**;35 张封面 103ms → 20ms(5 倍)。

③ 地址联想:抽成全站共用一份(`assets/js/addr_suggest.js`)+ 接入三处
  · 影院管理页、观影新增页的「新建影院」弹框、日程地点、行程出发/到达;
  · `layout.php` 按需加载(`[data-addr]` 出现才拉 SDK + 我们的脚本),
    并暴露 `fbLoadAddr(scope)` 给 AJAX 片段(v42 踩过"内联 script 不执行");
  · 安全密钥在 SDK 之前设置;回填支持 `data-addr-fill` / `data-addr-for`、
    **只补空白字段**(不覆盖用户手写)、直辖市 `province==city` 去重;
  · 字段名有两套(影院 `address`/`place`,行程 `from_addr`/`to_addr` 缩写 `_addr`),
    `fieldFor()` 带同义词表 + 后缀匹配;
  · 影院**只存地址、不存经纬度**(按用户要求)。

④ 笔记两条
  · 「点保存没反应」真因:附件区是个 `<form>` **套在保存表单里** ——
    HTML 不允许 form 套 form,浏览器丢掉内层标签、保留它的 `</form>`,
    于是**保存按钮成了孤儿**(`button.form === null`),点了不提交也不报错。
    真浏览器实测:修前 `btnInsideForm:false` / 点后 POST=0;修后 true / POST=1。
    改法:附件区改 `<div id="noteAttForm">`,上传按钮用 `formaction`。
  · 详情页宽度:`.note-read { max-width: 820px }` → `none`
    (内容卡 820px vs 下方共享卡 1032px;修后两者都是 1032px)。

⑤ 日程状态下线
  · 删表单里的「状态」下拉、`Calendar::statusWord()`、浮层 JSON 的 `status_word`;
  · **保留** `events.status` 列与 `Calendar::statusOf()`(契约不动,等整体清理)。

验证(新增 5 个套件):
  · `_fb_v47_addr.py`(接线 24 条)/ `_fb_v47_addr_sim.py`(真浏览器 + 假高德,16 条)
  · `_fb_v47_readperf.py`(缓存与会话锁,23 条)
  · `_fb_v47_notes.py`(真浏览器,9 条)
  · `_b47_readperf_falsify.py`(反向验证 9 个注入)—— **9/9 全抓住**
  · 全量 e2e **1510/0**;vCard 规格+往返 40/0;TB 探针 44/0;
    v44 反向验证 17/0;全流水线 **PUSH_RC=0**(含"第47批形态"静态断言)
  · **线上终验**:读书页整页 81ms、封面 103→20ms、media 冷 149→热 35→304 19.8ms、
    修复前第 2 次请求 8.73s → 修复后 0.15s、地址联想 meta[fb-addr] 带真高德 key

顺带修掉三处**探针自身**的假红/假绿(详见 pitfalls.md):
  · `_b44_falsify.py` 的"整小时"注入在**整点**与正确实现等价 ⇒ 换成恒错值;
  · `_fb_b44_ui.py` 3b 的并发计时断言在单进程 `php -S` 下必然假红 ⇒ 改断言确定事实;
  · 多处探针的"清理删除"撞沙箱删除门禁 ⇒ 探针**中途死掉**看起来像产品坏了
    ⇒ 统一改成 `_safe_rm()` / 不删。
2026-10-14 01:20:40 +08:00

589 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 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 /* ==================== 地址联想(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>