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:
+239
-33
@@ -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="关闭">✕</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; ?>
|
||||
|
||||
Reference in New Issue
Block a user