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
+239 -33
View File
@@ -46,39 +46,41 @@ $groupMeta = [
<h1><?= e((string)$t['title']) ?></h1>
<?php /* ⚠️ 用户第 6 条:标题旁边**不放**时间。时间在下面题图右侧的信息区里。 */ ?>
<div class="actions">
<?php /* 用户第 19 批 第 2 条:「旅程详情页面,增加返回按钮,点击后返回到旅程列表。」
按全站规矩放在动作区最左(右起是:设置 → 删除 → 动作),
文案与观影页统一成「返回旅程」。 */ ?>
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
<?php if ($canWrite): ?>
<?php /* 用户第 6 条:添加计划行程 / 实际行程的按钮都放**右上角**,
点击打开**新页面**(第 5 条:不再内联,内联会把页面撑破)。 */ ?>
<a class="btn btn-ghost" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'plan'])) ?>">
+ 计划行程
</a>
<a class="btn" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'actual'])) ?>">
+ 实际行程
</a>
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
<?php /* 🔴 v24 第 21 批 第 6 条:**设置按钮必须排到最后**。
用户原话:「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
⚠️ 原来这里的顺序是「… → 设置(☰) → 删除(🗑)」—— 设置被删除键挤到了**倒数第二**,
所以它并**不在最右**(实测:动作区最右边缘贴着的是那个删除表单,
而设置按钮左边还空着一大截)。全站其它模块(观影 / 日程 / 任务 / 资源)
的约定都是「设置 ☰ 恒为动作区最后一个」,用户点惯了一个位置,
到这里却偏了一格,就是"不统一"的全部含义。
⚠️ 所以删除键挪到设置**前面**(它现在是倒数第二)。 */ ?>
<?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-ghost btn-icon" type="submit" title="删除这个旅程" aria-label="删除">🗑</button>
<button class="btn btn-del" type="submit" title="删除这个旅程">删除</button>
</form>
<?php /* 用户第 8 条:右上角一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。
全站规矩:设置只有图标(三条横线)、无文字、**恒在动作区最右**。 */ ?>
<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>
@@ -139,10 +141,12 @@ $groupMeta = [
</div>
</div>
<?php /* 参与人是谁:用户点名要("显示的是参与人都有谁") */ ?>
<?php /* 参与人是谁:用户点名要("显示的是参与人都有谁")。
🔴 v41(用户 2026-10-10):标题后面那个括号(以及括号里那句解释)不要了。
这个括号连带里面的内容都不要了。」⇒ 只留标题四个字。 */ ?>
<?php if ($people): ?>
<div class="trip-people-block">
<span class="small muted">参与的人(各段行程的合集)</span>
<span class="small muted">参与的人</span>
<div class="viewers-list">
<?php foreach ($people as $a): ?>
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
@@ -191,6 +195,11 @@ $groupMeta = [
$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>
@@ -235,7 +244,25 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<span class="rev-badge" title="已修正过多次">已修正 <?= (int)$n['revision_n'] ?> 次</span>
<?php endif; ?>
<?php if ($mode !== ''): ?>
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?></span>
<?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>
@@ -253,7 +280,9 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php
$traffic = [];
if (!empty($n['line_name'])) { $traffic[] = (string)$n['line_name']; }
if (!empty($n['trip_no'])) { $traffic[] = (string)$n['trip_no']; }
/* 🔴 v41 第 8 条:`trip_no` **不再**放这里 —— 它已经跟在
「火车 / 飞机」模式词后面了(见上面 `.node-mode-no`)。
留在这里会让车次号在**同一个行程行上出现两次**。 */
if (!empty($n['carrier'])) { $traffic[] = (string)$n['carrier']; }
?>
<?php if ($traffic): ?>
@@ -358,9 +387,14 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
⚠️ 计划行的删除入口**保留**:`skipNode`(未成行)是主路径,
但"记错了、这段根本不该存在"时仍需要真删(hint 里也这么写)。 */ ?>
<div class="node-ops">
<?php /* 用户第 5 条:**编辑行程打开新页面**(内联的编辑面板会把页面撑破) */ ?>
<a class="btn btn-warn btn-sm"
href="<?= e(url('trips/node_edit', ['id' => $nid])) ?>">编辑</a>
<?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">
@@ -424,4 +458,176 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
</ol>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?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="关闭">&#10005;</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(); }
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,取片段塞进来 ---- */
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;
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
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; }
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;
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
}
});
}
/* 成功:服务端 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; ?>