diff --git a/app/CalDav.php b/app/CalDav.php index c4313c0..1269989 100644 --- a/app/CalDav.php +++ b/app/CalDav.php @@ -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/'; // ---------------- 应答 ---------------- diff --git a/app/CardDav.php b/app/CardDav.php index 0503487..e8cea78 100644 --- a/app/CardDav.php +++ b/app/CardDav.php @@ -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/'; /** * 当前用户可见的"通讯录"。 diff --git a/app/MovieStats.php b/app/MovieStats.php index 461dd45..aeb58fd 100644 --- a/app/MovieStats.php +++ b/app/MovieStats.php @@ -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" ); diff --git a/app/Trip.php b/app/Trip.php index fffc7eb..42390a0 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -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) { diff --git a/assets/css/app.css b/assets/css/app.css index f96ddcb..69b9ca5 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -20,6 +20,15 @@ 蓝=编辑、红=删除,全站统一,跟着模块换肤也不该变。 ⚠️ 暖纸底上要够暗才压得住白字:这里取 #2F6FB0(对比度 ≈ 4.9:1)。 */ --blue: #2F6FB0; + /* 🔴 v41(用户 2026-10-10):旅程详情页四种动作按钮各一种底色 + (计划=蓝 / 实际=绿 / 编辑=黄 / 删除=红)。蓝、绿(--green)、红已有; + 这里补"编辑黄"。 + ⚠️ 纯黄(#FFD400 之类)配白字几乎看不清(对比度 ≈ 1.3:1)。 + 取偏琥珀的深黄 #B8860B(DarkGoldenrod 系),暖纸底上白字对比度 ≈ 3.6:1, + 且不会和品牌暖棕 (`--brand` #A84A28) 混淆 —— 后者明显更红。 + ⚠️ "实际=绿"用既有的 `--green` (#6F9E5C),它在白字下对比度 ≈ 3.0:1, + 深一步更保险 —— 见 `.btn-actual` 里单独取的 #4E7C3F(≈ 4.6:1)。 */ + --yellow: #B8860B; --line: rgba(44, 35, 29, .10); --line-2: rgba(44, 35, 29, .06); --radius: 16px; @@ -1710,7 +1719,8 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, position: absolute; border: none; cursor: pointer; color: #fff; background: rgba(255, 255, 255, .12); display: flex; align-items: center; justify-content: center; - transition: background-color .2s ease; font-family: inherit; + transition: background-color .2s ease, filter .2s ease; + font-family: inherit; } .lb-btn:hover { } /* 深底上不能用薄雾:走文末统一段的 brightness 提亮 */ .lb-btn[hidden] { display: none; } @@ -1723,6 +1733,17 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, } .lb-dl:hover { background: #eee; } .lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; } +/* 🔴🔴 v41(用户 2026-10-10):「观影照片的左右按钮,鼠标悬停之后位置会变一下, + 一不小心就会点错。」 + 真根因:文末「全站悬停统一」段给 `.lb-btn:hover` 加了 + `transform: translateY(var(--hov-lift))`(= 上移 1px 的"轻抬升"手感)—— + 而 `.lb-nav` 是靠 `transform: translateY(-50%)` 做**垂直居中**的, + hover 那条**把居中的 transform 整个覆盖掉** ⇒ 鼠标一碰,按钮从"居中"跳到 + "距顶 50%",**位移接近半个按钮高度**(48px 的一半),确实会点错。 + ⚠️ 与 v21 家谱那次 `.f3 div.card:hover > div` 覆盖库的居中 transform 是**同一个坑**。 + ⇒ 修:`.lb-nav:hover` 显式把它自己的居中 transform 写回去(只保留提亮, + 不再抬升)。位置在 hover 前后**一字不动**,鼠标停在哪就点在哪。 + ⚠️ 必须写在统一 hover 段**之后**(同特异性下后者胜)—— 见文末那段里的同名规则。 */ .lb-prev { left: 14px; } .lb-next { right: 14px; } .lb-bar { @@ -2942,7 +2963,14 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, /* 新建影院:居中弹框(与名册的动作弹框同一形态,只是更宽) */ .cn-modal { - position: fixed; inset: 0; z-index: 70; + /* 🔴🔴 v41(用户 2026-10-10):「观影模块,第一场电影,新建影院这个弹窗不显示, + 被记一场电影这个弹窗盖住了。」 + 真根因:`.cn-modal` 是 **70**,而「记一场电影」用的 `.cal-modal` 是 **200** —— + 影院弹框是**从观影弹框里**打开的子层,层级却更低 ⇒ 被整块盖住、看不见也点不到。 + ⇒ 提到 260:必须**高于 .cal-modal(200)**,也高于 antd 的下拉面板 + (`.ant-picker-dropdown` 走 popper,默认 1050 之外的那一层我们没碰; + 这里只需保证"压过所有自建弹框"即可)。 */ + position: fixed; inset: 0; z-index: 260; display: flex; align-items: center; justify-content: center; } .cn-modal[hidden] { display: none; } @@ -3784,6 +3812,12 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b filter: brightness(1.18); transform: translateY(var(--hov-lift)); } +/* 🔴🔴 v41 例外(必须写在上面那条**之后**,靠"同特异性、后者胜"生效): + `.lb-nav`(照片左右切换)是**靠 transform 居中**的,一旦被上面的 + `translateY(var(--hov-lift))` 覆盖,hover 时按钮会从"居中"跳到"距顶 50%", + 位移近半个按钮高 —— 用户报的就是这个("悬停之后位置会变一下,一不小心就会点错")。 + ⇒ 这里把它的居中 transform 显式写回,只保留提亮。位置前后一字不动。 */ +.lb-nav:hover { transform: translateY(-50%); } /* v24 第 20 批 第 3 条:`form="id"` 关联用的**表单外壳**。 它们只承载字段、不出现在视觉里(按钮在原来的位置上,用 form= 指过来)。 @@ -5426,3 +5460,147 @@ h5.md-h, h6.md-h { font-size: 14px; } /* 纯日期版控件与观影那边同款外观(同高、同圆角、同描边)—— 复用 .dt-antd 的规则, 这里只补"它是块级、占满字段宽度"这一点(.dt-antd 原为 flex 子项)。 */ .dt-antd-dateonly { display: block; width: 100%; } + +/* ============================================================================ + 🔴 v41(用户 2026-10-10)旅程详情页的四个动作按钮:计划=蓝 / 实际=绿 / 编辑=黄 / 删除=红 + ============================================================================ + 为什么四种都要在**页头动作区那一层**再写一遍: + 页头有一套"特异性更高"的规则(`.page-head .actions > .btn:not(.btn-icon)` 等, + 见文件前部的页头按钮层),它们会盖过基类的 `.btn-xxx` 底色。 + v40 的 `.btn-del` 就吃过这一脚(必须在页头层再补一条才生效)。 + ⚠️ 三处形态都要写(裸类 / a. / button.):`.btn`、`a.btn`、`button.btn` + 的特异性不同,漏一种就有一种是错的(这条规律在 v40 已经验证过)。 + ⚠️ 设置按钮**不动**(用户:"设置就用这个图标,不要变")。 */ +.btn-plan, a.btn-plan, button.btn-plan { + background: var(--blue); color: #fff; border-color: transparent; +} +.btn-actual, a.btn-actual, button.btn-actual { + /* 绿比 --green 深一档:白字对比度 3.0 → 4.6,暖纸底上看得清 */ + background: #4E7C3F; color: #fff; border-color: transparent; +} +/* 🔴🔴 2026-10-11 修(v40 套件报的**真回归**): + 这里原来写的是**裸类** `.btn-edit`(全局改黄),直接把 v40 影院管理页的 + 「编辑 = 蓝色实心」覆盖掉了 —— 影院那页量出来的实际色是 `rgb(184,134,11)`(黄), + 而用户对它的要求是**蓝色**(v40 原话:「编辑使用蓝色的背景,删除使用红色的背景」)。 + ⇒ 两处需求不同:**旅程详情页的「编辑」= 黄**(v41 第 4 条), + **影院管理页的「编辑」= 蓝**(v40)。所以颜色必须**按作用域**给,不能裸类全局设。 + 锚点选 `[data-trip-edit]`(旅程详情页那个按钮的专属属性)—— + 它是最精确的标识,不依赖页头层级,也不会波及影院管理页的 ``。 */ +.btn-edit[data-trip-edit] { + background: var(--yellow); color: #fff; border-color: transparent; +} +.btn-del, a.btn-del, button.btn-del { + background: var(--red); color: #fff; border-color: transparent; +} +/* hover:同色系加深(与文末"全站悬停统一"段同一手法:brightness 压低) */ +.btn-plan:hover, .btn-actual:hover, .btn-edit:hover, .btn-del:hover { filter: brightness(.92); } +/* 页头动作区那一层(覆盖页头默认的"品牌色实心/ghost") */ +.page-head .actions > .btn-plan:not(.btn-icon), +.page-head .actions > .btn-actual:not(.btn-icon), +.page-head .actions > .btn-edit[data-trip-edit]:not(.btn-icon), +.page-head .actions > .btn-del:not(.btn-icon), +.page-head .actions > form > button.btn-del:not(.btn-icon) { color: #fff; border-color: transparent; } +.page-head .actions > .btn-plan:not(.btn-icon) { background: var(--blue); } +.page-head .actions > .btn-actual:not(.btn-icon) { background: #4E7C3F; } +.page-head .actions > .btn-edit[data-trip-edit]:not(.btn-icon) { background: var(--yellow); } +.page-head .actions > .btn-del:not(.btn-icon), +.page-head .actions > form > button.btn-del:not(.btn-icon) { background: var(--red); } + +/* ---- v41 第 8 条:交通方式的"标识"(车次号 / 车牌号)紧跟模式词 ---- + 用户:「把火车的车次号直接放在"火车"这两个字后面。如果是自驾, + 就应该把自驾的车牌号放在后面。」 + ⇒ 同一个 chip 内、模式词之后,用略淡的字重区分(仍属同一个信息单元)。 */ +.node-mode-no { + margin-left: 6px; padding-left: 6px; + border-left: 1px solid currentColor; + opacity: .78; font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* ---- v41 第 2 条:题图的说明文字搬进框里(原来在框外另起一段)---- */ +.cover-picker-hint { + display: block; margin-top: 8px; + font-size: 12.5px; line-height: 1.6; color: var(--muted); +} + +/* ============================================================================ + 🔴 v41(用户 2026-10-10 第 5/6 条)行程弹框的**观影式排版** + ============================================================================ + 用户原话:「编辑行程、加计划行程、加实际行程,全部都采用弹窗的形式。 + 其排版的方式和添加电影是一样的,前面是标题冒号,后面是输入框, + 和观影模块一样,注意排版。」 + + 🔴🔴 为什么这里用 **grid** 而不是 flex(2026-10-10 血泪): + 先按观影那套写了 flex(`display:flex; flex-wrap:wrap` + label 固定 62px), + 结果**控件被挤到下一行**(实测 label y=117 / 控件 y=187)。 + 查了半天:`.input/.select` 全站有 `width: 100%`,在 flex 行里 + 它的 auto 基准就是**整行宽** ⇒ 62 + 10 + 696 > 696 ⇒ 换行。 + 把 width 改 auto、把 flex-basis 从 0% 试到 auto、加 min-width…… + 控件宽度是对了(140),**换行依旧**(同一行里还有 `.tip` 声明着 + `flex: 0 0 100%`,wrap 算法会把它们的理想宽度一起算)。 + ⇒ 换 **grid**:`.field` 两列(62px + 1fr),标签在第 1 列、控件在第 2 列, + `.tip/.err` 用 `grid-column: 2` 自动落在控件下面**且与控件左缘对齐** + (连 margin-left 都不需要)。两列表单的标准写法,不受 width/flex 交互影响。 + + ⚠️ 作用域用 `.nf-form`(表单自己的类)而不是 `.nf-wrap` / `.nf-panel`: + 片段形态(`?embed=1`)两者都不输出(壳由详情页容器给), + 只用它们的话排版在弹框里**完全不生效**(探针抓到过)。 */ +.nf-form .form-grid { + display: flex; flex-direction: column; gap: 12px; +} +.nf-form .form-grid > .field { + display: grid; grid-template-columns: 62px minmax(0, 1fr); + column-gap: 10px; row-gap: 6px; align-items: start; +} +/* 标签:固定第 1 列、右对齐、自动冒号(与观影那套同一组数字) */ +.nf-form .form-grid > .field > label { + grid-column: 1; + text-align: right; padding-top: 9px; + font-size: 14px; font-weight: 600; color: var(--ink); + line-height: 1.4; word-break: keep-all; +} +.nf-form .form-grid > .field > label::after { + content: ':'; font-weight: 400; color: var(--muted); +} +/* 控件:占第 2 列(宽度由 grid 给,不受它的 width:100% 影响) */ +.nf-form .form-grid > .field > *:not(label) { + grid-column: 2; min-width: 0; +} +/* 提示 / 错误:也在第 2 列 ⇒ **自动与控件左缘对齐** */ +.nf-form .form-grid > .field > .tip, +.nf-form .form-grid > .field > .err { + grid-column: 2; +} +/* 起终点是两列小网格,整段落在第 2 列 */ +.nf-form .form-grid > .field > .loc-row { grid-column: 2; } +/* 「当前状态」是只读文本,对齐 40px 高的控件 */ +.nf-form .node-stage-ro { padding-top: 9px; } +/* 窄屏:标签列压到 52px,避免控件被挤得太窄 */ +@media (max-width: 560px) { + .nf-form .form-grid > .field { grid-template-columns: 52px minmax(0, 1fr); } +} + +/* ---- v41 第 8 条:交通方式的"标识"(车次号 / 车牌号)紧跟模式词 ---- + 用户:「把火车的车次号直接放在"火车"这两个字后面。如果是自驾, + 就应该把自驾的车牌号放在后面。」 + ⇒ 同一个 chip 内、模式词之后,用略淡的字重区分(仍属同一个信息单元)。 */ +.node-mode-no { + margin-left: 6px; padding-left: 6px; + border-left: 1px solid currentColor; + opacity: .78; font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* ---- v41 第 2 条:题图的说明文字搬进框里(原来在框外另起一段)---- */ +.cover-picker-hint { + display: block; margin-top: 8px; + font-size: 12.5px; line-height: 1.6; color: var(--muted); +} + + +/* ---- v41 第 10 条:纪念日弹框(观影式排版)里"月 / 日"那一行的两格 ---- */ +.an-md { display: flex; align-items: center; gap: 6px; } +.an-md .input { width: 88px; flex: 0 0 auto; text-align: center; } +.an-md-sep { color: var(--muted); font-size: 13px; } +/* 观影式行里的"填充块"(选人组件那种自带布局的控件,占满控件位) */ +.mv-row > .mv-fill { flex: 1 1 auto; min-width: 0; } diff --git a/carddav.php b/carddav.php index 163dde5..5579a4a 100644 --- a/carddav.php +++ b/carddav.php @@ -195,6 +195,29 @@ if ($method === 'PROPFIND') { // 5c. home-set(以及 `/carddav/` 本身):列出这个人可见的通讯录。 // ⚠️ `/carddav/` 也走这条路:用户与老客户端都直接把 /carddav/ 当 home 用。 if ($isHome || $bookId === 0) { + // 🔴🔴 2026-10-11:与 dav.php 同一个协议隐患 —— **Depth:0 只应回资源自身**。 + // 原来无论 depth 都把全部通讯录一起回,线上实测 `PROPFIND /carddav/ Depth:0` + // 第一条 response 是通讯录(不含 current-user-principal), + // 严格客户端可能判"拿不到 principal"。 + // ⇒ Depth:0 只回 `/carddav/` 自身(principal / home-set 都在里面)。 + if ($depth !== '1') { + $emit([ + '' . CardDav::x(CardDav::HOME_PATH) . '' + . '' + . '' + . '' . CardDav::x('家书通讯录') . '' + . '' . CardDav::x(CardDav::PRINCIPAL_PATH) + . '' + . '' . CardDav::x(CardDav::PRINCIPAL_PATH) + . '' + . '' . CardDav::x(CardDav::HOME_PATH) + . '' + . $readOnlyPriv + . '', + ]); + exit; + } + $parts = []; // 🔴 v33:**始终**先输出 home 集合自身。 // RFC 4918 规定:PROPFIND 的响应**必须包含被请求的资源本身**, diff --git a/dav.php b/dav.php index e5dc74a..db095ee 100644 --- a/dav.php +++ b/dav.php @@ -233,6 +233,39 @@ if ($method === 'PROPFIND') { if ($isHome || $bookId === 0) { $baseHref = $isHome ? CalDav::HOME_PATH : '/dav/'; $parts = []; + + // 🔴🔴 2026-10-11(本批线上实测发现的协议隐患,修之): + // RFC 4918 规定 **Depth:0 只描述「请求的那个资源本身」**, + // 而这里原来无论 depth 都把**所有日历本**一起回。 + // 线上实测的坏结果:`PROPFIND /dav/ Depth:0` 的**第一条 response + // 是 `/dav/1.ics/`**(日历本自己,**不含 current-user-principal**) + // —— 严格客户端(Thunderbird / iOS)只读"与请求 URL 匹配的那条"或 + // 第一条,就可能判"拿不到 principal"而中止。 + // ⇒ 按 depth 分流: + // · Depth:0 → **只回 `/dav/` 自身**(principal、home-set 都在这一条里, + // 所以"把 /dav/ 当入口的老客户端"仍然一次就能往下走); + // · Depth:1 → 自身 + 各日历本。 + if ($depth !== '1') { + $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" ' + . 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [ + '' . CalDav::x($baseHref) . '' + . '' + . '' + . '' . CalDav::x('家书日历') . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) + . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) + . '' + . '' . CalDav::x(CalDav::HOME_PATH) + . '' + . '' . CalDav::x(CalDav::CARD_HOME_PATH) + . '' + . $readOnlyPriv + . '', + ]); + exit; + } + foreach (CalDav::books($familyId, $me) as $c) { $href = CalDav::hrefOf($c); $parts[] = '' . CalDav::x($href) . '' diff --git a/index.php b/index.php index f154c0c..f8cc018 100644 --- a/index.php +++ b/index.php @@ -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)); } diff --git a/templates/days/form.php b/templates/days/form.php index 67d1165..0485d9b 100644 --- a/templates/days/form.php +++ b/templates/days/form.php @@ -1,121 +1,178 @@ Warning…` ⇒ 前端 `getElementById` 拿不到, + "点新建没反应"。这正是全站那条"模板片段不许偷宿主变量"的同一类坑。 */ +$editing = $editing ?? null; +$people = $people ?? []; +$kinds = $kinds ?? null; + $e0 = $editing ?: []; $v = static fn(string $k, $def = '') => $e0[$k] ?? $def; $repeat = (string)($e0['repeat_type'] ?? 'solar'); // 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单 -// (原来写死 PRESET_KINDS,用户要能自己增删 —— 见「纪念日设置」)。 $kinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS); $curKind = (string)($e0['kind'] ?? ''); -// 编辑一条用了**已被删掉的分类**的纪念日时,要把它自己也列进下拉, -// 否则保存一次就把它悄悄改成了第一个选项(用户没动它,值却变了)。 if ($curKind !== '' && !in_array($curKind, $kinds, true)) { array_unshift($kinds, $curKind); } + +/* 🔴 v41 弹框开关(与观影/旅程同一套命名习惯): + $anModal —— 渲染成弹框壳 + $anModalOnly —— 被列表页内嵌(初始关闭) + $anOpen —— 初始打开(直达 / AJAX 校验失败重画) + $anEmbed —— 只吐片段(详情页/列表页弹框 fetch 用) */ +$anModal = !isset($anModal) ? true : (bool)$anModal; +$anModalOnly = !empty($anModalOnly); +$anOpen = !empty($anOpen); +$anEmbed = !empty($anEmbed); +$anShell = !isset($anShell) ? !$anEmbed : (bool)$anShell; + +$backUrl = url('days'); ?> +

- 返回 + 返回
+ -
+ +
\ No newline at end of file diff --git a/templates/movies/form.php b/templates/movies/form.php index fd48c91..6f8f633 100644 --- a/templates/movies/form.php +++ b/templates/movies/form.php @@ -386,20 +386,39 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染 以后动这里只要保证:**开的数量 = 关的数量,且顺序一致**。 */ ?> +
- +
- + + 取消
- + `) + 关掉了 —— 原来这里**无条件**又关一次 ⇒ 弹框形态**多一个 ``**, + 把 `` 挤到了容器外面(`_fb_divdepth.py` 当场抓到: + "深度结束时 -1,首个负深度在 ``")。 + ⚠️ 这与 v37 那次"保存按钮被甩到 form 外面"是**同一个结构性坑**, + 而 v40 那轮只跑了 `php -l`(它对此完全无感)所以没发现 + —— 这一轮补上"按形态逐一量 div 深度"才暴露出来。 */ ?> + + + @@ -455,6 +474,15 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染 + +
+ + +
diff --git a/templates/movies/stats.php b/templates/movies/stats.php index 16cc7c9..57f801f 100644 --- a/templates/movies/stats.php +++ b/templates/movies/stats.php @@ -67,15 +67,19 @@ $wd = static function (?string $d): string {

按观影人统计 参与场次

+
- +
- +
@@ -87,12 +91,14 @@ $wd = static function (?string $d): string {

按年统计 观影数量

+
年 - +
diff --git a/templates/resources/index.php b/templates/resources/index.php index ff2a1ec..d22b492 100644 --- a/templates/resources/index.php +++ b/templates/resources/index.php @@ -91,58 +91,100 @@ $myPerson = (int)\App\Auth::personId();
-
> -

-
+
+
- - 返回旅程 - - - + 计划行程 - - - + 实际行程 - - 编辑 - + `(跳页面),现在全部改成**弹框**(见第 5 条), + 所以这里用 ` + +
- +
- + 返回旅程 + + 返回旅程 只读账号
@@ -139,10 +141,12 @@ $groupMeta = [
- +
- 参与的人(各段行程的合集) + 参与的人
(int)$a['id']])) ?> @@ -191,6 +195,11 @@ $groupMeta = [ $shown = 0; foreach ($bysStage as $lst) { $shown += count($lst); } ?> + +
还没有行程 @@ -235,7 +244,25 @@ foreach ($bysStage as $lst) { $shown += count($lst); } 已修正 次 - + +
@@ -253,7 +280,9 @@ foreach ($bysStage as $lst) { $shown += count($lst); } @@ -358,9 +387,14 @@ foreach ($bysStage as $lst) { $shown += count($lst); } ⚠️ 计划行的删除入口**保留**:`skipNode`(未成行)是主路径, 但"记错了、这段根本不该存在"时仍需要真删(hint 里也这么写)。 */ ?>
- - 编辑 + +
@@ -424,4 +458,176 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
- \ No newline at end of file + +
+ + + + + + +