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。
This commit is contained in:
2026-10-11 10:12:11 +08:00
parent f9e30bd7d8
commit 7544fb9f6a
19 changed files with 1616 additions and 342 deletions
+91 -11
View File
@@ -48,6 +48,14 @@ 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 条)。服务端先按已存值决定初始显隐,
@@ -72,7 +80,39 @@ $toRow = [
'city' => (string)($n['to_city'] ?? ''),
];
$amap = \App\Geo::jsConfig();
/* 🔴🔴 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' ? '实际' : '计划') . '行程') ?>
@@ -85,9 +125,21 @@ $amap = \App\Geo::jsConfig();
<a class="btn btn-ghost" href="<?= e($back) ?>">返回旅程</a>
</div>
</div>
<?php endif; ?>
<div class="card card-pad">
<form method="post" enctype="multipart/form-data"
<?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'] ?>">
@@ -96,7 +148,12 @@ $amap = \App\Geo::jsConfig();
<?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">
@@ -131,14 +188,20 @@ $amap = \App\Geo::jsConfig();
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* ---- 起止时间:一个范围控件(精确到秒,用户第 17 批问"怎么不显示时分秒")---- */ ?>
<?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';
$dtId = 'nf-s'; $dtRange = 'start'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
$dtId = 'nf-e'; $dtRange = 'end';
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
</div>
@@ -302,14 +365,31 @@ $amap = \App\Geo::jsConfig();
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
</div>
</div>
</div><?php /* /.form-grid */ ?>
</div><?php /* /.mv-form-inner(⚠️ 只关这一次) */ ?>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加这段行程' ?></button>
<a class="btn btn-ghost" href="<?= e($back) ?>">取消</a>
</div>
<?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>
</div>
<?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"` 把按钮关联到这里,