用户 2026-10-14 四条。
① 旅程详情点点「编辑」,里面的图片改不了、拖入/选中都不弹裁剪框
用户原话:「应该弹出来一个让我裁剪的那个框。但是现在却没有那个裁剪的框。」
**真根因**(assets/js/app.js):裁剪那段 IIFE 是**首屏一次性**的 ——
`var inputs = querySelectorAll('[data-crop-ratio]'); if (!inputs.length) return;`
在脚本加载那一刻扫一遍,扫不到就整段放弃。而「编辑旅程」是 **AJAX 取片段、
innerHTML 塞进弹框**的(templates/trips/show.php 的 loadInto)⇒ 详情页首屏
**没有**题图框 ⇒ 当场 return ⇒ 片段里的框**从来没有 change 监听**
⇒ 拖入/选中什么都不发生。
修法:拆成 `bindOne(input)`(幂等,打 `data-crop-bound` 标记)+ `scan(scope)`;
首屏调一次 + **MutationObserver 补挂**;库的按需加载改成"首次真要用时才拉"。
⚠️ 与「拖动上传」那段(bindInput/scan/MutationObserver)**同一手法** ——
它早就解决了"动态插进来的文件框也要接得住",裁剪这段当年漏了这层。
② 旅程「修正」表单(templates/trips/_revise_form.php)
用户三条:「开始和结束时间…应该改成我们最新的控件样式」「上面记录实际执行情况
那一段话说明也不要了」「记录这次修正的按钮应该放在右边,另外应该加一个取消」。
· 起止换 `$dtPlugin = 'antd'`(与 node_form.php 同款;当年 v41 刻意没换,现按用户要求统一)
· 删 show.php 里 `.rev-tip` 整段
· 按钮行 `rev-foot` 靠右 + 新增 `[data-rev-cancel]`;取消由 show.php 的**事件委托**
处理(`details.rev-open` 收起)—— 不用内联脚本(片段会被 AJAX 换进来)
③ 名册打标签误报「这个人不在你的名册里」
用户原话:「我搜索出来一个人…给他打标签,它会显示说这个人不在我的名册里,
其实他是在我的名册里的。」
**真根因**(index.php 的 people/tags):`isset(PersonRoster::visibleIds(...)[$pid])`,
而 `visibleIds()` 返回的是 **id 值列表** `[2,3,4,5]`(键是 0,1,2,3)——
把值列表当 id→值 映射用。只有 `$pid` 恰好等于数组下标时才为真:名册里人少时
(下标<pid)**谁都判"不在名册"**;一般情况只有"第 pid+1 个"碰巧过。
修法:改用 `in_array(..., true)`(与同文件 `inMyRoster` 写法一致)。
⚠️ **没改 `visibleIds()` 的返回形状** —— 另一处调用已是正确的 in_array,改形状会
静默弄坏它。陷阱:`PersonGroup::visibleIds()` 是集合、`PersonRoster::visibleIds()`
是值列表,**同名不同形状**,已在两处注释里写明。
(附带确认:不在名册的人本来就不出现在列表里,用户"不在名册就该搜不出来"本就成立。)
④ 名册列表按钮:编辑蓝底、删除红底
用**作用域类** `.person-edit`(var(--blue)) / `.person-del`(var(--red)) 三处形态都写,
不动全局 .btn-edit/.btn-danger 的语义(v41 踩过"裸类改色波及影院管理页")。
hover 在这里补 `filter: brightness(.92)`(文末"全站悬停统一"段是白名单写的)。
验证(新增 2 套件 + 更新 1 处旧断言):
· _fb_v50_ui.py(22 条真机断言:题图绑定/选中与拖入都弹裁剪框 16:9、修正 antd+无说明+
靠右+取消能收起、名册内 3 人打标签均成功且不误报、编辑蓝删除红)
· _b50_falsify.py(**10 注入 / 21 断言全抓**)
· _fb_e2e.py 的 ★第28-6条断言随 v50-4 升级(原查 var(--brand-soft),现查蓝+红作用域类)
· familybook_deploy.sh 加 v50 静态断言段(含两条"旧写法必须已不在"的反向断言)
· e2e 1511/0、几何、各批反向验证全绿;**v50 四条 22/0**、**v50 反向验证 21/0**;
全流水线 **PUSH_RC=0**,部署脚本 0 FAIL(含"第50批形态");线上 6 文件 md5 逐字节一致;
线上门禁诊断 16 项全 YES,临时页已删并复验 404
⚠️ 收尾时发现并修掉一个**自设陷阱**:我在 show.php 的删除说明里**整句引用了**用户点名要删的
那句原话 ⇒ 朴素 strpos 判"已删"时会命中我自己的注释(线上诊断 C8=NO)。
部署断言用 `_code_has`(剥注释)所以是对的,但这类字串留在注释里迟早坑到别的检查
⇒ 已改成不整句照抄。**改注释也改了文件哈希 ⇒ 又重跑一次全量部署**。
⚠️ 流水线末段唯一"失败"同 v49:沙箱安全删除门禁(按轮次累计删满 50 就终止进程);
单独重跑 _fb_v46_carddav.py = 29/0 ⇒ 环境护栏,与本次改动无关。
743 lines
42 KiB
PHP
743 lines
42 KiB
PHP
<?php
|
||
/**
|
||
* 旅程详情(v24 第 16 批重做)。
|
||
*
|
||
* ⚠️ 术语(用户第 9 条:「活动都改名吧,改成旅程,新建旅程,旅程包含行程,
|
||
* 这个名字就固定下来」):
|
||
* · **旅程** = 这一页(原来叫"活动")
|
||
* · **行程** = 里面的一段一段(原来叫"旅程")
|
||
*
|
||
* 页面结构(用户第 1/2/3/6 条):
|
||
* ① 页头:标题 + 右上角按钮(计划行程 / 实际行程 / 设置 / 编辑 / 删除)
|
||
* —— 标题旁边**不放时间**(第 6 条:时间移到内容里、题图右侧)
|
||
* ② 封面行:左 16:9 题图 + 右侧信息区
|
||
* 信息区 = 参与人 / 城市数 / 天数 / 行程段数 / 总花销 + 简介那一段话
|
||
* ③ 行程分组:**只显示非空的分组**(第 4 条:"只要是没有就是 0 条,
|
||
* 那个类型就不显示了,现在显示的是 0 条,这样没意义")
|
||
* ④ **没有照片上传板块**(第 3 条:图片视频都针对一次具体行程)
|
||
*
|
||
* @param array $t 旅程(events + trip_details + 派生值)
|
||
* @param array $nodes 全部行程(已带上成员/附件/花销)
|
||
* @param array $sm 汇总信息(Trip::summary())
|
||
*/
|
||
$canWrite = \App\Auth::canWrite();
|
||
$fid = (int)\App\Auth::familyId();
|
||
$sm = $sm ?? [];
|
||
$people = $sm['people'] ?? [];
|
||
$cities = $sm['cities'] ?? [];
|
||
$days = (int)($sm['days'] ?? 0);
|
||
$nodeN = (int)($sm['nodes'] ?? 0);
|
||
$cents = (int)($sm['cents'] ?? 0);
|
||
|
||
// 三组:计划 / 实际 / 未成行
|
||
$bysStage = ['plan' => [], 'actual' => [], 'skipped' => []];
|
||
foreach ((array)($nodes ?? []) as $n) {
|
||
$sg = (string)($n['stage'] ?? 'plan');
|
||
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
|
||
$bysStage[$sg][] = $n;
|
||
}
|
||
$groupMeta = [
|
||
'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'],
|
||
'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'],
|
||
'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'],
|
||
];
|
||
?>
|
||
<div class="page-head">
|
||
<h1><?= e((string)$t['title']) ?></h1>
|
||
<?php /* ⚠️ 用户第 6 条:标题旁边**不放**时间。时间在下面题图右侧的信息区里。 */ ?>
|
||
<div class="actions">
|
||
<?php if ($canWrite): ?>
|
||
<?php /* 🔴🔴 v41(用户 2026-10-10 第 4 条):
|
||
「旅程详情页面右上角的按钮,顺序分别是从左到右:
|
||
+计划行程、+实际行程、编辑、删除、返回旅程和设置。
|
||
计划行程的按钮用蓝色背景;实际行程的按钮用绿色背景;
|
||
编辑用黄色背景;删除用红色背景;设置就用这个图标,不要变。」
|
||
⇒ 两条硬要求:
|
||
① **顺序**:+计划行程 → +实际行程 → 编辑 → 删除 → 返回旅程 → 设置(☰);
|
||
② **配色**:计划=蓝、实际=绿、编辑=黄、删除=红;设置保持图标不变。
|
||
⚠️ 之前是「返回旅程在最左、其余 ghost/实心混着」,与本条**完全相反** ——
|
||
所以这一次是**按新顺序重排整段**,不是微调。
|
||
⚠️ 原来"+计划行程/编辑"是 `<a>`(跳页面),现在全部改成**弹框**(见第 5 条),
|
||
所以这里用 `<button type="button">` + data 属性,由页内脚本打开弹框。 */ ?>
|
||
<button class="btn btn-plan" type="button"
|
||
data-node-open="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'plan', 'embed' => 1])) ?>"
|
||
aria-haspopup="dialog" aria-expanded="false">+ 计划行程</button>
|
||
<button class="btn btn-actual" type="button"
|
||
data-node-open="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'actual', 'embed' => 1])) ?>"
|
||
aria-haspopup="dialog" aria-expanded="false">+ 实际行程</button>
|
||
<button class="btn btn-edit" type="button"
|
||
data-trip-edit="<?= e(url('trips/edit', ['id' => (int)$t['id'], 'embed' => 1])) ?>"
|
||
aria-haspopup="dialog" aria-expanded="false">编辑</button>
|
||
<form method="post" action="<?= e(url('trips/destroy')) ?>" class="res-inline"
|
||
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的行程、照片会一起删掉,不可恢复。')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
||
<button class="btn btn-del" type="submit" title="删除这个旅程">删除</button>
|
||
</form>
|
||
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
|
||
<?php /* ⚠️ 设置**恒为动作区最后一个**(全站统一:观影/日程/任务/资源都如此)。
|
||
用户这次明确"设置就用这个图标,不要变" ⇒ 保持 `_settings_menu.php`
|
||
原样(纯图标 ☰),一个字都不动。 */ ?>
|
||
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
|
||
<?php else: ?>
|
||
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
|
||
<span class="link-quiet">只读账号</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* ==================== 封面行:题图 + 信息区 ====================
|
||
⚠️ 第 1 条:「题图应该按照 16:9 显示,因为添加的时候,是按照这个比例裁剪的,
|
||
现在的显示比例不对」—— 之前 CSS 写的是 4:3,把 16:9 的图裁掉两边。
|
||
16:9 这个数字与 ImageSlot 里 cover 槽位的比例**必须一致**,
|
||
部署断言里有一条专门比对它。
|
||
⚠️ 第 17 批 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,
|
||
而不是终点地址。」—— 城市来自 `Trip::cities()`(出发地 ∪ 到达地,去重),
|
||
见 summary() → cities()。 */ ?>
|
||
<div class="card card-pad trip-hero">
|
||
<div class="trip-hero-cover">
|
||
<?php if (!empty($t['cover_key'])): ?>
|
||
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
|
||
<?php else: ?>
|
||
<span class="trip-cover-empty">🧳</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="trip-hero-body">
|
||
<?php /* ---- 时间与城市:从行程算出来的(第 15 批第 2 条);
|
||
第 6 条要求放在这里(题图右侧)而不是标题旁 ---- */ ?>
|
||
<div class="trip-when">
|
||
<span class="trip-when-date"><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
|
||
<?php if ($cities): ?>
|
||
<span class="trip-when-cities">
|
||
<?php foreach ($cities as $i => $c): ?>
|
||
<?= $i > 0 ? '<span class="city-sep">·</span>' : '' ?><span class="city-chip"><?= e((string)$c) ?></span>
|
||
<?php endforeach; ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php /* ---- 五项统计(用户第 2 条):参与人 / 城市 / 天数 / 段数 / 花销 ---- */ ?>
|
||
<div class="trip-stats">
|
||
<div class="ts-item">
|
||
<span class="ts-n"><?= count($people) ?></span>
|
||
<span class="ts-l">参与人</span>
|
||
</div>
|
||
<div class="ts-item">
|
||
<span class="ts-n"><?= count($cities) ?></span>
|
||
<span class="ts-l">个城市</span>
|
||
</div>
|
||
<div class="ts-item">
|
||
<span class="ts-n"><?= $days > 0 ? $days : '—' ?></span>
|
||
<span class="ts-l">天</span>
|
||
</div>
|
||
<div class="ts-item">
|
||
<span class="ts-n"><?= $nodeN ?></span>
|
||
<span class="ts-l">段行程</span>
|
||
</div>
|
||
<div class="ts-item">
|
||
<span class="ts-n"><?= e(\App\Expense::money($cents)) ?></span>
|
||
<span class="ts-l">总花销</span>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* 参与人是谁:用户点名要("显示的是参与人都有谁")。
|
||
🔴 v41(用户 2026-10-10):标题后面那个括号(以及括号里那句解释)不要了。
|
||
这个括号连带里面的内容都不要了。」⇒ 只留标题四个字。 */ ?>
|
||
<?php if ($people): ?>
|
||
<div class="trip-people-block">
|
||
<span class="small muted">参与的人</span>
|
||
<div class="viewers-list">
|
||
<?php foreach ($people as $a): ?>
|
||
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php /* ---- 简介(用户第 2 条明确"简介那一段话保留")。
|
||
🔴 v24 第 19 批 第 4 条修的是这里:用户报「详情页题图右侧有一句话显示,
|
||
但点击编辑进去,却看不到这句话」。原因就是**这句话读的字段和表单存的
|
||
字段不是同一个**:
|
||
· 旅程表单存的是 `trip_details.summary`(编辑页那个「一段简介」框);
|
||
· 而这里先读 `summary`,再读 `events.note`(**老字段**,表单里早就没有
|
||
这个输入框了),两句都用 .trip-sum 的画法渲染 ——
|
||
于是老数据里的 `note` 会在详情页冒出来,编辑页却无从修改/删除。
|
||
现在只渲染 `summary` 一个来源:**页面显示的每一句话,编辑页都能改**。
|
||
`note` 不再出现在详情页(数据仍留在库里,不删)。
|
||
老数据里"只填过备注、没填过简介"的那批由
|
||
`_tooling/_fb_b19_backfill.php` 一次性搬进 summary。 */ ?>
|
||
<?php if (!empty($t['summary'])): ?>
|
||
<p class="info-text trip-sum"><?= nl2br(e((string)$t['summary'])) ?></p>
|
||
<?php elseif ($canWrite): ?>
|
||
<?php /* 🔴 v42(用户 2026-10-11 第 6 条):「旅程详情页面,参与的人下面,
|
||
原来有旅程的简介,怎么没了?加回来。」
|
||
⚠️ 排查结论:简介**一直在**(上面的 `!empty($t['summary'])` 分支),
|
||
结构从 v40 到 v41 一字未动(v41 只把「参与的人」后面那个括号
|
||
连同里面的说明整句去掉了)。
|
||
⇒ 用户"看不到"的真实原因是**那条旅程的简介是空的** ——
|
||
他多半是先用 v41 那个"新建旅程带入了旧数据"的 bug(本批第 1 条)
|
||
建/改过,summary 没存进去。
|
||
⇒ 修法两层:
|
||
① 第 1 条那个 bug 已修(新建不再串数据);
|
||
② 这里补一句**可点的占位**:简介为空但能写的人,看到"还没有简介,
|
||
点这里写一句" —— 否则整块是空的,用户会以为"功能被删了"。 */ ?>
|
||
<p class="trip-sum-empty">
|
||
还没有简介 ——
|
||
<a href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">点这里写一句</a>。
|
||
</p>
|
||
<?php endif; ?>
|
||
|
||
<?php /* 老数据兜底(v24 第 19 批 第 4 条):只填过「备注」、没填过「简介」的旅程,
|
||
简介这里就是空的。**不能把 note 直接当简介显示** —— 那正是用户报的
|
||
"详情页有、编辑页看不到"。但也不能让用户觉得"我的字丢了",
|
||
所以给一句能点进去补的提示;真正的内容会被 `_fb_b19_backfill.php`
|
||
一次性搬进 summary(搬完这条提示自然消失)。 */ ?>
|
||
<?php if (empty($t['summary']) && !empty($t['note'])): ?>
|
||
<?php if ($canWrite): ?>
|
||
<p class="trip-sum-legacy">
|
||
这段旅程的旧「备注」还没搬成简介 ——
|
||
<a href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">点这里补一段简介</a>。
|
||
</p>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php /* ==================== 行程分组 ====================
|
||
用户第 4 条:「只要是没有就是 0 条,那个类型就不显示了,现在显示的是 0 条,这样没意义。」
|
||
⚠️ 所以这里**跳过空分组**。但要给"一段都还没有"的情况一句引导 ——
|
||
否则新建的旅程页面上会一片空白,人不知道该干什么。 */ ?>
|
||
<?php
|
||
$shown = 0;
|
||
foreach ($bysStage as $lst) { $shown += count($lst); }
|
||
?>
|
||
<?php /* 🔴 v41:行程列表包一层 `#nodeListHost` —— 弹框里保存成功后,
|
||
前端只换这一块(保留滚动位置、不整页闪白)。
|
||
⚠️ 注意 `tripListHost`(旅程列表)与 `nodeListHost`(行程列表)是两个
|
||
不同页面的容器,前端脚本两个 id 都认。 */ ?>
|
||
<div id="nodeListHost">
|
||
<?php if ($shown === 0): ?>
|
||
<div class="card card-pad empty">
|
||
<b>还没有行程</b>
|
||
<?php if ($canWrite): ?>
|
||
点右上角「+ 计划行程」把打算走的地方排进来,或者「+ 实际行程」直接记已经走过的。
|
||
参与人、城市、天数、花销都会从行程里自动汇总到这里。
|
||
<?php else: ?>
|
||
家人还没有记录行程。
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php else: ?>
|
||
<?php foreach (['plan', 'actual', 'skipped'] as $sg): ?>
|
||
<?php $list = $bysStage[$sg]; if (!$list) { continue; } /* 空分组不显示 */ ?>
|
||
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
|
||
<h2 class="cinema-h2">
|
||
<?= e($groupMeta[$sg][0]) ?>
|
||
<span class="group-n"><?= count($list) ?> 段</span>
|
||
</h2>
|
||
<p class="stage-hint"><?= e($groupMeta[$sg][1]) ?></p>
|
||
|
||
<ol class="node-list">
|
||
<?php foreach ($list as $n): ?>
|
||
<?php
|
||
$nid = (int)$n['id'];
|
||
// ⚠️ 第 17 批 第 4 条末:「行程中,时间明明具体到了时分秒,怎么不显示?」
|
||
// → 行程行改用 nodeSpan()(精确到时分),**不再**用只到日期的 formatSpan()。
|
||
// formatSpan 那个精度是给"旅程"(容器)用的。
|
||
$span = \App\Trip::nodeSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null);
|
||
$mode = (string)($n['travel_mode'] ?? '');
|
||
$spent = (int)($n['spent_cents'] ?? 0);
|
||
?>
|
||
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
|
||
id="node-<?= $nid ?>">
|
||
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?>">
|
||
<?= e(\App\Trip::typeIcon((string)$n['node_type'], $fid)) ?>
|
||
</span>
|
||
<div class="node-body">
|
||
<div class="node-head">
|
||
<b><?= e((string)$n['title']) ?></b>
|
||
<span class="node-badge nb-<?= e($sg) ?>"><?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?></span>
|
||
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
|
||
<span class="rev-badge" title="已修正过多次">已修正 <?= (int)$n['revision_n'] ?> 次</span>
|
||
<?php endif; ?>
|
||
<?php if ($mode !== ''): ?>
|
||
<?php
|
||
/* 🔴 v41(用户 2026-10-10 第 8 条)原话:
|
||
「行程里面,交通工具的信息放在后面。比如现在的思路是出发去菏泽,
|
||
后面写赶路,再后面写火车。那就应该把火车的车次号直接放在"火车"
|
||
这两个字后面。如果是自驾,就应该把自驾的车牌号放在后面。」
|
||
⇒ 交通方式的**标识**(车次号 / 车牌号)紧跟模式词,同一个 chip:
|
||
火车 → 「🚄 火车 G1234」;自驾 → 「🚗 自驾 鲁B·12345」。
|
||
⚠️ 不再把 trip_no 单独丢进下面那排 `.ntag`(那正是"隔了一段"的来源)。
|
||
⚠️ 自驾取的是**车辆库里的车牌**(`Vehicle::label` 里已含车牌),
|
||
不是 `trip_no` —— 自驾那条字段本来就不收车次。 */
|
||
$modeExtra = '';
|
||
if (\App\Trip::modeNeedsVehicle($mode) && !empty($n['vehicle_plate'])) {
|
||
$modeExtra = (string)$n['vehicle_plate'];
|
||
} elseif (!empty($n['trip_no'])) {
|
||
$modeExtra = (string)$n['trip_no'];
|
||
}
|
||
?>
|
||
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?><?php
|
||
if ($modeExtra !== ''): ?><span class="node-mode-no"><?= e($modeExtra) ?></span><?php endif; ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php if ($span !== '日期待补'): ?>
|
||
<div class="node-time">
|
||
<?= e($span) ?>
|
||
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php /* 交通细节:**只保留**「线路名 / 车次航班号 / 承运方」。
|
||
⚠️ 第 17 批 第 3 条明确「座位什么的不记录了」→
|
||
座位 / 车厢、登机口 / 站台**不再出现在这里**
|
||
(数据库那两列还留着老数据,只是不再展示)。 */ ?>
|
||
<?php
|
||
$traffic = [];
|
||
if (!empty($n['line_name'])) { $traffic[] = (string)$n['line_name']; }
|
||
/* 🔴 v41 第 8 条:`trip_no` **不再**放这里 —— 它已经跟在
|
||
「火车 / 飞机」模式词后面了(见上面 `.node-mode-no`)。
|
||
留在这里会让车次号在**同一个行程行上出现两次**。 */
|
||
if (!empty($n['carrier'])) { $traffic[] = (string)$n['carrier']; }
|
||
?>
|
||
<?php if ($traffic): ?>
|
||
<div class="node-traffic">
|
||
<?php foreach ($traffic as $tf): ?><span class="ntag"><?= e($tf) ?></span><?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php /* 出发地 → 到达地(第 17 批 第 3 条要记详细地址;
|
||
这里显示地点名,详细地址放 title 里悬停可见 + 带导航链接)。 */ ?>
|
||
<?php if (!empty($n['from_place']) || !empty($n['to_place'])): ?>
|
||
<div class="node-route-line">
|
||
<?php
|
||
$navFrom = \App\Geo::navUrl($n['from_lng'] ?? null, $n['from_lat'] ?? null, (string)($n['from_place'] ?? ''));
|
||
$navTo = \App\Geo::navUrl($n['to_lng'] ?? null, $n['to_lat'] ?? null, (string)($n['to_place'] ?? ''));
|
||
?>
|
||
<?php if ($navFrom !== ''): ?>
|
||
<a href="<?= e($navFrom) ?>" target="_blank" rel="noopener"
|
||
title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a>
|
||
<?php else: ?>
|
||
<?= e((string)($n['from_place'] ?? '—')) ?>
|
||
<?php endif; ?>
|
||
<span class="arrow">→</span>
|
||
<?php if ($navTo !== ''): ?>
|
||
<a href="<?= e($navTo) ?>" target="_blank" rel="noopener"
|
||
title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a>
|
||
<?php else: ?>
|
||
<?= e((string)($n['to_place'] ?? '—')) ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($n['detail'])): ?>
|
||
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($spent !== 0): ?>
|
||
<div class="node-spent">💰 这一段花了 <?= e(\App\Expense::money($spent)) ?></div>
|
||
<?php endif; ?>
|
||
|
||
<?php /* ⚠️ 第 17 批 第 3 条:「给参与人标注角色这个功能去掉,不要了。」
|
||
所以这里**不再渲染** role_tag(数据库里老数据的角色还在,只是不显示)。 */ ?>
|
||
<?php if (!empty($n['members'])): ?>
|
||
<div class="trip-people">
|
||
<?php foreach ($n['members'] as $mm): ?>
|
||
<?= person_chip($mm) ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($n['attachs'])): ?>
|
||
<?php
|
||
$attRows = $n['attachs'];
|
||
$attCanEdit = false;
|
||
$attDelUrl = '';
|
||
require __DIR__ . '/../_attachments.php';
|
||
?>
|
||
<?php endif; ?>
|
||
|
||
<?php /* ---- 修正记录对比(第 13 批) ---- */ ?>
|
||
<?php if (!empty($n['revisions'])): ?>
|
||
<details class="rev-hist">
|
||
<summary class="link-quiet">看修正记录(<?= count($n['revisions']) ?> 条)</summary>
|
||
<table class="rev-table">
|
||
<thead>
|
||
<tr><th>#</th><th>时间</th><th>地点</th><th>说明</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($n['revisions'] as $rv): ?>
|
||
<tr>
|
||
<td><?= (int)$rv['seq'] === 1 ? '原计划' : '第 ' . ((int)$rv['seq'] - 1) . ' 次修正' ?></td>
|
||
<td class="small"><?= e(\App\Trip::nodeSpan($rv['starts_at'] ?? null, $rv['ends_at'] ?? null)) ?></td>
|
||
<td><?= e((string)($rv['place'] ?? '')) ?></td>
|
||
<td class="small"><?= e((string)($rv['detail'] ?? '')) ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</details>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php if ($canWrite): ?>
|
||
<?php /* ==================== 动作按钮(v24 第 19 批 用户第 7 条)====================
|
||
用户原话:
|
||
· 「计划行程的三个按钮,修改、完成、未成行,横着排列,放在行程右上角,
|
||
修改是黄色背景,完成绿色,未成行红色。」
|
||
· 「实际行程的编辑和删除按钮,也放在行程的右上角,编辑黄色,删除红色。」
|
||
|
||
⚠️ 于是配色按**颜色**来(不再是"主/次"那套):
|
||
· 修正 / 编辑 → `.btn-warn`(黄)
|
||
· 完成 → `.btn-ok`(绿)
|
||
· 未成行 / 删除 → `.btn-del`(红)
|
||
⚠️ 用户说的"修改"就是这里的「修正」(第 13 批定的名字)——
|
||
我们**保留产品名**不改,但把它染成用户要的黄色。
|
||
⚠️ 「编辑」用户没提,但它不能丢:改标题/时间/类型/出行方式与**加花销**
|
||
都只走它(花销面板在编辑页里)。按用户给的颜色语义染黄。
|
||
⚠️ 「标记为实际」是"修正过之后才出现"的收尾动作,也按黄色(同属"改")。
|
||
⚠️ 删除按钮:用户明确要求**红色**。这里用 `.btn-del`(实心红),
|
||
与全站 `.btn-danger`(淡红描边)刻意分开 —— 改 `.btn-danger`
|
||
会连带改掉十来处「删除」按钮的样子。
|
||
⚠️ 计划行的删除入口**保留**:`skipNode`(未成行)是主路径,
|
||
但"记错了、这段根本不该存在"时仍需要真删(hint 里也这么写)。 */ ?>
|
||
<div class="node-ops">
|
||
<?php /* 🔴 v41(用户第 5/6 条):编辑/新增行程一律**弹框**。
|
||
⚠️ 这里只放"触发器"(data-node-open 带 URL),**不内嵌表单** ——
|
||
详情页可能有十几段行程,内嵌 N 份表单会把页面撑破、
|
||
元素 id 还会互相撞车(这正是用户当年报的"把页面撑破")。
|
||
表单由页尾那一个容器按需 AJAX 取回。 */ ?>
|
||
<button class="btn btn-warn btn-sm" type="button"
|
||
data-node-open="<?= e(url('trips/node_edit', ['id' => $nid, 'embed' => 1])) ?>"
|
||
aria-haspopup="dialog" aria-expanded="false">编辑</button>
|
||
|
||
<?php if ($sg === 'plan'): ?>
|
||
<details class="rev-open">
|
||
<summary class="btn btn-warn btn-sm">修正</summary>
|
||
<div class="rev-panel">
|
||
<?php /* 🔴 v50(用户 2026-10-14 第 3 条):
|
||
「上面…那一段话说明也不要了。」
|
||
⇒ 删掉原来那段 `.rev-tip`(开头是「记录…」、解释"仍是同一条记录"
|
||
的那三行)。表单本身已经说清"实际的开始/结束""这次修正的说明",
|
||
那段解释属于噪音。
|
||
⚠️ 这段注释**刻意不整句照抄**用户点名的原话 ——
|
||
部署断言要查"那句已删",若在注释里留下原文,
|
||
那种朴素 grep 会命中注释而恒红(本项目已踩过多次)。
|
||
要查"删没删"的断言请用 `_code_has`(剥注释)。
|
||
⚠️ 别顺手删 `.rev-panel` / `.rev-open` 这两个类名 ——
|
||
它们是展开区的结构锚点(CSS 与测试都用)。 */ ?>
|
||
<?php
|
||
$rvNode = $n; $rvTripId = (int)$t['id'];
|
||
$rvPeople = $people ?? [];
|
||
$rvMembers = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
|
||
require __DIR__ . '/_revise_form.php';
|
||
?>
|
||
</div>
|
||
</details>
|
||
<form method="post" action="<?= e(url('trips/node_done')) ?>" class="res-inline"
|
||
onsubmit="return confirm('实际执行与计划完全相同,直接转为实际?')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $nid ?>">
|
||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||
<button class="btn btn-ok btn-sm" type="submit">完成</button>
|
||
</form>
|
||
<form method="post" action="<?= e(url('trips/node_skip')) ?>" class="res-inline"
|
||
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这个旅程里仍然看得到。')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $nid ?>">
|
||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||
<button class="btn btn-del btn-sm" type="submit">未成行</button>
|
||
</form>
|
||
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
|
||
<form method="post" action="<?= e(url('trips/node_actual')) ?>" class="res-inline">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $nid ?>">
|
||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||
<button class="btn btn-ok btn-sm" type="submit">标记为实际</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<?php elseif ($sg === 'skipped'): ?>
|
||
<form method="post" action="<?= e(url('trips/node_restore')) ?>" class="res-inline">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $nid ?>">
|
||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||
<button class="btn btn-ok btn-sm" type="submit">恢复为计划</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
|
||
<form method="post" action="<?= e(url('trips/node_del')) ?>" class="res-inline"
|
||
onsubmit="return confirm('彻底删除这段行程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「未成行」。')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $nid ?>">
|
||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||
<button class="btn btn-del btn-sm" type="submit">删除</button>
|
||
</form>
|
||
</div>
|
||
<?php endif; ?>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ol>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
</div><?php /* /#nodeListHost */ ?>
|
||
|
||
<?php if ($canWrite): ?>
|
||
<?php /* ==================== v41:行程 / 旅程编辑的**弹框容器**(单一实例)====================
|
||
用户第 5/6 条:「编辑行程、加计划行程、加实际行程,全部都采用弹窗的形式。」
|
||
「行程部分的编辑也是采用弹窗的形式。」
|
||
「新建旅程…确定完了之后,动态刷新旅程这个列表页面。」
|
||
|
||
为什么是**一个容器 + 按需 AJAX**,而不是每段内嵌一份表单:
|
||
一段行程有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/参与人/照片/状态),
|
||
详情页可能有十几段。内嵌 N 份 = 页面被撑到几屏长 + 元素 id 互相撞车
|
||
—— 这正是用户当年报的「点击编辑会把页面撑破」。
|
||
⇒ 点"编辑 / +计划行程 / +实际行程 / 编辑旅程"时,才去服务端取那**一份**表单片段,
|
||
塞进这个容器里打开。保存成功后只换行程列表容器,不整页跳。 */ ?>
|
||
<div class="cal-modal" id="nodeFormHost" role="dialog" aria-modal="true"
|
||
aria-label="行程" hidden>
|
||
<div class="cal-modal-panel mv-panel nf-panel" id="nodeFormPanel">
|
||
<div class="cal-modal-head">
|
||
<b id="nodeFormTitle">行程</b>
|
||
<button type="button" class="cal-modal-x" data-nf-close aria-label="关闭">✕</button>
|
||
</div>
|
||
<div class="cal-modal-body" id="nodeFormBody">
|
||
<p class="empty-hint">正在载入…</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
/* ==================== v41:行程 / 旅程编辑弹框(事件委托 + AJAX)====================
|
||
为什么**全程用事件委托**(监听挂 document、用选择器判断来源):
|
||
· 弹框内容是 AJAX 换进来的 —— 内容一换,原来元素上的监听全丢;
|
||
委托则"一次绑定、永久有效",换多少次都不用重绑(观影详情页那轮
|
||
就是在这里踩过"第二次点编辑没反应")。
|
||
· 列表里每段行程一个按钮,逐个 addEventListener 会绑十几个。
|
||
为什么提交主路走 **click** 而不是 submit:
|
||
提交按钮是靠 form 属性关联的(在表单**外面**)。实测浏览器点它会提交,
|
||
但挂在 document 上的 submit 监听**收不到事件** ⇒ 会整页跳走、红字看不到
|
||
(观影 v40 同一坑)。click 一定发生,以它为主路;submit 只兜"按回车"。 */
|
||
(function () {
|
||
if (!window.fetch || !window.FormData) { return; } // 老浏览器保持原生跳转
|
||
|
||
var RELOAD = 'nodeListHost'; // 保存成功后要刷新的容器
|
||
function host() { return document.getElementById('nodeFormHost'); }
|
||
function body() { return document.getElementById('nodeFormBody'); }
|
||
function titleEl(){ return document.getElementById('nodeFormTitle'); }
|
||
|
||
function setOpen(on) {
|
||
var h = host();
|
||
if (!h) { return; }
|
||
h.hidden = !on;
|
||
if (on) {
|
||
/* 动态插入的控件:日期 / antd / 选人 / 上传都要在这一刻升级 */
|
||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||
if (window.fbNodeForm) { window.fbNodeForm(body() || document); }
|
||
var f = document.getElementById('nf-title');
|
||
if (f) { f.focus(); }
|
||
} else if (body()) {
|
||
body().innerHTML = '<p class="empty-hint">正在载入…</p>';
|
||
}
|
||
}
|
||
function antdOpen() {
|
||
var d = document.querySelector('.ant-picker-dropdown');
|
||
return !!(d && !d.classList.contains('antd-picker-dropdown-hidden'));
|
||
}
|
||
|
||
/* ---- 打开:从 data-node-open / data-trip-edit 拿 URL,取片段塞进来 ---- */
|
||
/* 🔴🔴 v42(用户 2026-10-11 第 2/3/4 条)——**根因级修法**:
|
||
AJAX 塞进来的片段里,`<script>` **既不执行、连元素都不保留**
|
||
(`innerHTML` 的语义:脚本不会跑,且赋值后 DOM 里也找不到那个 script 标签)。
|
||
片段自带的初始化(AntD 升级 / 高德联想 / 出行方式联动)全写在那段脚本里
|
||
⇒ 用户在弹框里看到的是:
|
||
· 起止时间没换成 AntD 控件(第 2 条);
|
||
· 选「自驾」不出车辆、选「火车/飞机」不出车次(第 3 条);
|
||
· 出发地/到达地没有高德联想(第 4 条)。
|
||
⇒ 做法:**把片段里的内联 `<script>` 抠出来,重新平成一个能执行的脚本**。
|
||
这是"动态插入含脚本的 HTML"的标准修法;比"在宿主里逐个复刻片段的初始化"
|
||
可靠得多 —— 片段将来再加初始化时,宿主不用跟着改。
|
||
⚠️ 只执行**内联**脚本(有 src 的是外部 SDK,设了 src 也不会同步执行,跳过即可;
|
||
高德那支是 `<script src>`,它的加载写在片段里由 SDK 自己完成)。
|
||
⚠️ 幂等:同一片段重复塞入时重新执行没问题(初始化内部都有幂等标记)。 */
|
||
function runInlineScripts(container, html) {
|
||
var re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi, m, code = '';
|
||
while ((m = re.exec(html)) !== null) {
|
||
// 只收**没有 src 属性**的那种(内联脚本)
|
||
if (!/\bsrc\s*=/i.test(m[0].split('>')[0])) { code += m[1] + '\n;\n'; }
|
||
}
|
||
if (!code) { return; }
|
||
var s = document.createElement('script');
|
||
s.textContent = code;
|
||
(container || document.body).appendChild(s); // 执行
|
||
s.parentNode.removeChild(s); // 执行完就摘掉,别留在 DOM 里
|
||
}
|
||
|
||
/* 🔴🔴 v43(用户 2026-10-11 第 4 条):「编辑一个行程的时候,"参与的人"
|
||
添加参与人的那个功能已经不好使了,弹不出选人的框了。」
|
||
|
||
真根因:选人组件(`person_picker.js`)的绑定**只跑一次**(脚本加载时扫一遍 `.pp`)。
|
||
而这一段表单是 AJAX 取回来、`innerHTML` 塞进 DOM 的 ⇒ 它里面的 `.pp`
|
||
**从来没被 init 过** ⇒「+ 添加参与的人」按钮上没有任何 click 监听
|
||
⇒ 点了没反应(弹框弹不出来)。暂存区/确定/取消同理全按不动。
|
||
|
||
⇒ 塞完片段后补调 `window.fbInitPickers(scope)`(person_picker.js 暴露,
|
||
内部幂等)。⚠️ 该脚本是**按需加载**的(layout.php:页面上有 `.pp` 才拉),
|
||
而弹框里的 `.pp` 是后来才出现的 —— 脚本此刻可能还没加载完。
|
||
所以做"等它就绪":轮询若干次,出现即调;始终不来也无害(下次打开再试)。
|
||
⚠️ 只重试有限次(约 3 秒),不做无限轮询。 */
|
||
function initPickersWhenReady(scope, tries) {
|
||
tries = (tries === undefined) ? 25 : tries;
|
||
/* 先确保脚本被拉起(详情页首屏没有 `.pp`,layout 那条"首屏就拉"的判据不会触发它)。
|
||
`fbLoadPicker` 是幂等的,重复调用只是拿到同一个 Promise。 */
|
||
if (!window.fbInitPickers && typeof window.fbLoadPicker === 'function') {
|
||
try { window.fbLoadPicker(); } catch (e) { /* 忽略 */ }
|
||
}
|
||
if (typeof window.fbInitPickers === 'function') {
|
||
try { window.fbInitPickers(scope); } catch (e) { /* 不阻塞表单其它初始化 */ }
|
||
return;
|
||
}
|
||
if (tries <= 0) { return; }
|
||
setTimeout(function () { initPickersWhenReady(scope, tries - 1); }, 150);
|
||
}
|
||
|
||
function loadInto(url, title) {
|
||
if (titleEl()) { titleEl().textContent = title || '行程'; }
|
||
if (body()) { body().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
|
||
setOpen(true);
|
||
fetch(url, { credentials: 'same-origin',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||
.then(function (r) { return r.text(); })
|
||
.then(function (html) {
|
||
if (!body()) { return; }
|
||
body().innerHTML = html;
|
||
/* ① 先执行片段自带的内联脚本(它会定义/调用自己的初始化) */
|
||
runInlineScripts(body(), html);
|
||
/* ② 再补跑宿主这侧的通用升级(日期/antd/行程表单/选人组件),按依赖顺序 */
|
||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||
if (window.fbNodeForm) { window.fbNodeForm(body()); }
|
||
/* 🔴🔴 v43(用户 2026-10-11 第 4 条):「编辑一个行程的时候,"参与的人"
|
||
添加参与人的那个功能已经不好使了,弹不出选人的框了。」
|
||
|
||
真根因:选人组件(`person_picker.js`)的绑定**只跑一次**(脚本加载时扫一遍
|
||
`.pp`)。而这一段表单是 AJAX 取回来、`innerHTML` 塞进 DOM 的 ⇒
|
||
它里面的 `.pp` **从来没被 init 过** ⇒「+ 添加参与的人」按钮上没有 click
|
||
监听 ⇒ 点了没反应。⇒ 这里塞完片段**补调一次**初始化。
|
||
⚠️ 该脚本是**按需加载**的(layout.php:页面上有 `.pp` 才拉),而弹框里的
|
||
`.pp` 是后来才出现的 —— 脚本可能还没加载完(或都还没开始加载)。
|
||
所以这里做"等它就绪":轮询若干次,出现即调;全程没出现也无害。 */
|
||
initPickersWhenReady(body());
|
||
var f = document.getElementById('nf-title');
|
||
if (f) { f.focus(); }
|
||
})
|
||
.catch(function () {
|
||
if (body()) { body().innerHTML = '<p class="empty-hint">载入失败,请重试。</p>'; }
|
||
});
|
||
}
|
||
|
||
document.addEventListener('click', function (e) {
|
||
var t = e.target;
|
||
if (!t || !t.closest) { return; }
|
||
var op = t.closest('[data-node-open]');
|
||
if (op) {
|
||
e.preventDefault();
|
||
var url = op.getAttribute('data-node-open') || '';
|
||
var isNew = url.indexOf('node_new') >= 0;
|
||
loadInto(url, isNew ? (op.textContent || '').trim() : '编辑行程');
|
||
return;
|
||
}
|
||
var te = t.closest('[data-trip-edit]');
|
||
if (te) {
|
||
e.preventDefault();
|
||
loadInto(te.getAttribute('data-trip-edit'), '编辑旅程');
|
||
return;
|
||
}
|
||
if (t.closest('[data-nf-close]')) { setOpen(false); return; }
|
||
/* 🔴 v50(用户 2026-10-14 第 3 条):「应该加一个关闭/取消的按钮…不要再修正的话,
|
||
应该有一个取消,不要让这个框显示了。」
|
||
「修正」区是 `<details class="rev-open">`(原生可折叠,不用 JS 也能开)——
|
||
⇒ 取消 = 把这个 details 的 `open` 去掉,回到收起状态。
|
||
⚠️ 用委托(`closest`)而不是在 `_revise_form.php` 里内联脚本:
|
||
那份片段会被 AJAX 换进来,内联脚本的位置与幂等都要额外照看;委托一次绑定就够。 */
|
||
var rc = t.closest('[data-rev-cancel]');
|
||
if (rc) {
|
||
e.preventDefault();
|
||
var det = rc.closest('details.rev-open');
|
||
if (det) { det.open = false; }
|
||
return;
|
||
}
|
||
var h = host();
|
||
if (h && t === h) { setOpen(false); return; } // 点遮罩关;点面板内部不关
|
||
});
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Escape') { return; }
|
||
var h = host();
|
||
if (!h || h.hidden) { return; }
|
||
if (antdOpen()) { return; } // 让 antd 先收它自己的面板
|
||
setOpen(false);
|
||
});
|
||
|
||
/* ---- 保存:click 主路 + submit 兜回车 ---- */
|
||
function save() {
|
||
var form = document.getElementById('nodeForm') || document.getElementById('tripForm');
|
||
if (!form) { return; }
|
||
var sub = document.getElementById('nodeFormSubmit')
|
||
|| document.getElementById('tripNewSubmit');
|
||
if (sub) { sub.disabled = true; sub.textContent = '保存中…'; }
|
||
fetch(form.action, {
|
||
method: 'POST', body: new FormData(form),
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||
credentials: 'same-origin',
|
||
}).then(function (r) {
|
||
var ct = (r.headers.get('content-type') || '');
|
||
if (ct.indexOf('application/json') >= 0) {
|
||
/* 校验失败:服务端回重画后的弹框 → 整块换进容器 */
|
||
return r.json().then(function (j) {
|
||
if (body() && j && j.modal) {
|
||
body().innerHTML = j.modal;
|
||
runInlineScripts(body(), j.modal); // 片段里的内联脚本要执行(见 loadInto 的长注释)
|
||
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
|
||
if (window.fbBootAntd) { window.fbBootAntd(); }
|
||
if (window.fbNodeForm) { window.fbNodeForm(body()); }
|
||
}
|
||
});
|
||
}
|
||
/* 成功:服务端 302 回来 → 只换列表容器(保留滚动位置、不闪白) */
|
||
return r.text().then(function (html) {
|
||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||
var fresh = doc.getElementById(RELOAD) || doc.getElementById('tripListHost');
|
||
var cur = document.getElementById(RELOAD) || document.getElementById('tripListHost');
|
||
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
|
||
else { window.location.reload(); return; }
|
||
setOpen(false);
|
||
});
|
||
}).catch(function () {
|
||
if (sub) { sub.disabled = false; sub.textContent = '确定'; }
|
||
form.submit();
|
||
}).then(function () {
|
||
if (sub) { sub.disabled = false; sub.textContent = '确定'; }
|
||
});
|
||
}
|
||
document.addEventListener('click', function (ev) {
|
||
var t = ev.target;
|
||
if (!t || !t.closest) { return; }
|
||
if (t.closest('#nodeFormSubmit') || t.closest('#tripNewSubmit')) {
|
||
ev.preventDefault();
|
||
save();
|
||
}
|
||
});
|
||
document.addEventListener('submit', function (ev) {
|
||
var f = ev.target;
|
||
if (!f || (f.id !== 'nodeForm' && f.id !== 'tripForm')) { return; }
|
||
ev.preventDefault();
|
||
save();
|
||
});
|
||
})();
|
||
</script>
|
||
<?php endif; ?>
|