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
+117 -6
View File
@@ -3394,6 +3394,10 @@ switch ($r) {
'presetAttendees' => [(int)Auth::personId()],
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [],
/* 🔴 v41:直达 `trips/new` 时也渲染成**打开着的弹框**。
不打开的话,用户看到的是一张"空白页面"(内容全在 hidden 的弹框里)。 */
'tpModal' => true,
'tpOpen' => true,
], 'trips');
break;
@@ -3405,12 +3409,24 @@ switch ($r) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
View::render('trips/form', [
'title' => '编辑旅程',
/* 🔴 v41:详情页的「编辑」按钮也改弹框(用户第 4 条把那排按钮列全了,
第 5/6 条要求弹窗化)。`?embed=1` → 只吐表单片段,由详情页弹框容器承载。 */
$tpEmbed = (string)($_GET['embed'] ?? '') === '1';
$tpArgs = [
't' => $t,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'trips');
'tpEmbed' => $tpEmbed,
'tpModal' => !$tpEmbed,
'tpOpen' => true,
'tpReload' => 'tripListHost',
];
if ($tpEmbed) {
header('Content-Type: text/html; charset=utf-8');
echo View::renderPartial('trips/form', $tpArgs);
exit;
}
View::render('trips/form', ['title' => '编辑旅程'] + $tpArgs, 'trips');
break;
case 'trips/create':
@@ -3479,11 +3495,33 @@ switch ($r) {
}
if ($errors) {
set_old($_POST);
/* 🔴 v41(用户 2026-10-10):新建旅程改弹框后,**校验失败**要
把这同一个弹框重画回去(而不是整页跳走)。
判据用 `X-Requested-With`(与观影详情页编辑同一套约定),
回 200 + JSON `{ok:false, modal:'<片段>'}`。 */
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'modal' => View::renderPartial('trips/form', [
't' => $isEdit ? array_merge($t, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
'tpModal' => true,
'tpModalOnly' => false, // 要壳
'tpOpen' => true, // 强制成"弹框 + 打开"
]),
], JSON_UNESCAPED_UNICODE);
exit;
}
View::render('trips/form', [
'title' => $isEdit ? '编辑旅程' : '新建旅程',
't' => $isEdit ? array_merge($t, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
// ⚠️ 整页直达时也要**打开弹框**:否则用户提交失败后跳到这一页
// 只会看到一张空白(所有提示都在弹框里)。
'tpModal' => true, 'tpOpen' => true,
], 'trips');
exit;
}
@@ -3504,6 +3542,12 @@ switch ($r) {
);
clear_old();
flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
/* 🔴 v41(用户第 2 条):「确定完了之后,**动态刷新旅程这个列表页面**。」
⇒ 从列表页弹框来的(`back=trips`)回**列表页**,让前端只换列表容器;
其余入口(如直达整页 `trips/new`)保持老行为:进详情页加行程。 */
if ((string)($_POST['back'] ?? '') === 'trips') {
redirect('trips');
}
redirect('trips/show', ['id' => $newId]);
break;
@@ -3568,8 +3612,13 @@ switch ($r) {
$stage = 'plan'; // "未成行"是标记出来的,不是新建出来的
}
}
View::render('trips/node_form', [
'title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title'],
/* 🔴 v41(用户 2026-10-10 第 5/6 条):新增/编辑行程改为**弹框**。
`?embed=1`(详情页弹框里 fetch 时带的)→ 只吐**表单片段**(不带整页外壳),
由详情页的弹框容器塞进去。
⚠️ 不带 embed 时仍渲染整页 —— 老链接(书签、旧入口)照样能用,
而且校验失败时整页重画也走这条路。 */
$embed = (string)($_GET['embed'] ?? '') === '1';
$ndArgs = [
'ev' => $t,
'n' => $nd,
'stage' => $stage,
@@ -3578,7 +3627,23 @@ switch ($r) {
'vehicles' => Vehicle::pickable($familyId),
'expenses' => $nd ? Expense::ofNode((int)$nd['id']) : [],
'errors' => [],
], 'trips');
/* 🔴 v41:两种形态由**路由明确指定**,模板不猜:
· embed=1(详情页弹框 fetch 片段)→ 无页头、无弹框壳(只吐表单);
· 直达整页(含校验失败重画)→ 有页头 + 弹框壳且**打开**。 */
'nfEmbed' => $embed,
'nfModal' => !$embed,
'nfShell' => !$embed,
'nfOpen' => true,
'nfReload' => 'nodeListHost',
];
if ($embed) {
header('Content-Type: text/html; charset=utf-8');
echo View::renderPartial('trips/node_form', $ndArgs);
exit;
}
View::render('trips/node_form',
['title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title']] + $ndArgs,
'trips');
break;
// ---------------- 花销(行程级,v24 第 16 批 第 2 条)----------------
@@ -3841,6 +3906,32 @@ switch ($r) {
'to_city' => trim((string)($_POST['to_city'] ?? '')),
];
if ($d['title'] === '') {
/* 🔴 v41(用户 2026-10-10 第 5/6 条):行程新增/编辑改成**弹框**后,
校验失败不能在弹框里"整页跳走"(用户会看到白页面、也不知道错在哪)。
⇒ AJAX 请求(弹框提交)回 200 + JSON `{ok:false, modal:'<重画片段>'}`,
由详情页把这一段塞回弹框;非 AJAX(老式整页提交)保持原行为。 */
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => '这段行程得有个名字。',
'modal' => View::renderPartial('trips/node_form', [
'ev' => $t,
'n' => $isEdit ? Trip::node($nid, (int)Auth::personId()) : null,
'stage' => (string)($_POST['stage'] ?? 'plan'),
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'types' => NodeType::all($familyId),
'vehicles' => Vehicle::pickable($familyId),
'expenses' => $isEdit ? Expense::ofNode($nid) : [],
'errors' => ['title' => '这段行程得有个名字。'],
'nfEmbed' => true, // 只吐表单片段(壳由详情页容器给)
'nfModal' => false,
'nfShell' => false,
'nfReload' => 'nodeListHost',
]),
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '这段行程得有个名字。');
redirect('trips/show', ['id' => $tripId]);
}
@@ -4291,6 +4382,11 @@ $want = array_keys($want);
'editing' => $editing,
'kinds' => Anniversary::kinds($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
/* 🔴 v41(用户第 10 条):「新建纪念日,采用弹框形式。」
直达 `days/new` 时也渲染成**打开着的弹框** ——
否则用户看到的是一张空白页(内容全在 hidden 的弹框里)。 */
'anModal' => true,
'anOpen' => true,
], 'days');
break;
@@ -5214,6 +5310,21 @@ $want = array_keys($want);
flash('ok', '资源已新建。');
}
$warns = attach_save_files($familyId, Resource::ATTACH, $rid, 'attach', $me);
/* 🔴 v41(用户 2026-10-10 第 9 条):「添加资源,采用弹窗形式,并在新建资源的
的时候,就确定共享人。」⇒ 表单里多了一个"共享给谁"的选人(`share_ids[]`),
在这里一次性落库(新建与编辑都收)。
⚠️ 权限一律给 `read`:新建时最多只到"只读共享"。
要给人**可写**,去列表里那条资源的「共享」弹框单独设
(那里能选只读/可读写)。这样"新建时勾错"的代价最小。
⚠️ owner 自己会被 `setShare` 自动跳过(见 Resource::setShare 的第一行),
所以"把自己也勾上"不会产生一条多余的共享记录。 */
$shareIds = filter_family_person_ids((array)($_POST['share_ids'] ?? []), $familyId);
foreach ($shareIds as $sid) {
Resource::setShare($rid, $ownerId, (int)$sid, 'read');
}
if ($shareIds) {
Notification::syncShares($familyId, Notification::RESOURCE, $rid, $me);
}
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}