v52:旅程 CalDAV 结束时间 / 笔记(标题·段落下加段加图·逐段就地编辑)/ 名册自动匹配与设置页 / 模块设置整合表
用户 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% 在别的批次数据上假红)。
This commit is contained in:
@@ -1375,6 +1375,30 @@ switch ($r) {
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 名册设置(v52 用户 2026-10-15 第 6 条):设置下拉里「名册分组」的目标页。
|
||||
*
|
||||
* 内容 = 从个人设置**整块搬**过来的「我的分组」(见 templates/_person_groups.php)。
|
||||
* ⚠️ 保存仍走既有的 `settings/groups/*` 路由(一个字段没改)——
|
||||
* 分组是"我的分组",与它挂在哪一页无关;换路由只会多一处要维护的东西。
|
||||
*/
|
||||
case 'people/settings':
|
||||
Auth::requireLogin();
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能改分组。');
|
||||
redirect('people');
|
||||
}
|
||||
$gs = PersonGroup::orderFor($familyId, $me);
|
||||
View::render('people/settings', [
|
||||
'title' => '名册设置',
|
||||
'groups' => $gs,
|
||||
'groupCounts' => PersonGroup::counts($familyId, $me),
|
||||
'rosterCount' => count(Person::roster($familyId, $me, 'member', '')),
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
|
||||
* 则这个名片,自动出现在这个用户的名册中」)。
|
||||
@@ -1688,6 +1712,76 @@ switch ($r) {
|
||||
redirect('settings/me', [], 'modules');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 🔴 v52(用户 2026-10-15 第 7 条):**一站式保存模块设置**。
|
||||
*
|
||||
* 用户原话:「个人设置里面,模块相关的设置太多了,应该**整合**,先列出来所有模块,
|
||||
* 中间有一列,让他勾选是否在导航显示,再给他一列,让他选择是否在首页显示,
|
||||
* 再给他一列,让他选择颜色,最后给他一列,让他**调整顺序**,
|
||||
* 这样就把几个设置,整合到一起了,更加简洁。」
|
||||
*
|
||||
* ⇒ 原来那是**三张卡三个表单**(导航条勾选+排序 / 首页勾选+排序 / 主题色),
|
||||
* 内容高度重叠(同一串模块名出现两遍,颜色又另起一张),用户看得累、改起来要点三次保存。
|
||||
* 现在合成**一张表**:一行一个模块,四列(导航 / 首页 / 颜色 / 顺序),一次保存。
|
||||
*
|
||||
* ⚠️ 底层存储**一个都没改**(仍是 nav_modules / home_modules / module_colors 三个键):
|
||||
* 这条路由只是把三个 save 按顺序调一遍 —— 老数据天然兼容,
|
||||
* 别的入口(老表单、脚本、移动端 API)照旧能写。
|
||||
* ⚠️ 权限仍是分开的:导航/首页是**每人一份**,颜色是**家庭一份**(非管理员只能看)。
|
||||
* 非管理员提交时**忽略颜色**那一列(不能因为"合并成一张表"就顺手给了他改配色的权力)。
|
||||
*/
|
||||
case 'settings/modules_save':
|
||||
Auth::requireLogin();
|
||||
csrf_check();
|
||||
$me = (int)Auth::personId();
|
||||
$familyId = (int)Auth::familyId();
|
||||
|
||||
// 顺序:表里每行自带 `order[]`(拖动搬的是整行,提交上来就是屏幕顺序)
|
||||
$order = array_values(array_filter(
|
||||
array_map('strval', (array)($_POST['order'] ?? [])),
|
||||
static fn($k) => Module::exists($k)
|
||||
));
|
||||
// 补上没出现在表单里的模块(理论上不会,但别让它们"消失")
|
||||
foreach (Module::keys() as $k) {
|
||||
if (!in_array($k, $order, true)) {
|
||||
$order[] = $k;
|
||||
}
|
||||
}
|
||||
|
||||
$navOn = array_map('strval', (array)($_POST['nav'] ?? []));
|
||||
$homeOn = array_map('strval', (array)($_POST['home'] ?? []));
|
||||
$move = (string)($_POST['move'] ?? '');
|
||||
|
||||
// ▲▼ 兜底(没有 JS 时也能排序):在当前顺序里跟邻居换一位
|
||||
if ($move !== '' && strpos($move, ':') !== false) {
|
||||
[$dir, $key] = explode(':', $move, 2);
|
||||
$i = array_search($key, $order, true);
|
||||
if ($i !== false) {
|
||||
$j = $dir === 'up' ? (int)$i - 1 : (int)$i + 1;
|
||||
if ($j >= 0 && $j < count($order)) {
|
||||
[$order[$i], $order[$j]] = [$order[$j], $order[$i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按 order 的顺序取交集 —— 顺序就是用户拖出来的顺序
|
||||
$navKeys = array_values(array_filter($order, static fn($k) => in_array($k, $navOn, true)));
|
||||
$homeKeys = array_values(array_filter($order, static fn($k) => in_array($k, $homeOn, true)));
|
||||
if (!$navKeys) {
|
||||
$navKeys = Module::DEFAULT_NAV; // 一个都不勾 = 导航条会空掉,退回默认
|
||||
}
|
||||
Module::saveNav($me, $navKeys);
|
||||
HomeMods::save($me, $homeKeys);
|
||||
|
||||
// 主题色:家庭一份,只有管理员能改(非管理员提交时**静默忽略**,不报错)
|
||||
if (Auth::isAdmin() && isset($_POST['c']) && is_array($_POST['c'])) {
|
||||
Module::saveColors($familyId, (array)$_POST['c']);
|
||||
}
|
||||
|
||||
flash('ok', '模块设置已保存。');
|
||||
redirect('settings/me', [], 'modules');
|
||||
break;
|
||||
|
||||
// ==================== 分组的顺序 ====================
|
||||
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
|
||||
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
|
||||
@@ -1878,6 +1972,68 @@ switch ($r) {
|
||||
$errors['contacts'] = $ctErr;
|
||||
}
|
||||
|
||||
/* 🔴🔴 v52(用户 2026-10-15 第 5 条):**先问一句"是不是这个人"**。
|
||||
用户原话:「当其他的用户新增名册中的人员时,当输入的信息,例如姓名、手机号、身份证号,
|
||||
和名册中已有人员匹配时,提示他**是不是这个人**,如果是,
|
||||
**直接让他添加到自己的名册中即可**。」
|
||||
⇒ 校验通过后、**真的建人之前**按 姓名/手机号/身份证号 找一找家里人;
|
||||
找到了就让用户确认(`confirm_existing` 带上来匹配到的 id 才真加)。
|
||||
⚠️ 只在校验通过时问 —— 表还没填对就先问"是不是这个人"是本末倒置。
|
||||
⚠️ 判"是不是第一次提交"用 `array_key_exists('confirm_existing')`:
|
||||
没这个字段 = 第一次提交 → 出确认卡;
|
||||
='1' → 用户已确认"就是这个人" ⇒ 不新建,直接加进我的名册;
|
||||
='0' → 用户点了"不是,另建一个" ⇒ 照常往下新建。
|
||||
⚠️ 不能光看"匹不匹配"就替用户决定 —— 同名的人真的存在("张伟"),
|
||||
系统只能**问一句**,决定权在用户。 */
|
||||
if (!$errors && !array_key_exists('confirm_existing', $_POST)) {
|
||||
$hit = Person::matchExisting($familyId, $d);
|
||||
if ($hit) {
|
||||
set_old($_POST);
|
||||
/* 把「是不是这个人」做成**表单页里的一个确认条**,而不是单独一页 ——
|
||||
⚠️ 关键理由:单独一页就得把用户刚填的一整张表(含工作经历/补充联系方式
|
||||
这些**数组字段**)原样回传,任何一类字段漏传都会静默丢数据。
|
||||
在**同一张表**里问,字段天然全在,一个都不会丢。 */
|
||||
View::render('people/form', [
|
||||
'title' => '添加成员',
|
||||
'person' => null,
|
||||
'errors' => [],
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue'=> $d['id_card'],
|
||||
'canEditAll' => true,
|
||||
'isSelf' => false,
|
||||
'contacts' => $contacts,
|
||||
'jobRows' => (array)($d['jobs'] ?? []),
|
||||
'myNote' => trim((string)($_POST['my_note'] ?? '')),
|
||||
'confirmHit' => $hit,
|
||||
'confirmWhy' => (string)($hit['_match'] ?? 'name'),
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
// 用户确认"就是这个人" ⇒ 不新建,直接加进我的名册(正是用户要的"直接添加")
|
||||
if (array_key_exists('confirm_existing', $_POST)
|
||||
&& (string)$_POST['confirm_existing'] === '1') {
|
||||
$pid = (int)($_POST['existing_id'] ?? 0);
|
||||
$ex = $pid > 0 ? Person::find($pid) : null;
|
||||
if ($ex && (int)$ex['family_id'] === $familyId) {
|
||||
$gid = (int)($_POST['group_id'] ?? 0);
|
||||
$okGids = array_map('intval', array_column(
|
||||
PersonGroup::orderFor($familyId, (int)Auth::personId()), 'id'));
|
||||
if ($gid > 0 && !in_array($gid, $okGids, true)) {
|
||||
$gid = 0;
|
||||
}
|
||||
$newly = PersonRoster::ensure($familyId, (int)Auth::personId(), $pid,
|
||||
$gid > 0 ? $gid : null, 'self');
|
||||
clear_old();
|
||||
flash('ok', $newly
|
||||
? '已把「' . (string)$ex['name'] . '」加进你的名册 —— 资料共用同一份,不用重录。'
|
||||
: '「' . (string)$ex['name'] . '」本来就在你的名册里。');
|
||||
redirect('people/show', ['id' => $pid]);
|
||||
}
|
||||
flash('err', '那个人不在了,重新填一次吧。');
|
||||
redirect('people/new');
|
||||
}
|
||||
|
||||
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
|
||||
// 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。
|
||||
// 现在是:人先存进去,头像的事单独提示。
|
||||
@@ -7094,8 +7250,13 @@ $want = array_keys($want);
|
||||
redirect('notes/form', ['id' => $id]);
|
||||
}
|
||||
$editMode = $id <= 0 || $wantEdit;
|
||||
/* 🔴 v52(用户第 2 条):阅读态的**浏览器标题**也用笔记自己的名字
|
||||
(与页面里那个 h1 保持一致;编辑态仍是"改笔记/写笔记")。 */
|
||||
$noteHead = trim((string)($note['title'] ?? ''));
|
||||
View::render('notes/form', [
|
||||
'title' => $editMode ? ($id > 0 ? '改笔记' : '写笔记') : '看笔记',
|
||||
'title' => $editMode
|
||||
? ($id > 0 ? '改笔记' : '写笔记')
|
||||
: ($noteHead !== '' ? $noteHead : '看笔记'),
|
||||
'editing' => $note,
|
||||
'editMode' => $editMode,
|
||||
'perm' => $perm,
|
||||
@@ -7324,6 +7485,35 @@ $want = array_keys($want);
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
break;
|
||||
|
||||
/**
|
||||
* 🔴 v52(用户 2026-10-15 第 4 条):**只改一个块**(详情页的分段编辑)。
|
||||
*
|
||||
* 与 `notes/save`(整条替换)分开是刻意的:
|
||||
* · 这条按 **块主键** 改一行,别的块一个字不动 ⇒ 用户要的"其他内容不影响";
|
||||
* · 顺带避开"整组替换会换掉块 id"的问题(前端拿着的 data-block 会失效)。
|
||||
* 返回 JSON(前端就地替换那一块的内容,不整页刷新)。
|
||||
*/
|
||||
case 'notes/block':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
// ⚠️ 前端可能用 fetch 且带 `X-Requested-With`;这里**不看**那个头 ——
|
||||
// 分支只此一处用途,永远回 JSON(不像其它路由要兼顾整页提交)。
|
||||
$noteId = (int)($_POST['note_id'] ?? 0);
|
||||
$blockId = (int)($_POST['block_id'] ?? 0);
|
||||
$kind = (string)($_POST['kind'] ?? 'text');
|
||||
$content = (string)($_POST['content'] ?? '');
|
||||
$row = \App\Note::updateBlock($familyId, $noteId, $blockId, $me, $kind, $content);
|
||||
if ($row === null) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'msg' => '这一块改不了(没权限,或它已经不在了)。'],
|
||||
JSON_UNESCAPED_UNICODE);
|
||||
break;
|
||||
}
|
||||
echo json_encode(['ok' => true, 'block' => $row], JSON_UNESCAPED_UNICODE);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
View::render('error', [
|
||||
|
||||
Reference in New Issue
Block a user