Files
FamilyBook/templates/trips/form.php
T
laoyang 81dfacaf1b v42 第 42 批:十一条反馈(界面 9 条 + CalDAV 两处新根因)
CalDAV(用户「还是不行,thunderbird 提示找不到日历」):
- 🔴 `.well-known/caldav` 原来**只回 301**。Thunderbird 建账号时常是
  **带鉴权直接 PROPFIND** 这个地址(把它当 DAV 服务入口探),而它对这一跳的
  301 **不跟随** ⇒ 判定"这里没有 CalDAV" ⇒ 找不到日历。
  ⇒ 按方法分流:带鉴权 PROPFIND → 直接 207 给发现内容(principal / home-set /
    日历本清单,一步到位);其余仍是 301。carddav 同修。
- 🔴 `Location` 由**相对** `/dav/` 改成**绝对 URL**(按真实 HTTP_HOST 拼,
  协议从 HTTPS / X-Forwarded-Proto 取)。TB 在服务发现这一跳对相对跳转不可靠。
- 线上终验:匿名 GET 301+绝对URL;带鉴权 PROPFIND 直接 207(含 principal/home-set);
  Depth:0 只回自身;Depth:1 列出 12 本日历。

界面九条:
- ① 新建旅程不再带入旧数据(真根因:`_list_body.php` 的 `foreach ($rows as $t)`
  循环后 `$t` 残留,而 `form.php` 读的正是 `$t` ⇒ 弹框被填上列表最后一趟的数据)。
  修:循环变量改 `$row` + `form.php` 契约兼容(`$tIn` 优先)。
- ②③④ 行程弹框里的 AntD 时间控件 / 出行方式联动车牌与车次 / 高德地址联想全部失效
  (真根因:AJAX 片段里的内联 `<script>` **既不执行也不保留**)。
  ⇒ 加 `runInlineScripts()` 把片段内联脚本抠出来执行;
  另修 `app.js`:`if (!hasMonth) return` 的短路让 `window.fbBootAntd` 从未被定义,
  把它提到所有 return 之前。
- ④b⑤ 标签改「照片」「时间范围」。
- ⑥ 简介补"还没有简介,点这里写一句"的占位(简介本身一直在,是那条 summary 为空)。
- ⑦ 资源编辑复用新建弹框(`#resAddCard`),并在其底栏补「共享」「附件」入口
  (第 7 条改动后这两个能力一度无入口)。
- ⑧ 纪念日「月/日」→「日期」+ AntD 纯日期控件(新增 mountAntdDate / [data-antd-date]),
  存储契约仍是 month + day。
- ⑨ 编辑纪念日弹框关不掉(关闭逻辑原来只在 days/index.php,而 days/edit 是整页)
  ⇒ 搬进 days/form.php 自包含。

验证:e2e 1510/0;v42 真机 33/0;DAV·TB 35/0;DAV 反向 8/0;
b23 55/0;b28 39/0;b32 26/0;b32 反向 5/0;v31 DAV 28/0;b31 反向 8/0;
部署断言空跑 337/0;完整流水线 PUSH_RC=0。
2026-10-11 18:35:03 +08:00

221 lines
13 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 批精简;**v41 按观影模块的排版重做**)。
*
* ⚠️ 术语(用户第 9 条):这一层是**旅程**(容器),里面一段一段的是**行程**。
*
* 🔴🔴 v41(用户 2026-10-10 第 2 条)原话:
* 「旅程模块,新建旅程也采用弹窗的方式。排版的方式和观影模式里面记一下一部电影类似。
* 左侧是标题冒号,右侧是输入框。题图也是一样的,把相关的说明放在题图的那个框里边。
* 一段介绍也是一样的。公开这个部分,下面的说明去掉吧,什么不勾等于自己留着看什么的,
* 这一段东西都去掉。只需要显示在旅程列表与日历里显示这个旅程的这个复选框就可以了,
* 也是一样的,公开冒号,后面是那个复选框,两个在一行里边。下面的按钮就是确定和取消两个。
* 确定就不用说了,就是添加这个旅程。如果是取消的话,就直接关闭这个弹窗。
* 确定完了之后,动态刷新旅程这个列表页面。」
*
* ⇒ 四条落实:
* ① **排版**:整表单改用观影那套 `.mv-row`(左标签固定 62px + 右对齐 + 自动冒号;
* 控件占满剩余宽度)—— 不再用"标签一行、输入框一行"的 `.field` 堆叠。
* ② **题图/简介的说明文字**:搬进各自的框里(题图提示移到 `.cover-picker` 内部;
* 简介不再另起一行写 tip)。
* ③ **公开**:把那一整句说明("不勾等于只留着自己看"之类)**整段删掉**,
* 只留「公开:☑ 在旅程列表与日历里显示这个旅程」**一行**。
* ④ **按钮**:确定 / 取消(取消 = 关弹窗,见本文件的页内脚本)。
*
* ⚠️ 表单本身**既能当整页也能当弹框**(`$tpModal` 控制):
* · 列表页内嵌 → 弹框(用户第 2 条要的就是这个);
* · 整页直接访问 `trips/new`(含校验失败重画)→ 仍然整页,
* 否则用户提交失败后跳到这一页只会看到一张空白(提示全在弹框里)。
*
* @param array|null $t 编辑时的旅程;null = 新建
* @param array|null $tIn 【列表页内嵌时用】同上语义
* @param array $errors 校验错误
*
* 🔴🔴 v42(用户 2026-10-11 第 1 条):「新建旅程,把数据带进来了。」
* 真根因:`trips/index.php` 用 `$tIn`(=null)把"编辑数据"传进来,
* 而这份模板读的是 `$t` —— 偏偏 `trips/_list_body.php` 里有个
* `foreach ($rows as $t)`,**循环结束后 `$t` 残留为列表里最后一个旅程**!
* ⇒ 新建弹框被填上了那趟旅程的标题/简介/封面。
* ⇒ 修法(两道保险,且不破坏路由那条路径):
* ① 契约兼容:列表页显式传了 `$tIn` 时**以它为准**(那份才是"要编辑的");
* 路由(trips/new、trips/edit)走的仍是 `$t`,原样不动;
* ② 列表页那边把 `_list_body.php` 的循环变量改名,**不再残留 `$t`**。
*/
if (isset($tIn)) { $t = $tIn; unset($tIn); }
$isEdit = !empty($t);
$canWrite = \App\Auth::canWrite();
$cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900,
'label' => '题图', 'hint' => ''];
$hasCover = $isEdit && !empty($t['cover_key']);
/* 弹框形态的开关(与观影 form.php 同一套命名习惯):
$tpModal —— 是否渲染成弹框壳(含遮罩/标题栏)
$tpModalOnly —— 被列表页内嵌(初始关闭,等用户点"新建旅程"才开)
$tpOpen —— 初始是否打开(整页直达 / AJAX 校验失败重画时为 true) */
$tpModal = !isset($tpModal) ? true : (bool)$tpModal; // v41:默认就是弹框形态
$tpModalOnly = !empty($tpModalOnly);
$tpOpen = !empty($tpOpen);
$tpReload = (string)($tpReload ?? '');
/* 🔴🔴 `$tpEmbed`(v41):**只吐表单片段**(详情页弹框 fetch 用)。
⇒ 不输出页头、也不输出任何外层容器 —— 壳由详情页的 `#nodeFormHost` 提供。
片段再自带一层弹框壳会变成"弹框里套弹框"(遮罩叠遮罩、标题栏双份)。
`$tpShell`:本模板**是否输出外层容器**(片段形态 = false;整页/列表内嵌 = true)。
⚠️ 外壳**类型**仍由 `$tpModal` 决定(true→`.cal-modal`,false→`.card`)。
收尾必须用同一对判据,否则"开头开 .card、结尾收 .cal-modal"→ 深度不配平
(2026-10-10 实测被 _fb_divdepth.py 当场抓到)。 */
$tpEmbed = !empty($tpEmbed);
$tpShell = !isset($tpShell) ? !$tpEmbed : (bool)$tpShell;
?>
<?php if (!$tpEmbed && !$tpModal): ?>
<div class="page-head">
<h1><?= $isEdit ? '编辑旅程' : '新建旅程' ?></h1>
<span class="sub">旅程里装行程;参与人、城市、天数、花销都从行程里汇总</span>
<div class="actions">
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回</a>
</div>
</div>
<?php endif; ?>
<?php if ($tpShell): ?>
<div class="<?= $tpModal ? 'cal-modal' : 'card card-pad' ?>"<?php
if ($tpModal) {
echo ' id="tripNewCard" role="dialog" aria-modal="true" aria-label="' . ($isEdit ? '编辑旅程' : '新建旅程') . '"'
. ($tpOpen ? '' : ' hidden');
} ?>>
<?php if ($tpModal): ?>
<div class="cal-modal-panel mv-panel">
<div class="cal-modal-head">
<b><?= $isEdit ? '编辑旅程' : '新建旅程' ?></b>
<button type="button" class="cal-modal-x" id="tripNewX" aria-label="关闭">&#10005;</button>
</div>
<?php endif; ?>
<?php endif; ?>
<form method="post" enctype="multipart/form-data" id="tripForm"
class="<?= $tpModal ? 'mv-modal-form' : '' ?>"
action="<?= e(url($isEdit ? 'trips/update' : 'trips/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php endif; ?>
<?php if ($tpModal && !$isEdit): ?>
<?php /* 从列表页弹框新建成功 → 回到**列表页**并刷新(用户第 2 条:
"确定完了之后,动态刷新旅程这个列表页面")。 */ ?>
<input type="hidden" name="back" value="trips">
<?php endif; ?>
<div class="mv-form-inner">
<div class="mv-grid">
<?php /* ==================== ① 标题(左标签 + 右输入框,观影同款)==================== */ ?>
<div class="mv-row">
<span class="mv-lb">旅程名<i>*</i></span>
<input class="input" id="t-title" name="title" required autofocus
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛之行 · 2026 暑假">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* ==================== ② 题图(说明搬进框里)====================
⚠️ data-pick-input 让 app.js 的通用逻辑接管"点图 = 开文件框" ——
少了它会"看着能点、点了没反应"(第 15 批用户报过的那个 bug)。
🔴 v41:用户要求"把相关的说明放在题图的那个框里边" ⇒
这段 `.cover-picker-hint` 现在**在** `.cover-picker` 内部(原来在外面)。 */ ?>
<div class="mv-row mv-row-media">
<span class="mv-lb">题图</span>
<div class="mv-media-body">
<div class="cover-picker">
<img id="cover-preview"
src="<?= e($hasCover ? media_url((string)$t['cover_key']) : '') ?>"
alt="" title="点击选择题图"
data-pick-input="#t-cover"
data-has-cover="<?= $hasCover ? '1' : '0' ?>">
<input class="sr-only" id="t-cover" name="cover" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$cvSlot['ratio']) ?>"
data-crop-w="<?= (int)$cvSlot['w'] ?>"
data-crop-h="<?= (int)$cvSlot['h'] ?>"
data-crop-label="<?= e((string)$cvSlot['label']) ?>"
data-crop-hint="<?= e((string)$cvSlot['hint']) ?>"
data-crop-preview="#cover-preview">
<?php /* 用户挑的裁剪框(归一化 0..1);没裁就留空 = 服务端按 16:9 居中裁 */ ?>
<input type="hidden" name="cover_crop" value="">
<span class="cover-picker-hint">
点击或拖动进行上传<br>
16:9 宽幅横图,详情页也按 16:9 显示<?php if ($hasCover): ?>;不换图就不用管<?php endif; ?>
</span>
</div>
</div>
</div>
<?php /* ==================== ③ 一段介绍(观影的"简介"同款:左标签 + 右多行框)==================== */ ?>
<div class="mv-row">
<span class="mv-lb">一段介绍</span>
<textarea class="input" id="t-sum" name="summary" rows="3"
placeholder="这趟玩得怎么样、为什么想记下来"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
</div>
<?php /* ---- 🔴 v41:公开 = **一行**(左标签 + 右复选框),
下面的说明文字按用户要求**整段删除**(只留复选框那一行)。
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",而不是"这个字段没出现"
(后者会被当成"没传"而默认公开,于是"取消勾选"看起来毫无作用)。 ---- */ ?>
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
<div class="mv-row">
<span class="mv-lb">公开</span>
<label class="chk-inline">
<input type="hidden" name="is_public" value="0">
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
<span>在旅程列表与日历里显示这个旅程</span>
</label>
</div>
<?php if ($isEdit): ?>
<?php
/* 编辑时把"已经算出来什么"摆出来:不显示的话,改完标题保存会以为
"我把参与人/时间弄丢了"。 */
$tSpan = \App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null);
$tCity = (string)($t['city'] ?? '');
$tN = count((array)($t['attendees'] ?? []));
?>
<p class="form-note">
当前:<b><?= e($tSpan) ?></b><?php if ($tCity !== ''): ?> · 去过 <b><?= e($tCity) ?></b><?php endif; ?>
<?php if ($tN > 0): ?> · <b><?= $tN ?></b> 人参与<?php endif; ?>
<br>这些都是从各段行程算出来的,这里改不了 —— 要改就去改对应的那段行程。
</p>
<?php endif; ?>
</div><?php /* /.mv-grid */ ?>
</div><?php /* /.mv-form-inner —— ⚠️ 只关这一次! */ ?>
<?php /* ⚠️🔴 DOM 嵌套必须**按分支配对**:
① 按钮行:弹框壳 / 片段自带 / 整页 btn-row 三种;
② 收尾:`.cal-modal-panel` 只在 `$tpModal` 时开过 ⇒ **也只在它时才关**。 */ ?>
<?php if ($tpShell && $tpModal): ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="tripNewTip"></span>
<button class="btn btn-sm" type="submit" form="tripForm" id="tripNewSubmit">确定</button>
<button class="btn btn-ghost btn-sm" type="button" id="tripNewCancel">取消</button>
</div>
</div><?php /* /.cal-modal-panel —— 与开头 `if ($tpModal)` 严格对应 */ ?>
<?php elseif ($tpEmbed): ?>
<?php /* 🔴 片段形态(详情页弹框 fetch 进来的):**自带按钮行**。
详情页那个容器的 `.cal-modal-foot` 是给"行程表单"准备的、
按钮 id 也是行程那套;这里自带一份,最不容易接错。 */ ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="tripNewTip"></span>
<button class="btn btn-sm" type="submit" form="tripForm" id="tripNewSubmit">确定</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="tripNewSubmit"><?= $isEdit ? '确定' : '建好,去加行程' ?></button>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">取消</a>
</div>
<?php endif; ?>
</form>
<?php /* ⚠️🔴 收尾必须与开头**严格一一对应**(2026-10-10 反复踩到的就是这里):
· 弹框(tpShell && tpModal):开头开了 `.cal-modal` → `.cal-modal-panel`
(panel 已在 `</form>` 之前关掉了)⇒ 这里只关 `.cal-modal` **一个**;
· 整页(tpShell && !tpModal):开头开的是 `.card` ⇒ 关一个;
· 片段(!tpShell):开头什么外层都没开 ⇒ 一个都不关。 */ ?>
<?php if ($tpShell): ?>
</div><?php /* 关掉开头那个外层(.cal-modal 或 .card) */ ?>
<?php endif; ?>