Files
FamilyBook/templates/trips/form.php
T
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00

209 lines
12 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 $errors 校验错误
*/
$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; ?>