Files
FamilyBook/templates/trips/node_form.php
T
laoyang f0437e6c93 v24 第 21 批:无刷新删除与新建日程 / 实际行程不回退 / 行程按时间排 / 统一上传框 / 时间范围控件修复(用户 8 条)
用户 8 条原话要点:
  ①「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
     从当前页面的 dom 中删除,页面保持不动。」
  ②「观影模块,海报和票根以及照片都带进来了,但是没有缩放,把页面撑破了,
     应该缩放成缩略图。」
  ③「个人设置页面,天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行
     (如「青岛」)… 这些提示,都删除。把城市单独放一行,精度维度放在一行。」
  ④「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,这个不合理,
     实际行程永远是实际行程,不会再变回计划行程了。」
  ⑤「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,最新的在最上面,
     而不是按照编辑修改时间。」
  ⑥「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
  ⑦「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击上传
     按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
     点击或拖动进行上传,其他的功能和现在保持一致。」
  ⑧「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
     这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。添加日历日程,
     开始时间和结束时间那里,用我们统一的时间范围选择器重做,现在功能无法使用。
     选择日历本,带上颜色。」

🔴 五个真根因(全都不像 bug):

 ① 🔴 **④「实际行程变回计划」= 表单字段同名合并**(本批最要紧的一条)
    暂存上传的令牌字段原叫 `stage[]`,与行程表单里那个隐藏的 `stage`(plan/actual)
    **同名**。PHP 解析表单时同名会**合并进同一个键**:
        `stage=actual` + `stage[]=tok` → `$_POST['stage'] = ['actual','tok']`
    (`[]` 只是"往同一个键追加"的记号,**不构成另一个键名**。)
    于是路由里 `(string)($_POST['stage'] ?? 'plan')` 得到 `"Array"` →
    `in_array('Array', ['plan','actual'])` 为假 → **静默回落成 'plan'**。
    ⚠️ 只有"拖过图片的行程"会犯(没拖就没有 `stage[]`)→ 症状是"时好时坏"。
    修法:令牌字段改名成 `stage_token[]`(改的是**字段名本身**,不是"是数组就取第一个"
    绕过去 —— 同名合并是 PHP 的表单语义,绕过去只会把坑留给下一次加字段)。
    **另加一道语义保险**:编辑路径**一律沿用库里那条自己的 stage**,不读表单 ——
    编辑表单里根本没有改阶段的控件(改阶段走详情页那几个显式动作)。
    这样即便将来又冒出某个字段名冲突,也不可能把「实际」悄悄变回「计划」。

 ② 🔴 **⑧「时间范围选择器无法使用」= 三道门叠在一起**
    ① `_event_fields.php` 的**结束端默认带 `hidden`**(要点「+ 添加结束时间」才出现);
    ② `app.js` 的 `upgradeRange()` 有「**两端都得可见**才升级」的判断 → 永远不成立
       (实测 `data-semi-range-done` 恒为 `null`);
    ③ CSS `html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden }` →
       未升级时原生控件**也是不可见的** ⇒ 屏幕上一片空白、点哪儿都没反应。
    修法:两端**始终可见**("没有结束"由清空后半段表达,不再靠开关);
    并把旧的「+ 添加结束时间 / 去掉结束时间」开关整套删掉。
    ⚠️ 顺带挖出:`upgradeRange()` 里 `group.insertBefore(host, a)` 在"两端各自被
    `.field` 包着"时 `a` 不是 `group` 的子节点 → 直接抛
    `Failed to execute 'insertBefore' on 'Node'`。这个异常**以前从没触发过**
    (被 ② 的提前 return 挡住了)—— 一旦放出来就炸。修成插在"端点最外层包装"之前。
    ⚠️ 观察器加了 `attributes: true, attributeFilter: ['hidden']`(并把 childList
    分支的 `return` 去掉):弹框/折叠面板/tab 里"稍后才露出来"的日期控件从此都能自动升级。

 ③ 🔴 **⑧「新建日程默认建出观影记录」= 产品缺口,不只是显示问题**
    `Calendar::DEFAULT_KINDS` 原来只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/
    节气),**一本可写的普通本都没有**。于是"放在哪本"里全是派生本、默认选中排最前的
    **观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**
    (日历里看着是多了条日程,观影列表里莫名多一条)。这是"建错东西",事后极难发现。
    → ① 默认本补成「我的日程」(kind='custom'),模板里默认选中也改成**优先 custom 本**;
      ② 选择器里把**纯派生只读本**禁用并标「(自动生成)」
         (直接不列会让人以为本子丢了;列为可选又会白选一次再吃错误)。
    ⚠️ 顺带发现 `Calendar::READONLY_KINDS` 把 `movie`/`trip` 也算进去了,而
    `event_add` 里**紧跟着**就有两段专门处理它们的分支(「已加进观影」/「已加进旅程」)
    —— 那两段**永远走不到**(校验先一步把请求打回去了)。判据要按**能不能写**分,
    不是按"是不是派生"。

 ④ 🔴 **②「观影编辑页图片撑破」= `.mv-slot` 这个类在样式表里根本不存在**
    第 21 批加模板时只写了结构没写样式 → `<img>` 按**原始像素**渲染:600×900 的海报
    在 236px 宽的格子里撑出 942px 高,还把整页横向顶破(实测 docW 1297 > vw 1280)。
    → 补 `.mv-slot` 规格:固定 108×162 缩略图 + `object-fit: cover`。
    ⚠️ **高度必须写死** —— 不写死就又会由原图比例决定,等于没修。

 ⑤ **⑤行程列表排序 = `category, position` 主导**
    `position` 默认同一个数(旧迁移给的多是 0/100),于是顺序被 position 挤乱。
    实测线上 #222 渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」。
    → 改成 `ORDER BY (starts_at IS NULL), starts_at, id`。
    ⚠️ 方向是**正序**(早的在前,已跟用户确认);旅程列表仍是 DESC(最新在最上)。
       两者方向不同是**刻意的**:一个是"挑哪趟旅行",一个是"读这趟旅行的行程"。
    ⚠️ 详情页**按阶段分三组**渲染(计划/实际/未成行),排序只需在**组内**有序。

本批新增能力:
- **统一上传框**(⑦):`templates/_stage_drop.php` 现在**同时**负责
  「sr-only 的真文件框 + 看得见的上传框 + 暂存清单」,调用方只 require 一行。
  框是 `role="button" tabindex="0"`,JS 转发 `input.click()`(键盘 Enter/Space 也通),
  拖文件进框走既有的暂存上传(拖一个立刻传、可续拖、失败标红、未传完拦住保存)。
  ⚠️ 框用 `<div>` 不是 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
     按钮里嵌按钮是非法 HTML(第 21 批刚在 `<form>` 嵌套上栽过同一个跟头)。
  ⚠️ 文件框用 `.sr-only` 不是 `display:none`:后者不可聚焦,带 `required` 时
     Chrome 会报 "An invalid form control is not focusable" —— 点了什么都不发生。
  落了 11 处:行程照片 / 观影海报·票根·合影 / 资源 / 任务 / 日程附件 / 书籍 /
  联系人导入 / ics 导入 / 观影补传 / 资源补传。
  ⚠️ 清单与提示行移到框**外面**(放框内会变成"一个大框套五张图"),
     所以 app.js 改从 `.upfield` 容器里找它们。
- **无刷新删除(①)与无刷新新建日程(⑧)**:路由按 `X-Requested-With`
  (或 `Accept: application/json`)区分调用者 —— 带它回 JSON、不带照旧 302
  (无 JS 的兜底**不变**,两边的反向都写了断言)。
  · 名册删除:成功 → 摘卡片 + 就地改「共 N 人」与分组人数 + 派发 `input` 让
    filter.js 自己重算(**不抄它的判据**);失败 → 把原因说出来(不刷页);
    拿不到 JSON → **退回原生提交**(绝不静默失败)。
  · 日历新建:JSON 带齐前端插入当前视图要用的字段(时间/颜色/链接/是否全天/cal)。
    月视图**按时间插入**(不能 append —— 服务端只渲染前 3 条、按时间序取,
    append 会让"新加的早间日程"掉进「+N 条」里看不见,刷新后又跑到第一位);
    周/日视图按"24h = 100%"算 top/height 插块。
  · 观影 / 旅行本是**可写派生本**:AJAX 时不再跳详情页(跳走就离开了日历),
    改在提示行给一个「去看详情 →」链接。
- **⑧日历本带颜色**:原生 `<option>` 画不出色块(浏览器不允许在 option 里放元素),
  所以做成「色块 + 下拉」并排,色块跟着当前选中的本子变色。
- **③天气卡片**:三句提示全删;城市单独一行、纬度/经度同一行(`.loc-row`)。
  删的只是说明文字,三个输入框 + 保存 + 状态回显都保留。
- **⑥设置按钮贴最右**:原来顺序是「… → 设置 ☰ → 删除 🗑」,设置被删除键挤到**倒数第二**;
  改成删除在前、设置恒为最后一个(与观影/日程/任务/资源同一个手势)。

验证(本地全套):
- e2e **1059/0**(本批新增约 40 条断言)· 结构升级 149/0 · 桌面几何 428/0
- **真机探针 75/0**(`_fb_b21_ui.py`):AJAX 删除真的没导航 / 卡片真被摘掉 /
  滚动位置保持;缩略图几何(108×162,不再溢出);上传框(框在、原生框不可见、
  点框真打开文件选择框、连拖两张都上去且清单可见);日历弹框(打开时网格一动不动、
  范围控件真的挂上且可见、能弹面板、色块跟着换、无刷新提交后当前视图里立刻出现、
  周视图也能插时间块、全程无 JS 报错)。
- **反向验证 12 条**(`_fb_b21_falsify.py`):逐条把修复改回旧行为看断言是否变红 ——
  令牌字段改回 stage[] / 编辑读表单 stage / 排序改回 position / 设置按钮挪走 /
  框不转发点击 / 文件框不再 sr-only / 删缩略图规格 / 删除不回 JSON /
  结束端改回 hidden / 日历本默认改回第一个 / 天气提示加回 / 弹框改回内联。
- 部署脚本静态断言段本地空跑 **307/0**。
2026-10-06 12:08:40 +08:00

514 lines
27 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'));
$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();
?>
<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 的第一个字段;第 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 /* ---- 起止时间:一个范围控件(精确到秒,用户第 17 批问"怎么不显示时分秒")---- */ ?>
<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)---- */ ?>
<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>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加这段行程' ?></button>
<a class="btn btn-ghost" href="<?= e($back) ?>">取消</a>
</div>
</form>
</div>
<?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>