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
+28 -2
View File
@@ -117,12 +117,38 @@ final class CalDav
*/
public const PRINCIPAL_PATH = '/dav/principal/';
public const HOME_PATH = '/dav/calendars/';
/**
* 🔴🔴 v41(用户 2026-10-10 第 11 条:「caldav 和 carddav 功能还是不好用,
* thunderbird 返回的结果是,找不到联系人,找不到日历本,再仔细检查这个功能,
* 这次要务必彻底解决。」)
*
* 真根因(线上实测出来的,不是猜的):
* 原来 `HOME_PATH = '/dav/calendars/'`,而**日历本自己的 href 是 `/dav/1.ics/`**
* —— 也就是说 **日历本并不在 home-set 之下**。
* Thunderbird(严格按 RFC 4791/6352):拿到 `calendar-home-set` 后,
* PROPFIND 它、**只看它的成员**当作日历本 ⇒ 那里一个都没有
* ⇒ 报「找不到日历本」。CardDAV 侧一模一样(`/carddav/addressbooks/`
* vs 通讯录 `/carddav/1.vcf/`)⇒ 「找不到联系人」。
*
* ⚠️ 为什么 v33 那批没抓出来:那批的探针**只验证"发现链每一步都回 207"**,
* 而每一步确实都回了 207、principal 与 home-set 也都拿到了 ——
* **层级关系错了这件事,单个请求的返回码看不出来**。
* ⇒ 新探针必须断言"**home-set 的成员里必须有日历本/通讯录**"。
*
* 修法:让 home-set 就指向**本协议自己的根**(`/dav/`、`/carddav/`)——
* ① 成员关系立刻成立(`/dav/1.ics/` 确实在 `/dav/` 之下);
* ② **所有既有 URL 一字不变**(客户端里配的 `/dav/`、`/dav/1.ics/` 继续有效,
* 不需要用户重新配置,也不会让已同步的客户端全部失效);
* ③ 少一层跳转。
* ⚠️ 别改成"把日历本挪到 /dav/calendars/ 下面"——那会让**每一个用户**的
* 客户端配置作废(已订阅的日历会 404),代价大得多。
*/
public const HOME_PATH = '/dav/';
/** 通讯录那边同义(见 App\CardDav,两边刻意保持一致) */
public const CARD_PRINCIPAL_PATH = '/carddav/principal/';
public const CARD_HOME_PATH = '/carddav/addressbooks/';
public const CARD_HOME_PATH = '/carddav/';
// ---------------- 应答 ----------------
+9 -1
View File
@@ -52,7 +52,15 @@ final class CardDav
*/
public const PRINCIPAL_PATH = '/carddav/principal/';
public const HOME_PATH = '/carddav/addressbooks/';
/**
* 🔴🔴 v41(用户 2026-10-10 第 11 条:「thunderbird 返回的结果是,找不到联系人,
* 找不到日历本」)——真根因与 CalDAV 那边**一模一样**(见 CalDav::HOME_PATH 的长注释):
* 原来是 `/carddav/addressbooks/`,而通讯录自己的 href 是 `/carddav/1.vcf/`
* ⇒ **通讯录不在 home-set 之下** ⇒ Thunderbird 在 home-set 里一本都找不到
* ⇒ 报「找不到联系人」。
* 修法:home-set 就指向 `/carddav/` 自己(成员关系成立,且所有既有 URL 不变)。
*/
public const HOME_PATH = '/carddav/';
/**
* 当前用户可见的"通讯录"。
+40 -2
View File
@@ -110,6 +110,43 @@ final class MovieStats
return self::WEEKDAY_COLORS[$w] ?? '#A84A28';
}
/**
* 年份配色(v41 用户 2026-10-10):「按年统计,也用不同的颜色来显示。」
*
* 为什么不按"年份 % 12"去借 MONTH_COLORS:那样 **12 年一循环**,
* 一个 30 年的观影史会出现两遍同色,看着像"同一年"。
* ⇒ 这里按**年份序号**在色相环上均匀取点(黄金角步进),
* 相邻年份色相差得开、且**不随年份范围变化**(同一个年份永远是同一个颜色,
* 不会因为"库里多了几年"就换色 —— 那种不稳定比不好看更糟)。
*/
public static function yearColor(int $year): string
{
// 黄金角 ≈ 137.5°,让相邻年份在色相环上尽量分开。
// 用绝对年份参与运算 ⇒ 结果只取决于年份本身(稳定、可复现)。
$hue = fmod(($year * 137.508), 360.0);
if ($hue < 0) {
$hue += 360.0;
}
// 明度/饱和度固定在这一档:宣纸底上都能看清,也不与品牌暖棕打架。
return self::hslToHex($hue, 0.46, 0.47);
}
/** HSL → #rrggbb(只给 yearColor 用,避免引一整个颜色库) */
private static function hslToHex(float $h, float $s, float $l): string
{
$c = (1 - abs(2 * $l - 1)) * $s;
$x = $c * (1 - abs(fmod($h / 60.0, 2) - 1));
$m = $l - $c / 2;
if ($h < 60) { $r = $c; $g = $x; $b = 0.0; }
elseif ($h < 120) { $r = $x; $g = $c; $b = 0.0; }
elseif ($h < 180) { $r = 0.0; $g = $c; $b = $x; }
elseif ($h < 240) { $r = 0.0; $g = $x; $b = $c; }
elseif ($h < 300) { $r = $x; $g = 0.0; $b = $c; }
else { $r = $c; $g = 0.0; $b = $x; }
return sprintf('#%02x%02x%02x',
(int)round(($r + $m) * 255), (int)round(($g + $m) * 255), (int)round(($b + $m) * 255));
}
/** 按观影人:参与了多少场(带头像,供统计页画条;名称按「我叫他什么」显示)
*
* 🔴 v40(用户 2026-10-10):「观影统计里面,如果这个人在名册里面,但是他一部电影的
@@ -125,13 +162,14 @@ final class MovieStats
public static function byPerson(int $familyId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
"SELECT p.id, p.name, p.avatar, n.nickname AS my_nickname, COUNT(e.id) AS cnt
"SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname,
COUNT(e.id) AS cnt
FROM persons p
LEFT JOIN event_attendees a ON a.person_id = p.id
LEFT JOIN events e ON e.id = a.event_id AND e.kind = 'movie' AND e.family_id = ?
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = 'member'
GROUP BY p.id, p.name, p.avatar, n.nickname
GROUP BY p.id, p.name, p.avatar, p.avatar_color, n.nickname
HAVING cnt > 0
ORDER BY cnt DESC, p.id"
);
+25
View File
@@ -658,6 +658,31 @@ final class Trip
$st->execute([$eventId]);
$rows = $st->fetchAll();
$ids = array_map('intval', array_column($rows, 'id'));
/* 🔴 v41(用户 2026-10-10 第 8 条):行程行要把「自驾的车牌号」直接跟在
「自驾」两个字后面 ⇒ 这里一次性把用到的车辆**车牌**挂上去。
⚠️ 用一条 IN 查询而不是逐行 JOIN:一张"十几段"的行程单逐行查会把
N 次往返变成 N 次;车辆是低频引用(多数段没有车),
IN 一次就够。
⚠️ 字段名 `vehicle_plate` 与 `Vehicle::label` 的取向不同:
label 是给"选车下拉"用的(车牌 + 名称,方便辨认),
详情页要的是**车牌号本身**(用户原话就是"车牌号")。 */
$vehIds = array_values(array_filter(array_map(
static fn($n) => (int)($n['vehicle_id'] ?? 0), $rows)));
if ($vehIds) {
$vin = implode(',', array_fill(0, count(array_unique($vehIds)), '?'));
$sv = Database::get()->prepare(
"SELECT id, plate FROM vehicles WHERE id IN ($vin)"
);
$sv->execute(array_values(array_unique($vehIds)));
$plates = [];
foreach ($sv->fetchAll() as $v) {
$plates[(int)$v['id']] = (string)$v['plate'];
}
foreach ($rows as &$nv) {
$nv['vehicle_plate'] = $plates[(int)($nv['vehicle_id'] ?? 0)] ?? '';
}
unset($nv);
}
// 一次性把全部节点的附件取出来(逐节点查会让"十几段的旅程"变成几十次查询)
$attach = [];
foreach ($ids as $nid) {