用户 2026-10-15 七条。
① 旅程行程经 CalDAV 同步后「结束时间 = 开始时间」
**真根因**(app/Calendar.php 的 exportRows()):对**派生条目**(观影/旅程)写死
'ends_at' => null,而 Ics::vevent() 遇到空结束会退化成「DTEND = DTSTART」
⇒ 手机上每条行程的结束时间都等于开始。
**附带第二个 bug**:分组键含日期(kind-ref_id-date),而 month() 把**跨天条目逐天**
铺出来 ⇒ 跨天节点被拆成 N 条(UID 相同),resources() 按 UID 去重后**只留第一天**,
而第一天那条没有结束时刻 ⇒ 又回到 DTEND=DTSTART。
修法:键改成 `kind-ref_id`,先收集同节点的各天,再用**首末天**拼一条真实跨度
(末天那条才带 end_time)。实测单日 DTEND=12:30、跨天 DTEND=第3天11:00,
每节点恰好 1 条 VEVENT。
⚠️ 副作用(已处理):v31 DAV 去重的反向验证原来靠「跨天节点逐天出、UID 全同」造重复,
本修把那条素材**修没了** ⇒ 给夹具换成「同一份 .ics 导两次」(两条 UID 相同的
自定义日程),守卫仍能被证伪(见 _fb_dav_diag.py 与 pitfalls 的同族条目)。
② 笔记详情页标题是"看笔记"而不是笔记名
阅读态 h1 改用笔记自己的 $noteTitle;**编辑态仍用「改笔记/写笔记」**
(编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 <title> 同步。
③ 加段落/加图片要在**输入框下面**
note_editor.js 的 makeBlock() 里每块下方加一排 .note-block-add(+段落/+图片),
点了插在**这一块后面**;顶部那组**保留**(用户明确"上面和下面都保留")。
⚠️ 挂在 body 里而不是 wrap 上 —— .note-block 是 display:flex 横排,
挂 wrap 会变成"输入框右边的第三列",不是"下面"。
⚠️ 用 data-add-below(不是 data-add):顶部是"追加到末尾"、块内是"插在本块后"。
④ 笔记详情页「逐段就地编辑」(分块多时不用跳编辑页找那一块)
模板给每块包 .note-read-item[data-block][data-kind][data-raw] + 右上角「编辑」按钮;
新增 assets/js/note_inline.js(按需加载):划过出现 → 点编辑就地变 textarea →
保存 POST notes/block → 只换这一块。
服务端 Note::updateBlock():**按块主键改一行**(不是整组替换)——
⚠️ 整组替换会换掉每个块的 id(前端手里的 data-block 当场失效)+ 并发互相覆盖;
⚠️ SQL 必须带 `AND note_id = ?`(块 id 全局自增,少了它就能改别人笔记里的块)。
权限与整条编辑同一把尺(find() + canWrite())。
⑤ 删掉「从已有家里人中挑选」,改成「新增/导入时自动匹配」
新增 Person::matchExisting():手机号(有 phone_hash,一条 SQL 精确命中)→
身份证(无哈希,整表解密比)→ 姓名(**先记下不立刻返回**,等精确命中看完再退到它)。
people/create:校验通过后先 matchExisting,命中就在**同一张表单里**出确认条
(#personConfirm)。⚠️ **不做成单独确认页**:单独页要把整张表(含工作经历/补充联系
方式这些**数组字段**)原样回传,漏一类就静默丢数据;同表单里字段天然全在。
⚠️ 判"是不是第一次提交"用 array_key_exists('confirm_existing')(0=不是、1=是、无=首次)。
导入那条路的缺口(本轮补):ContactImporter 命中已有的人时只补字段、**不挂进导入者的
名册** ⇒ "文件里有他、系统里也有他,但导完我的名册里看不到他"。已在命中分支加
PersonRoster::ensure(..., $meId, ...)。
顺手加 PersonRoster::has()(避免调用方又踩"visibleIds 是值列表不是集合"那个坑)。
⑥ 名册页只剩「添加成员 + 设置」;分组搬到名册设置
templates/people/_settings_menu.php(新):☰ 下拉两行 = 导入联系人 / 名册分组。
templates/people/settings.php(新,路由 people/settings):require 从个人设置**整块搬出来**
的 templates/_person_groups.php。
⚠️ 是**真搬**不是复制(两份一模一样的分组界面,改一处忘一处是必然的);
⚠️ 片段变量一律加 pg 前缀 —— 模板片段与宿主 require 是**共享作用域**,
用 $groups 这种通名会覆盖宿主同名变量(本轮真踩到,已改)。
⚠️ 表单 action 仍指 settings/groups/*(一个字段没改)。
⑦ 个人设置:模块设置整合成一张表
原来是**三张卡三个表单**(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。
现在一张表五列:顺序 | 模块 | 导航 | 首页 | 颜色,一次保存。
新增 settings/modules_save 路由:**底层存储一个没改**(仍是 nav_modules /
home_modules / module_colors),只是把三个 save 依次调一遍 ⇒ 老数据天然兼容。
⚠️ 权限**没有**因为"合并"被放宽:颜色仍是家庭一份、只有管理员能改
(非管理员那列是只读色块;提交时服务端静默忽略 c[])。
窄屏:表格 min-width:560px + 外层 overflow-x:auto(375px 实测横向溢出 0)。
验证(新增 5 套件 + 更新若干旧断言):
· _fb_v52_ics.py(3)/ _fb_v52_note.py(19)/ _fb_v52_roster.py(12)/ _fb_v52_mods.py(13)
· _b52_falsify.py(**12 注入 / 31 断言全抓**)
· _fb_import_test.py 补 ⑯(命中的人也要回到导入者名册);_fb_dav_diag.py 换去重素材
· e2e 1516/0;_fb_batch_e.py / _fb_e2e.py / familybook_deploy.sh 里随 #6/#7 搬家或改形的
旧断言(分组在个人设置、模块行 li→tr、show_present、RRULE 前缀)意图一字不改
· 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第52批形态");线上 16 文件 md5 逐字节一致
⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
· 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
· **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
· 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
· 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
· 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
117 lines
6.2 KiB
PHP
117 lines
6.2 KiB
PHP
<?php
|
||
/**
|
||
* 「名册分组」管理卡片 —— 从个人设置**搬出来**的(v52 用户 2026-10-15 第 6 条)。
|
||
*
|
||
* 用户原话:「名册右侧,只保留一个添加成员按钮,右侧再加一个设置按钮,
|
||
* 点击后下拉设置,第一个就是导入联系人,第二个就是原来个人设置那里的
|
||
* **名册分组**,这样**个人设置中就不显示名册分组的内容**了。」
|
||
*
|
||
* ⇒ 同一个卡片现在挂在「名册 → 设置 → 名册分组」这一页上(`templates/people/settings.php`)。
|
||
* ⚠️ 表单 action 仍指向 `settings/groups/*`(**一个字段都没改**)——
|
||
* 分组管理是"我自己的分组",与它挂在哪一页无关;
|
||
* 换路由只会白白多一处要维护的东西(而且老书签/脚本会失效)。
|
||
*
|
||
* 变量(require 前设好;**入参仍用宿主那边的名字**,片段内部一律 pg* 前缀,见下):
|
||
* $groups PersonGroup::orderFor(...) 的结果(公共 + 我的)
|
||
* $groupCounts gid => 人数
|
||
* $rosterCount 我的名册人数
|
||
*/
|
||
/* ⚠️ 变量名**必须带片段前缀**(pg*):模板片段与宿主 `require` 是**共享作用域**,
|
||
用 `$groups` 这种通名会把宿主模板的同名变量覆盖掉(本项目有静态断言专门查这个)。 */
|
||
$pgGroups = (array)($groups ?? []);
|
||
$pgCounts = (array)($groupCounts ?? []);
|
||
$pgRosterN = (int)($rosterCount ?? 0);
|
||
$pgTotal = count($pgGroups);
|
||
?>
|
||
<div class="card card-pad">
|
||
<h2 class="section-title">
|
||
名册分组
|
||
<span class="hint">公共的 + 我自己加的</span>
|
||
</h2>
|
||
<p class="small muted" style="margin:0 0 16px">
|
||
名册是按「关系分组」分栏显示的。这里有两层:
|
||
<b>公共分组</b>由管理员在「系统设置」里维护(全家都能用);
|
||
你要是觉得不够,就在下面<b>加只给自己看的分组</b>,别人看不到、也不受影响。
|
||
<br>
|
||
列表里 <b>公共</b> 标的是大家共用的那几个。
|
||
哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。
|
||
<br>
|
||
你的名册现在有 <b><?= $pgRosterN ?></b> 人。
|
||
</p>
|
||
|
||
<?php if (!$pgGroups): ?>
|
||
<p class="small muted" style="margin:0 0 14px">还没有分组。加一个吧。</p>
|
||
<?php else: ?>
|
||
<ol class="order-list">
|
||
<?php foreach ($pgGroups as $i => $g): $pgGid = (int)$g['id']; $pgOwn = (int)$g['owner_id']; ?>
|
||
<li class="order-row">
|
||
<span class="order-no"><?= $i + 1 ?></span>
|
||
<span class="order-name">
|
||
<?= e((string)$g['name']) ?>
|
||
<?php if ($pgOwn === 0): ?><span class="badge">公共</span><?php else: ?><span class="badge badge-open">我的</span><?php endif; ?>
|
||
</span>
|
||
<span class="order-count"><?= (int)($pgCounts[$pgGid] ?? 0) ?> 人</span>
|
||
<form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $pgGid ?>">
|
||
<input type="hidden" name="scope" value="me">
|
||
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
|
||
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
|
||
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
|
||
title="下移一组" <?= $i === $pgTotal - 1 ? 'disabled' : '' ?>>▼</button>
|
||
</form>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ol>
|
||
<?php endif; ?>
|
||
|
||
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="scope" value="me">
|
||
<label class="sr-only" for="newMyGroup">新分组名</label>
|
||
<input class="input" id="newMyGroup" name="name" maxlength="20" placeholder="例如:球友、老同学">
|
||
<button class="btn btn-sm" type="submit">加一个我自己的分组</button>
|
||
</form>
|
||
|
||
<?php
|
||
// 只有我自己建的分组才给改名 / 删除 —— 公共分组要在系统设置里改(那是管理员的事),
|
||
// 免得有人在自己设置里把"家人"改成别的、影响全家。
|
||
$pgMine = array_values(array_filter($pgGroups, fn($g) => (int)$g['owner_id'] > 0));
|
||
?>
|
||
<?php if ($pgMine): ?>
|
||
<ul class="group-manage" style="margin-top:14px">
|
||
<?php foreach ($pgMine as $g): $pgGid = (int)$g['id']; ?>
|
||
<li class="group-manage-row">
|
||
<form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $pgGid ?>">
|
||
<input type="hidden" name="scope" value="me">
|
||
<label class="sr-only" for="gname<?= $pgGid ?>">分组名</label>
|
||
<input class="input" id="gname<?= $pgGid ?>" name="name" maxlength="20"
|
||
value="<?= e((string)$g['name']) ?>">
|
||
<button class="btn btn-ghost btn-sm" type="submit">改名</button>
|
||
</form>
|
||
<span class="group-count muted small"><?= (int)($pgCounts[$pgGid] ?? 0) ?> 人</span>
|
||
<?php /* 删除做成两步(<details>),不用 JS 也能防手滑 —— 一点就没了太容易出事 */ ?>
|
||
<details class="del-wrap">
|
||
<summary class="btn btn-ghost btn-sm">删除</summary>
|
||
<form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="id" value="<?= $pgGid ?>">
|
||
<input type="hidden" name="scope" value="me">
|
||
<span class="small muted">这组的人会变成「未分类」,确定?</span>
|
||
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
|
||
</form>
|
||
</details>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php endif; ?>
|
||
|
||
<?php if (\App\Auth::isAdmin()): ?>
|
||
<p class="small muted" style="margin:16px 0 0">
|
||
公共分组在「<a href="<?= e(url('settings')) ?>#public-groups">系统设置</a>」里维护。
|
||
</p>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php unset($pgGroups, $pgCounts, $pgRosterN, $pgTotal, $pgMine, $pgGid, $pgOwn, $g); ?>
|