diff --git a/app/ContactImporter.php b/app/ContactImporter.php index 24a401c..4bcfefe 100644 --- a/app/ContactImporter.php +++ b/app/ContactImporter.php @@ -75,6 +75,18 @@ final class ContactImporter if ($hit !== null) { // ---- ② 命中:只补空字段 ---- $filled = self::fillMissing($hit, $r, $meId); + /* 🔴🔴 v52(用户 2026-10-15 第 5 条):「而用户导入数据时,**也是一样的**, + 如果名册中有这个人,就不用再新增了,而是根据名字、手机号等信息 + **自动匹配后添加到这个用户的名册中**。」 + ⇒ 命中已有的人时,**必须**把他也挂进**导入者的名册** —— + 否则会出现最费解的一种结果:导入文件里有这个人、系统里也有这个人, + 但导入完**我的名册里还是看不到他**(他只在别人名册里), + 用户只会觉得"导入漏了人"。 + ⚠️ 这一段原先只在"没命中 → 新建"那条路上挂名册(见下面 ③ 的 ensure), + 命中那条路**没有** —— 正是本批要补的缺口。 + ⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */ + PersonRoster::ensure($familyId, $meId, (int)$hit['id'], + $groupId > 0 ? $groupId : null, 'self'); if ($filled) { $updated++; $log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled]; diff --git a/app/Note.php b/app/Note.php index f840f59..2bc0aef 100644 --- a/app/Note.php +++ b/app/Note.php @@ -589,6 +589,68 @@ final class Note )->execute([$id, $familyId, $personId]); } + /** + * 🔴 v52(用户 2026-10-15 第 4 条):**只改一个块**。 + * + * 用户原话:「笔记这个功能模块,在详情页面,应该具备**分段编辑**功能,就是说, + * 我在一个笔记页面,可以对其中一个段落单独编辑,其他的内容不影响, + * 而不用跳转到编辑页面,因为当页面中的分块很多的时候,跳转到编辑页面 + * 难以定位到想要编辑的那一个内容块。在鼠标划过内容块的时候,这个内容块 + * 右侧出现一个编辑按钮,点击后当前的内容块变成编辑框,编辑完成后, + * 点击保存,页面动态更新。」 + * + * 为什么**不**复用 `update()`: + * `update()` 是"整组替换"(`writeBlocks` 先 DELETE 再 INSERT)—— + * 那会**换掉每个块的 id**,前端拿着的 `data-block` 立刻失效, + * 而且并发下"他改第 2 段、我改第 3 段"会互相覆盖。 + * 这里按**主键**改一行,其它块一个字都不动 —— 正是用户要的"其他内容不影响"。 + * + * ⚠️ 权限与 `update()` 同一把尺:先 `find()`(带家庭 + 可见性校验)再 `canWrite()`。 + * **不能**只看 `person_id = 我` —— 共享给"可写"的人也该能分段改, + * 与整条编辑的权限保持一致(否则同一件事两个入口两种结果)。 + * + * @return array|null 成功返回更新后的块(含 html,供前端就地换内容);失败 null + */ + public static function updateBlock(int $familyId, int $noteId, int $blockId, + int $personId, string $kind, string $content): ?array + { + $note = self::find($familyId, $noteId, $personId); + if (!$note || !self::canWrite($note, $personId)) { + return null; + } + // 归一:与 cleanBlocks 同一条路(list → Markdown、图片 key 去空白、文本去首尾换行) + $clean = self::cleanBlocks([['kind' => $kind, 'content' => $content]]); + if (!$clean) { + return null; + } + $b = $clean[0]; + $db = Database::get(); + // ⚠️ `AND note_id = ?` 不能省:块 id 是全局自增的,少了它就能改**别人笔记**里的块 + $st = $db->prepare( + 'UPDATE note_blocks SET kind = ?, content = ? WHERE id = ? AND note_id = ?' + ); + $st->execute([$b['kind'], $b['content'], $blockId, $noteId]); + if ($st->rowCount() === 0) { + // 没这一行(或内容一字未改 —— MySQL 的 rowCount 在"值相同"时也是 0, + // 所以这里不当作错误,照常把当前值查回去给前端) + $chk = $db->prepare('SELECT id FROM note_blocks WHERE id = ? AND note_id = ?'); + $chk->execute([$blockId, $noteId]); + if (!$chk->fetchColumn()) { + return null; + } + } + // 笔记的 updated_at 要跟着动(列表按它倒序,改了一段却不刷新时间会让人以为没改成) + $db->prepare('UPDATE notes SET updated_at = NOW() WHERE id = ?')->execute([$noteId]); + // 图片块:把本次上传的草稿认领过来(与整条保存同一套,见 claimUploads) + self::claimUploads($familyId, $noteId, $clean); + return [ + 'id' => $blockId, + 'kind' => $b['kind'], + 'content' => $b['content'], + 'html' => self::blockHtml($b['kind'], $b['content']), + ]; + } + /** * 一条笔记的**文字摘要**(列表卡片上露个开头,不必渲染全部块)。 * 图片块显示成 `[图片]`,清单块显示成 `· 首项`。 diff --git a/app/Person.php b/app/Person.php index e25057e..1838545 100644 --- a/app/Person.php +++ b/app/Person.php @@ -154,6 +154,75 @@ final class Person return $row ?: null; } + /** + * 🔴 v52(用户 2026-10-15 第 5 条):按**姓名 / 手机号 / 身份证号**找一找 + * "这个人家里是不是已经有了"。 + * + * 用户原话:「名册功能,**从已有家里人中挑选这个按钮删除**,改为:当其他的用户新增名册中的 + * 人员时,当输入的信息,例如**姓名、手机号、身份证号**,和名册中已有人员匹配时, + * 提示他**是不是这个人**,如果是,直接让他添加到自己的名册中即可。 + * 而用户**导入数据**时,也是一样的,如果名册中有这个人,就不用再新增了, + * 而是根据名字、手机号等信息自动匹配后添加到这个用户的名册中。」 + * + * ⚠️ 这是"判重"而不是"登录找人",所以: + * · 范围是**整个家庭**(`family_id`),不是"我的名册"—— 别人先录的也该能认出来; + * · **排除我自己在册的那个 id**(改资料时别把自己匹配上,见调用点传 `$exceptId`)。 + * ⚠️ 手机号与身份证号都是**加密存**的: + * · 手机号有 `phone_hash`(确定性哈希)→ 可以一条 SQL 精确命中,**优先走它**; + * · 身份证号**没有**哈希列 → 只能把整表拉出来逐行解密比对。 + * 家庭名册是百人量级,一次全解密可接受(这与 v51 那次手机号归一迁移同一量级)。 + * + * @return array 命中的那条 person 行(附 `_match` = 命中原因),没命中返回 [] + */ + public static function matchExisting(int $familyId, array $d, int $exceptId = 0): array + { + $name = trim((string)($d['name'] ?? '')); + $phone = self::normPhone($d['phone'] ?? ''); + $idCard = trim((string)($d['id_card'] ?? '')); + if ($name === '' && $phone === '' && $idCard === '') { + return []; + } + + // ① 手机号优先:有确定性哈希,一条 SQL 就能精确命中(最可靠,不会重名误判) + if ($phone !== '') { + $st = Database::get()->prepare( + 'SELECT * FROM persons WHERE family_id = ? AND phone_hash = ? AND id <> ? LIMIT 1' + ); + $st->execute([$familyId, Crypto::hash($phone), $exceptId]); + $row = $st->fetch(); + if ($row) { + return $row + ['_match' => 'phone']; + } + } + + // ② 身份证号 / 姓名:拉一次全家的行,在 PHP 里比(身份证要解密才能比) + $st = Database::get()->prepare( + 'SELECT * FROM persons WHERE family_id = ? AND id <> ? AND kind = ?' + ); + $st->execute([$familyId, $exceptId, 'member']); + $lower = function (string $s): string { + return mb_strtolower(trim($s), 'UTF-8'); + }; + $nameHit = []; + foreach ($st->fetchAll() as $row) { + if ($idCard !== '') { + $ic = Crypto::decrypt((string)($row['id_card_encrypted'] ?? '')); + if ($ic !== '' && $ic === $idCard) { + return $row + ['_match' => 'id_card']; + } + } + // ⚠️ 姓名命中先**记下**、不立刻返回:同名的人真的存在("张伟"), + // 先看完有没有身份证/手机号这种**精确**命中,再退到姓名。 + if ($name !== '' && $lower((string)$row['name']) === $lower($name)) { + $nameHit = $row; + } + } + if ($nameHit) { + return $nameHit + ['_match' => 'name']; + } + return []; + } + /** * 新增。手机号是敏感字段,进来就加密 + 哈希;三个生日由 Birthday 规范化。 * @return int 新 id diff --git a/app/PersonRoster.php b/app/PersonRoster.php index 1ce2e3f..ad4660b 100644 --- a/app/PersonRoster.php +++ b/app/PersonRoster.php @@ -132,6 +132,24 @@ final class PersonRoster return array_keys(self::mine($familyId, $ownerId)); } + /** + * 这个人在不在**我的**名册里(v52 新增)。 + * + * ⚠️ 为什么另起一个方法而不是让调用方写 `in_array($pid, visibleIds(...), true)`: + * 本项目在 v50 栽过一次「同名方法不同返回形状」—— + * `visibleIds()` 返回的是**值列表**(id 列表),不是 id=>值 的集合, + * 调用方很容易顺手写成 `isset($ids[$pid])`(那会静默判错,见 pitfalls.md)。 + * `has()` 的返回值只有 true/false 一种形状,名字也不与别处撞 —— + * "问在不在"就用它,别再去碰 `visibleIds` 的形状。 + */ + public static function has(int $familyId, int $ownerId, int $personId): bool + { + if ($personId <= 0) { + return false; + } + return in_array($personId, self::visibleIds($familyId, $ownerId), true); + } + public static function countFor(int $familyId, int $ownerId): int { $st = Database::get()->prepare( diff --git a/assets/css/app.css b/assets/css/app.css index 5bf9980..4b9d398 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -4765,6 +4765,18 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b .nb-btn:hover { border-color: var(--brand-2); color: var(--brand-ink); } .nb-del:hover { border-color: var(--red); color: var(--red); } .note-block-body { flex: 1 1 auto; min-width: 0; } +/* 🔴 v52(用户第 3 条):块**下方**的「+段落 / +图片」。 + 放在每段输入框的下方,写到哪一段就在哪儿加,不用划回顶部。 + ⚠️ 克制视觉:它是"随手加"的快捷入口,不该抢正文的注意力 —— + 小字、灰色、平时半透明,鼠标进到这一块才变清晰。 */ +.note-block-add { display: flex; gap: 6px; margin-top: 6px; } +.nb-add { + padding: 3px 10px; font-size: 12.5px; line-height: 1.5; cursor: pointer; + border: 1px dashed var(--line); border-radius: 999px; background: transparent; + color: var(--muted); +} +.note-block:hover .nb-add, .note-block:focus-within .nb-add { color: var(--brand-ink); } +.nb-add:hover { border-color: var(--brand-2); border-style: solid; background: #fff; } .note-block-ta { width: 100%; resize: vertical; font-size: 14.5px; line-height: 1.7; } .note-block-img { max-width: 100%; max-height: 320px; border-radius: var(--radius-s); @@ -5151,6 +5163,29 @@ h5.md-h, h6.md-h { font-size: 14px; } .note-read-title { margin: 0 0 6px; font-size: 22px; font-weight: 600; color: var(--ink); } .note-read-tags { margin: 0 0 14px; } .note-read-body { display: flex; flex-direction: column; gap: 14px; } +/* 🔴 v52(用户第 4 条):详情页的**分段编辑** —— 每一块外面包的这个容器。 + ⚠️ `position: relative` 是必须的:那个"鼠标划过才出来的编辑按钮"按它绝对定位到右上角。 + ⚠️ 不能给它背景色:它不是一张卡,只是"一块内容的边界框",加底色会让正文看起来像被框住。 */ +.note-read-item { position: relative; } +.note-read-item.is-editing { padding: 0; } +/* 编辑按钮:平时不可见(但**不能用 display:none** —— 那样焦点/无障碍都拿不到它), + 用 opacity + pointer-events 收起;鼠标进这一块、或键盘 Tab 到它,就浮出来。 */ +.note-item-edit { + position: absolute; top: 2px; right: 2px; + padding: 2px 10px; font-size: 12.5px; line-height: 1.6; + border: 1px solid var(--line); border-radius: 999px; + background: #fff; color: var(--muted); cursor: pointer; + opacity: 0; pointer-events: none; transition: opacity .12s; +} +.note-read-item:hover .note-item-edit, +.note-read-item:focus-within .note-item-edit { opacity: 1; pointer-events: auto; } +.note-item-edit:hover { border-color: var(--brand-2); color: var(--brand-ink); } +.note-read-item.is-editing .note-item-edit { display: none; } +/* 就地编辑框:与正文同宽、留出"我在改"的视觉边界 */ +.note-item-editor { display: flex; flex-direction: column; gap: 8px; } +.note-item-ta { width: 100%; resize: vertical; font-size: 15px; line-height: 1.8; } +.note-item-ops { display: flex; align-items: center; gap: 8px; } +.note-item-hint { margin: 0; font-size: 13px; color: var(--muted); } .note-read-block { font-size: 15px; line-height: 1.8; color: var(--ink); } /* 图片:默认满宽,可调(内联 style 给百分比);圆角与卡片一致 */ .note-read-fig { margin: 0; } @@ -5843,3 +5878,63 @@ h5.md-h, h6.md-h { font-size: 14px; } @media (max-width: 640px) { .fb-entry-panel, .fb-entry-panel.is-wide { width: 100%; } } + +/* ============================================================ + v52(用户 2026-10-15 第 5 条):「是不是这个人?」确认条 + ------------------------------------------------------------ + 新增成员时若按 姓名/手机号/身份证号 在家里找到匹配的人,表单页顶部出现这一条。 + ⚠️ 它**在这张表单里面**(不是单独一页)—— 见 templates/people/form.php 的长注释: + 单独一页得把整张表(含数组字段)原样回传,漏一类就静默丢数据。 + ⚠️ 视觉上要"明显但不像出错":它是个**问句**,不是错误 —— 所以用品牌淡底 + 左侧竖条, + 而不是红色告警(红色会让人以为"提交失败了")。 + ============================================================ */ +.person-confirm { + border-left: 3px solid var(--brand-2); + background: var(--brand-soft, #F3F7F7); +} +.person-hit { + display: flex; align-items: center; gap: 14px; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius-s); padding: 12px 14px; +} +.person-hit-av { flex: 0 0 auto; } +.person-hit-av img, +.person-hit-av .avatar-fallback { + width: 52px; height: 52px; border-radius: 12px; display: block; + object-fit: cover; background: var(--line-2); +} +.person-hit-av .avatar-fallback { + display: flex; align-items: center; justify-content: center; + font-size: 22px; color: var(--muted); +} +.person-hit-info { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.person-hit-ops { flex: 0 0 auto; } + +/* ============================================================ + v52(用户 2026-10-15 第 7 条):模块设置的**整合表** + ------------------------------------------------------------ + 一行一个模块,四列 = 顺序 / 模块 / 导航 / 首页 / 颜色。 + ⚠️ 原来那是三张卡(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。 + 合成一张表后用户一次就能改完 —— 表格是这种"多列同构"最自然的形态。 + ⚠️ `min-width` + 外层 `overflow-x:auto`:窄屏(375px)五列放不下时 + 表格能横向滚动,而不是把页面撑破(本项目在名册上栽过溢出)。 + ============================================================ */ +.mod-table-wrap { overflow-x: auto; } +.mod-table { width: 100%; border-collapse: collapse; min-width: 560px; } +.mod-table th, .mod-table td { + padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--line-2); + font-size: 14px; vertical-align: middle; +} +.mod-table th { font-size: 12px; color: var(--muted); font-weight: 600; } +.mod-table .mod-th-on, .mod-table .mod-td-on { width: 62px; text-align: center; } +.mod-table .mod-th-color, .mod-table .mod-td-color { width: 72px; text-align: center; } +.mod-table .mod-th-order, .mod-table .mod-td-order { width: 108px; } +.mod-table .mod-td-order { white-space: nowrap; } +.mod-table .mod-td-name { display: flex; align-items: center; gap: 8px; border-bottom: 0; } +.mod-chk { display: inline-flex; cursor: pointer; } +.mod-chk input { accent-color: var(--brand); width: 16px; height: 16px; } +.mod-move { display: inline-flex; gap: 2px; margin-left: 4px; } +.mod-color-ro { width: 22px; height: 22px; border-radius: 5px; display: inline-block; } +.mod-table .mod-row.is-dragged { opacity: .5; } +.mod-table .mod-row.is-drop-before td { border-top: 2px solid var(--brand-2); } +.mod-table .color-picker { width: 34px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 5px; background: #fff; cursor: pointer; } diff --git a/assets/js/note_editor.js b/assets/js/note_editor.js index a7820ac..fc8c34b 100644 --- a/assets/js/note_editor.js +++ b/assets/js/note_editor.js @@ -172,6 +172,28 @@ body.appendChild(pv); } wrap.appendChild(body); + + /* 🔴🔴 v52(用户 2026-10-15 第 3 条):「在编辑笔记的时候,或者在新增笔记的时候, + …加段落和加图片应该是在**输入框的下面**。否则的话,如果都在上面的话, + 我一段一段的往下写,每次要增加一个段落或者增加一个图片,我都要划上去增加一个段落, + 再划下来……所以它应该是在正文这个框的**下面**,每增加一段,就应该在这个段落的下面 + 出现加段落和加图片。上面和下(的按钮)都保留,下面再增加一个加段落和加图片的按钮。 + 就是它在随着这个输入框中的增多,一直往下走。」 + + ⇒ 每一块的**下方**再放一排「+段落 / +图片」,点了就**插在这一块后面** + (`addBlock(kind, '', wrap)` 的 afterEl 语义正是"插在它后面")。 + ⚠️ 挂在 **`body` 里**(不是挂 `wrap`):`.note-block` 是 `display:flex` 的横排 + (左边操作列 + 右边正文),挂到 `wrap` 上会变成横排的第三列(跑到输入框右边), + 而不是"在输入框下面"。挂进 `body` 才是真的在输入框下方。 + ⚠️ 顶部的 `.note-add` **保留不动** —— 用户明确说"上面和下面都保留"。 + ⚠️ 用 `data-add-below` 标记(不是 `data-add`):顶部那组是"追加到末尾", + 这一组是"插在本块后面",语义不同,共用标记会互相抢绑定。 */ + var foot = document.createElement('div'); + foot.className = 'note-block-add'; + foot.innerHTML = + '' + + ''; + body.appendChild(foot); return wrap; } @@ -274,6 +296,23 @@ // 事件委托:统一处理块上的所有按钮 list.addEventListener('click', function (e) { + /* 🔴 v52(用户第 3 条):**块内下方**的「+段落 / +图片」也走这个委托。 + ⚠️ 必须放前面判(它不带 `data-op`),否则会被下面那行 `[data-op]` 的 + `closest` 直接漏掉 —— 按钮点了没反应,而且看起来"按钮明明在"。 */ + var addBelow = e.target.closest ? e.target.closest('[data-add-below]') : null; + if (addBelow && list.contains(addBelow)) { + var thisBlock = addBelow.closest('.note-block'); + if (!thisBlock) { return; } + var k2 = addBelow.getAttribute('data-add-below'); + // 「插在这一块**后面**」—— addBlock 的 afterEl 语义正是如此 + var el2 = addBlock(k2, '', thisBlock); + if (k2 === 'image') { + pendingBlock = el2; + fileIn.click(); + } + return; + } + var btn = e.target.closest ? e.target.closest('[data-op]') : null; if (!btn || !list.contains(btn)) { return; } var op = btn.getAttribute('data-op'); diff --git a/assets/js/note_inline.js b/assets/js/note_inline.js new file mode 100644 index 0000000..2582961 --- /dev/null +++ b/assets/js/note_inline.js @@ -0,0 +1,215 @@ +/** + * 笔记详情页的**分段编辑**(v52 用户 2026-10-15 第 4 条) + * ============================================================ + * 用户原话:「笔记这个功能模块,在详情页面,应该具备分段编辑功能,就是说,我在一个笔记页面, + * 可以对其中一个段落单独编辑,其他的内容不影响,而不用跳转到编辑页面,因为当页面中的 + * 分块很多的时候,跳转到编辑页面难以定位到想要编辑的那一个内容块,在鼠标划过内容块的时候, + * 这个内容块右侧出现一个编辑按钮,点击后当前的内容块变成编辑框,编辑完成后, + * 点击保存,页面动态更新。」 + * + * 做法: + * · 模板给每一块包 `.note-read-item[data-block][data-kind][data-raw]`; + * · 鼠标划过 → CSS 让右上角的「编辑」出现; + * · 点编辑 → 这一块**就地**换成 textarea(段落)或 换图提示(图片), + * 旁边的「保存 / 取消」是就地长出来的; + * · 保存 → POST `notes/block`(**按块主键改一行**)→ 拿回 {html} → 只替换这一块的渲染。 + * + * ⚠️ 为什么必须"按块主键改一行"而不是复用整条保存: + * 整条保存是"整组替换"(先删光再插),会**换掉每个块的 id** —— + * 前端手里那个 `data-block` 当场失效;更糟的是两个人同时改不同段落会互相覆盖。 + * 按主键改就没有这些问题(用户要的正是"其他内容不影响")。 + * + * ⚠️ 与 `note_editor.js` 的分工:那个是**编辑页**的分块编辑器(整条保存), + * 这个只在**阅读态**跑。两者共用的是"渲染出的样子"(都由服务端 blockHtml 出), + * 但**不共用**任何绑定逻辑 —— 阅读页根本不加载 note_editor.js(见 layout.php 的按需加载)。 + */ +(function () { + 'use strict'; + + var body = document.querySelector('.note-read-body[data-note-id]'); + if (!body) { return; } + var noteId = parseInt(body.getAttribute('data-note-id') || '0', 10) || 0; + if (!noteId) { return; } + + var csrf = (function () { + var i = document.querySelector('input[name="_csrf"]'); + return i ? i.value : ''; + })(); + + /** 当前正在编辑的那一块(同时只允许一块进编辑态 —— 多了用户自己也乱) */ + var editing = null; + + function esc(s) { + return String(s == null ? '' : s); + } + + /* ---------------- 进入编辑态 ---------------- */ + function startEdit(item) { + if (editing === item) { return; } + if (editing) { cancelEdit(editing); } + + var kind = item.getAttribute('data-kind') || 'text'; + var raw = esc(item.getAttribute('data-raw')); + var blockId = item.getAttribute('data-block'); + + // 把原内容收起来,编辑完取消时要能还原 + item.setAttribute('data-saved-html', item.innerHTML); + + var box = document.createElement('div'); + box.className = 'note-item-editor'; + + if (kind === 'image') { + /* 图片块:分段编辑里**不做**"换图"(那是重传附件的事,编辑器里已有完整流程)。 + 这里明确告诉用户去哪儿换,别给一个点了没反应的框。 */ + box.innerHTML = + '
图片块请用右上角的「编辑」进整页编辑器替换。
' + + '(图片块)
'; + } else { + inner = '
+ 名册是按「关系分组」分栏显示的。这里有两层:
+ 公共分组由管理员在「系统设置」里维护(全家都能用);
+ 你要是觉得不够,就在下面加只给自己看的分组,别人看不到、也不受影响。
+
+ 列表里 公共 标的是大家共用的那几个。
+ 哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。
+
+ 你的名册现在有 = $pgRosterN ?> 人。
+
还没有分组。加一个吧。
+ ++ 公共分组在「系统设置」里维护。 +
+ +