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
+156 -45
View File
@@ -91,58 +91,100 @@ $myPerson = (int)\App\Auth::personId();
</div>
<?php if ($canWrite): ?>
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
<h2 class="cinema-h2"><?= $isEditing ? '编辑资源' : '新建资源' ?></h2>
<form method="post" enctype="multipart/form-data"
<?php /* 🔴 v41(用户 2026-10-10 第 9 条):「添加资源,采用弹窗形式,并在新建资源的
时候,就确定共享人。排版和观影一样,前面是标题,后面跟着冒号,然后是各类输入框。
各类框,放在一个页面,包括共享人和附件,编辑也是一样,直接复用添加这个输入框即可。」
⇒ 三件事:
① 从"页面里可开合的一块"改成**弹框**(`#resAddCard` → `.cal-modal`);
② 排版改观影式(`.mv-row`:左标签固定 62px + 右对齐 + 自动冒号);
③ **共享人**与**附件**都放进这一张表单(原来共享只在那条的「共享」弹框里做)。
⚠️ `$isEditing` 时弹框**自动打开**(从列表点"编辑"进来就是要改它)。 */ ?>
<div class="cal-modal" id="resAddCard" role="dialog" aria-modal="true"
aria-label="<?= $isEditing ? '编辑资源' : '新建资源' ?>"
<?= ($isEditing || !empty($resOpen)) ? '' : 'hidden' ?>>
<div class="cal-modal-panel mv-panel">
<div class="cal-modal-head">
<b id="resAddTitle"><?= $isEditing ? '编辑资源' : '新建资源' ?></b>
<button type="button" class="cal-modal-x" id="resAddX" aria-label="关闭">&#10005;</button>
</div>
<form method="post" enctype="multipart/form-data" id="resForm"
action="<?= e(url($isEditing ? 'resources/update' : 'resources/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>归属于谁 *</label>
<?php /* v24 第 11 条:与全局选人组件统一(原来是下拉,名册一长就难找) */ ?>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
$ppMode = 'single';
$ppOpenBtn = '选择归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label for="rs-title">标题 *</label>
<input class="input" id="rs-title" name="title" required
value="<?= e($editing['title'] ?? '') ?>" placeholder="如:身份证 / 学位证">
</div>
<div class="field full">
<label for="rs-content">正文</label>
<textarea class="input" id="rs-content" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"><?= e($editing['content'] ?? '') ?></textarea>
</div>
<div class="field full">
<label for="sd-attach">附件(可多选)</label>
<?php /* ⚠️ 新建时资源还不存在,附件只能在这里一起提交(建完再挂)。
列表里那条的附件管理则走右上角蓝色的「附件」弹框。
两处的文件框 id 必须**不同**(同 id 会撞),所以这里显式给一个。 */
$sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="mv-form-inner">
<div class="mv-grid">
<div class="mv-row">
<span class="mv-lb">归属于谁<i>*</i></span>
<div class="mv-fill">
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
$ppMode = 'single';
$ppOpenBtn = '选择归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div>
</div>
<div class="mv-row">
<span class="mv-lb">标题<i>*</i></span>
<input class="input" id="rs-title" name="title" required
value="<?= e($editing['title'] ?? '') ?>" placeholder="如:身份证 / 学位证">
</div>
<div class="mv-row">
<span class="mv-lb">正文</span>
<textarea class="input" id="rs-content" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"><?= e($editing['content'] ?? '') ?></textarea>
</div>
<?php /* 🔴 v41:**共享给谁**(用户第 9 条"新建的时候就确定共享人")。
⚠️ 这里只给"只读" —— 新建时最多到只读;要给人可写,
去列表里那条资源的「共享」弹框单独设(那里能选权限)。
⚠️ 归属人自己会被服务端自动跳过(不会产生多余共享记录)。 */ ?>
<div class="mv-row">
<span class="mv-lb">共享</span>
<div class="mv-fill">
<?php
$ppName = 'share_ids[]';
$ppPeople = $people;
$ppSelected = array_map('intval', array_column(
$isEditing ? \App\Resource::shares((int)$editing['id']) : [], 'person_id'));
$ppMode = 'multi';
$ppOpenBtn = '+ 共享给谁';
$ppTitle = '这条资源共享给谁(只读)';
$ppTip = '归属人不用勾(本来就是他的)。要给人「可读写」,到列表里那条的「共享」里设。';
require __DIR__ . '/../_person_picker.php';
?>
</div>
</div>
<div class="mv-row mv-row-media">
<span class="mv-lb">附件</span>
<div class="mv-media-body">
<?php /* ⚠️ 新建时资源还不存在,附件只能在这里一起提交(建完再挂)。
列表里那条的附件管理则走右上角蓝色的「附件」弹框。
两处的文件框 id 必须**不同**(同 id 会撞),所以这里显式给一个。 */
$sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '新建' ?></button>
<?php if ($isEditing): ?>
<a class="btn btn-ghost" href="<?= e(url('resources')) ?>">取消</a>
<?php endif; ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="resAddTip"></span>
<button class="btn btn-sm" type="submit" form="resForm" id="resAddSubmit">确定</button>
<button class="btn btn-ghost btn-sm" type="button" id="resAddCancel">取消</button>
</div>
</div><?php /* /.cal-modal-panel */ ?>
</form>
</div>
<?php endif; ?>
<?php /* 🔴 v41:列表内容包一层 `#resListHost` —— 弹框保存成功后前端只换这一块。 */ ?>
<div id="resListHost">
<div class="card card-pad">
<h2 class="cinema-h2">
资源列表
@@ -458,19 +500,88 @@ $myPerson = (int)\App\Auth::personId();
</div>
<?php endif; ?>
</div><?php /* /#resListHost */ ?>
<script>
(function () {
/* ---- 「新建资源」开合(与旧版一致)---- */
/* ---- 🔴 v41(用户第 9 条):「新建资源」改成**弹框** ----
⚠️ 原来是"页面里可开合的一块"(点一下展开、再点收起)。
现在它与「编辑」共用同一个弹框(`#resAddCard`),
所以打开时要清空上次的输入(否则会带着上一次的内容)。
⚠️ 提交走 AJAX + 只换列表容器:用户没要求,但"弹框里点确定却整页跳走"
会丢掉滚动位置;既然列表页已经有 `#resListHost` 这样的容器就顺手接上。 */
var addBtn = document.getElementById('resAddBtn');
var addCard = document.getElementById('resAddCard');
function setResOpen(on) {
if (!addCard) { return; }
addCard.hidden = !on;
if (on) {
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('rs-title');
if (t) { t.focus(); }
}
}
if (addBtn && addCard) {
addBtn.addEventListener('click', function () {
addCard.hidden = !addCard.hidden;
addBtn.textContent = addCard.hidden ? '新建资源' : '收起';
if (!addCard.hidden) { addCard.scrollIntoView({ block: 'nearest' }); }
addBtn.addEventListener('click', function () { setResOpen(true); });
}
['resAddX', 'resAddCancel'].forEach(function (id) {
var el = document.getElementById(id);
if (el) { el.addEventListener('click', function () { setResOpen(false); }); }
});
if (addCard) {
addCard.addEventListener('click', function (e) {
if (e.target === addCard) { setResOpen(false); } // 点遮罩关
});
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' || !addCard || addCard.hidden) { return; }
var d = document.querySelector('.ant-picker-dropdown');
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; }
setResOpen(false);
});
/* 提交(click 主路 + submit 兜回车;理由见观影/旅程那两处注释) */
function resSave() {
var form = document.getElementById('resForm');
if (!form || !window.fetch || !window.FormData) { return false; }
var s = document.getElementById('resAddSubmit');
if (s) { s.disabled = true; s.textContent = '保存中…'; }
fetch(form.action, {
method: 'POST', body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
}).then(function (r) {
return r.text().then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = doc.getElementById('resListHost');
var cur = document.getElementById('resListHost');
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
else { window.location.reload(); return; }
setResOpen(false);
});
}).catch(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
form.submit();
}).then(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
});
return true;
}
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest || !t.closest('#resAddSubmit')) { return; }
if (resSave()) { ev.preventDefault(); }
});
document.addEventListener('submit', function (ev) {
if (ev.target && ev.target.id === 'resForm') {
if (resSave()) { ev.preventDefault(); }
}
});
})();
</script>
<script>
(function () {
/* ============================================================
动作 → 一份共享弹框(v24 第 23 批 用户第 1 条;
v28 第 7 条把「编辑 / 附件 / 共享」合成一个**带页签**的面板)