From ed122c034b45054841bb61f1ee7ceb6031d7362c Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 15 Oct 2026 23:04:25 +0800 Subject: [PATCH] =?UTF-8?q?v52=EF=BC=9A=E6=97=85=E7=A8=8B=20CalDAV=20?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E6=97=B6=E9=97=B4=20/=20=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=EF=BC=88=E6=A0=87=E9=A2=98=C2=B7=E6=AE=B5=E8=90=BD=E4=B8=8B?= =?UTF-8?q?=E5=8A=A0=E6=AE=B5=E5=8A=A0=E5=9B=BE=C2=B7=E9=80=90=E6=AE=B5?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E7=BC=96=E8=BE=91=EF=BC=89/=20=E5=90=8D?= =?UTF-8?q?=E5=86=8C=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E4=B8=8E=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=20/=20=E6=A8=A1=E5=9D=97=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=95=B4=E5=90=88=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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;**编辑态仍用「改笔记/写笔记」** (编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 同步。 ③ 加段落/加图片要在**输入框下面** 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% 在别的批次数据上假红)。 --- app/ContactImporter.php | 12 ++ app/Note.php | 62 ++++++ app/Person.php | 69 +++++++ app/PersonRoster.php | 18 ++ assets/css/app.css | 95 +++++++++ assets/js/note_editor.js | 39 ++++ assets/js/note_inline.js | 215 +++++++++++++++++++++ index.php | 192 ++++++++++++++++++- templates/_person_groups.php | 117 +++++++++++ templates/layout.php | 12 ++ templates/notes/form.php | 61 ++++-- templates/people/_settings_menu.php | 45 +++++ templates/people/form.php | 60 ++++++ templates/people/index.php | 38 ++-- templates/people/settings.php | 29 +++ templates/settings/_modules.php | 176 ----------------- templates/settings/me.php | 288 +++++++++++++++------------- 17 files changed, 1183 insertions(+), 345 deletions(-) create mode 100644 assets/js/note_inline.js create mode 100644 templates/_person_groups.php create mode 100644 templates/people/_settings_menu.php create mode 100644 templates/people/settings.php delete mode 100644 templates/settings/_modules.php 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 = + '<button type="button" class="nb-add" data-add-below="text" title="在这段下面加一段">+ 段落</button>' + + '<button type="button" class="nb-add" data-add-below="image" title="在这段下面加一张图">+ 图片</button>'; + 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 = + '<p class="note-item-hint">图片块请用右上角的「编辑」进整页编辑器替换。</p>' + + '<div class="note-item-ops">' + + ' <button type="button" class="btn btn-ghost btn-sm" data-item-cancel>关闭</button>' + + '</div>'; + } else { + var ta = document.createElement('textarea'); + ta.className = 'input note-item-ta'; + ta.rows = Math.max(3, Math.min(20, raw.split('\n').length + 1)); + ta.value = raw; + box.appendChild(ta); + var ops = document.createElement('div'); + ops.className = 'note-item-ops'; + ops.innerHTML = + '<button type="button" class="btn btn-sm" data-item-save>保存</button>' + + '<button type="button" class="btn btn-ghost btn-sm" data-item-cancel>取消</button>' + + '<span class="note-item-tip small muted"></span>'; + box.appendChild(ops); + } + + item.innerHTML = ''; + item.appendChild(box); + item.classList.add('is-editing'); + editing = item; + + var ta2 = box.querySelector('textarea'); + if (ta2) { + ta2.focus(); + // 光标放到末尾(用户多半是想接着改,而不是从头重写) + try { ta2.setSelectionRange(ta2.value.length, ta2.value.length); } catch (e) {} + } + } + + /* ---------------- 取消:还原成进来之前的样子 ---------------- */ + function cancelEdit(item) { + var html = item.getAttribute('data-saved-html'); + if (html != null) { + item.innerHTML = html; + item.removeAttribute('data-saved-html'); + } + item.classList.remove('is-editing'); + if (editing === item) { editing = null; } + } + + /* ---------------- 保存:只提交这一块 ---------------- */ + function saveEdit(item) { + var ta = item.querySelector('textarea'); + var tip = item.querySelector('.note-item-tip'); + if (!ta) { return; } + var blockId = item.getAttribute('data-block'); + var kind = item.getAttribute('data-kind') || 'text'; + var val = ta.value; + + // 原样没动 → 直接退出编辑态(省一次请求,也不让"保存"按钮假忙一场) + if (val === esc(item.getAttribute('data-raw'))) { + cancelEdit(item); + return; + } + + var fd = new FormData(); + fd.append('_csrf', csrf); + fd.append('note_id', String(noteId)); + fd.append('block_id', String(blockId)); + fd.append('kind', kind); + fd.append('content', val); + + if (tip) { tip.textContent = '保存中…'; } + var btn = item.querySelector('[data-item-save]'); + if (btn) { btn.disabled = true; } + + fetch('index.php?r=notes/block', { + method: 'POST', body: fd, credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }).then(function (r) { return r.json().catch(function () { return null; }); }) + .then(function (d) { + if (!d || !d.ok || !d.block) { + if (tip) { tip.textContent = (d && d.msg) ? d.msg : '没保存上,再试一次。'; } + if (btn) { btn.disabled = false; } + return; + } + /* 成功:把这一块换成服务端渲染好的内容(**含那块「编辑」按钮** —— + 保存后要能接着再改,别让它消失)。 + ⚠️ 不能直接写 d.block.html 了事:图片/段落的**外壳**(.note-read-item / + .note-read-block)也要一起还原,否则 `data-raw` 还是旧值、 + 再点编辑会看到旧内容。 */ + var rawNew = esc(d.block.content); + item.setAttribute('data-raw', rawNew); + item.removeAttribute('data-saved-html'); + item.classList.remove('is-editing'); + + var inner = ''; + if (d.block.kind === 'image') { + // 图片块的 html 恒为空(见 Note::blockHtml),这里保留原来的 <figure> + inner = '<p class="note-item-hint">(图片块)</p>'; + } else { + inner = '<div class="note-read-block md-body">' + esc(d.block.html) + '</div>'; + } + item.innerHTML = inner + editButtonHtml(blockId); + if (editing === item) { editing = null; } + }) + .catch(function () { + if (tip) { tip.textContent = '网络出错,没保存上。'; } + if (btn) { btn.disabled = false; } + }); + } + + function editButtonHtml(blockId) { + return '<button type="button" class="note-item-edit" data-item-edit="' + esc(blockId) + + '" title="只改这一段">编辑</button>'; + } + + /* ---------------- 事件委托 ---------------- */ + body.addEventListener('click', function (e) { + var t = e.target; + if (!t || !t.closest) { return; } + + var editBtn = t.closest('[data-item-edit]'); + if (editBtn) { + var item = editBtn.closest('.note-read-item'); + if (item) { startEdit(item); } + return; + } + if (t.closest('[data-item-save]')) { + var it2 = t.closest('.note-read-item'); + if (it2) { saveEdit(it2); } + return; + } + if (t.closest('[data-item-cancel]')) { + var it3 = t.closest('.note-read-item'); + if (it3) { cancelEdit(it3); } + return; + } + /* 点在这一块**外面** → 收掉编辑态(点了别处通常就是"算了")。 + 点在自己那一块里不动作,否则写一半点到正文就没了。 */ + if (editing && !t.closest('.note-read-item')) { + cancelEdit(editing); + } + }); + + // Esc = 取消(与站内其它浮层同一手感) + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && editing) { cancelEdit(editing); } + }); + + /* Ctrl/Cmd + Enter = 保存(写长段落时手不用离开键盘) */ + body.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + var it = e.target && e.target.closest ? e.target.closest('.note-read-item') : null; + if (it) { e.preventDefault(); saveEdit(it); } + } + }); +})(); \ No newline at end of file diff --git a/index.php b/index.php index a96b01c..2fc2709 100644 --- a/index.php +++ b/index.php @@ -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', [ diff --git a/templates/_person_groups.php b/templates/_person_groups.php new file mode 100644 index 0000000..ee987fd --- /dev/null +++ b/templates/_person_groups.php @@ -0,0 +1,117 @@ +<?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); ?> \ No newline at end of file diff --git a/templates/layout.php b/templates/layout.php index b73cdf1..f58a485 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -378,5 +378,17 @@ window.FB_NOTIFY = { </script> <script src="<?= e(asset('/assets/js/filter.js')) ?>"></script> <script src="<?= e(asset('/assets/js/preview.js')) ?>"></script> +<?php /* 🔴 v52(用户第 4 条):笔记**详情页**的分段编辑(就地改一段、不跳整页编辑器)。 + ⚠️ 只在阅读态需要它 —— 判据用 `.note-read-body[data-note-id]`(模板在阅读态才输出), + 编辑页没有这个元素,因此不会白拉一份脚本。 */ ?> +<script> +(function () { + if (!document.querySelector('.note-read-body[data-note-id]')) { return; } + var s = document.createElement('script'); + s.src = <?= json_encode(asset('/assets/js/note_inline.js')) ?>; + s.defer = true; + document.head.appendChild(s); +})(); +</script> </body> </html> diff --git a/templates/notes/form.php b/templates/notes/form.php index 69e89a6..9f36521 100644 --- a/templates/notes/form.php +++ b/templates/notes/form.php @@ -74,7 +74,20 @@ $imgParts = static function (string $c): array { }; ?> <div class="page-head"> - <h1><?= $editMode ? ($isEditing ? '改笔记' : '写笔记') : '看笔记' ?></h1> + <?php + /* 🔴 v52(用户 2026-10-15 第 2 条):「笔记这个功能模块,点进去一个笔记的详情之后, + 上边的标题不是看笔记,而是**用户自己写的这个标题的名称**。」 + ⇒ 阅读态页头显示笔记自己的标题;没有标题时才退回「看笔记」。 + ⚠️ 只在阅读态这么做 —— 编辑态仍用「改笔记 / 写笔记」: + 编辑时页面标题是"我在干什么",而笔记标题就在下面第一个输入框里, + 两处都写同一个名字反而看着像重复。 + ⚠️ 用 `$noteHeadTitle`(HTTP 里也参与 <title>,见 View::render 的 title 参数)—— + 由路由传进来,见 index.php 的 notes/form 分支。 */ + $noteTitle = trim((string)($editing['title'] ?? '')); + $headText = (!$editMode && $isEditing && $noteTitle !== '') ? $noteTitle + : ($editMode ? ($isEditing ? '改笔记' : '写笔记') : '看笔记'); + ?> + <h1><?= e($headText) ?></h1> <span class="sub"> <?php if ($isEditing && !$editMode): ?> <?= e(substr((string)$editing['updated_at'], 0, 16)) ?> @@ -116,23 +129,39 @@ $imgParts = static function (string $c): array { </div> <?php endif; ?> - <div class="note-read-body"> + <div class="note-read-body" data-note-id="<?= (int)($editing['id'] ?? 0) ?>"> <?php foreach ((array)$editing['blocks'] as $b): ?> - <?php $k = (string)$b['kind']; $c = (string)$b['content']; ?> - <?php if ($k === 'image'): ?> - <?php [$key, $w] = $imgParts($c); ?> - <?php if ($key !== ''): ?> - <figure class="note-read-fig"> - <a href="<?= e(media_url($key)) ?>" data-prev> - <img src="<?= e(media_url($key)) ?>" alt="" - <?= $w > 0 ? 'style="width:' . (int)$w . '%"' : '' ?> - loading="lazy" decoding="async"> - </a> - </figure> + <?php + $k = (string)$b['kind']; $c = (string)$b['content']; + /* 🔴 v52(用户第 4 条):每一块包一层 `.note-read-item`,带**块主键** —— + 详情页的"分段编辑"要靠它定位改哪一块(AJAX 按主键改一行)。 + ⚠️ 加 `data-block` 的**同时**保留原来的类名(`note-read-fig` / `note-read-block`)—— + 现有 e2e 与 CSS 都按它们断言,改类名会静默弄坏那些检查。 */ + ?> + <div class="note-read-item" data-block="<?= (int)($b['id'] ?? 0) ?>" + data-kind="<?= e($k) ?>" data-raw="<?= e($c) ?>"> + <?php if ($k === 'image'): ?> + <?php [$key, $w] = $imgParts($c); ?> + <?php if ($key !== ''): ?> + <figure class="note-read-fig"> + <a href="<?= e(media_url($key)) ?>" data-prev> + <img src="<?= e(media_url($key)) ?>" alt="" + <?= $w > 0 ? 'style="width:' . (int)$w . '%"' : '' ?> + loading="lazy" decoding="async"> + </a> + </figure> + <?php endif; ?> + <?php else: ?> + <div class="note-read-block md-body"><?= (string)($b['html'] ?? '') ?></div> <?php endif; ?> - <?php else: ?> - <div class="note-read-block md-body"><?= (string)($b['html'] ?? '') ?></div> - <?php endif; ?> + + <?php /* 鼠标划过才出现的「编辑」——只在能写且这一块真的有主键时给 + (`$b['id']` 为 0 的块理论上不该有;给了也改不了,不如不给)。 */ ?> + <?php if ($canEdit && $canWrite && (int)($b['id'] ?? 0) > 0): ?> + <button type="button" class="note-item-edit" + data-item-edit="<?= (int)$b['id'] ?>" title="只改这一段">编辑</button> + <?php endif; ?> + </div> <?php endforeach; ?> <?php if (!$editing['blocks']): ?> diff --git a/templates/people/_settings_menu.php b/templates/people/_settings_menu.php new file mode 100644 index 0000000..aeefb3c --- /dev/null +++ b/templates/people/_settings_menu.php @@ -0,0 +1,45 @@ +<?php +/** + * 名册模块的「设置」下拉(v52 用户 2026-10-15 第 6 条)。 + * + * 用户原话:「名册右侧,**只保留一个添加成员按钮**,右侧再加一个设置按钮, + * 点击后下拉设置,第一个就是**导入联系人**,第二个就是原来个人设置那里的 + * **名册分组**,这样个人设置中就不显示名册分组的内容了。」 + * + * ⇒ 名册页右上角从此只有两颗:**添加成员** + **☰ 设置**(设置恒在最右,全站统一)。 + * 原来散着的那几颗(从已有家里人里挑 / 导入联系人 / …)全部收进这个下拉。 + * + * 两行(顺序是用户点名的): + * ① 导入联系人 → `people/import` + * ② 名册分组 → `people/settings`(从个人设置搬过来的那一块) + * + * 用法(require 前 set 好): + * $plSetCurrent 当前页 key('import'|'groups')用来高亮;'' 也行 + */ +$plSetCurrent = (string)($plSetCurrent ?? ''); +$plSetOn = static function (string $key) use ($plSetCurrent): string { + return $plSetCurrent === $key ? ' class="on"' : ''; +}; +$plSetIcon = static function (string $name): string { + $paths = [ + 'import' => '<path d="M8 2.6v7.2M5 6.8l3 3 3-3M3.2 13.4h9.6"/>', + 'groups' => '<rect x="2" y="2.6" width="5" height="5" rx="1"/><rect x="9" y="2.6" width="5" height="5" rx="1"/>' + . '<rect x="2" y="8.6" width="5" height="5" rx="1"/><rect x="9" y="8.6" width="5" height="5" rx="1"/>', + ]; + $d = $paths[$name] ?? ''; + if ($d === '') { + return ''; + } + return '<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"' + . ' fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"' + . ' stroke-linejoin="round">' . $d . '</svg>'; +}; +?> +<details class="movie-set menu"> + <summary class="btn btn-icon" title="名册设置" aria-label="名册设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary> + <div class="menu-list"> + <a href="<?= e(url('people/import')) ?>"<?= $plSetOn('import') ?>><?= $plSetIcon('import') ?><span>导入联系人</span></a> + <a href="<?= e(url('people/settings')) ?>"<?= $plSetOn('groups') ?>><?= $plSetIcon('groups') ?><span>名册分组</span></a> + </div> +</details> +<?php unset($plSetCurrent, $plSetOn, $plSetIcon); ?> \ No newline at end of file diff --git a/templates/people/form.php b/templates/people/form.php index a087229..8d4edc6 100644 --- a/templates/people/form.php +++ b/templates/people/form.php @@ -69,6 +69,66 @@ $ctHints = [ <input type="hidden" name="id" value="<?= (int)$person['id'] ?>"> <?php endif; ?> + <?php /* 🔴🔴 v52(用户 2026-10-15 第 5 条):「是不是这个人?」 + 新增成员时,若按 姓名 / 手机号 / 身份证号 在家里找到匹配的人, + **就在这张表里问一句** —— 而不是跳到一个单独的确认页。 + ⚠️ 关键理由:单独一页要把用户刚填的一整张表(含工作经历、补充联系方式 + 这些**数组字段**)原样回传;漏传任何一类都会**静默丢数据**。 + 在同一张表里问,字段天然全在。 + ⚠️ 两个按钮提交的就是**这张表**,靠 `confirm_existing` 的值分流: + 1 = 就是这个人(不新建,直接加进我的名册);0 = 不是,另建一条。 */ ?> + <?php if (!empty($confirmHit)): ?> + <?php + $cfWhy = [ + 'phone' => '手机号一样', + 'id_card' => '身份证号一样', + 'name' => '姓名一样', + ][(string)($confirmWhy ?? 'name')] ?? '信息很像'; + $cfPid = (int)($confirmHit['id'] ?? 0); + $cfInMine = \App\PersonRoster::has((int)\App\Auth::familyId(), (int)\App\Auth::personId(), $cfPid); + ?> + <div class="card card-pad person-confirm" id="personConfirm"> + <h2 class="section-title"> + 家里已经有这个人了 —— 是他吗? + <span class="hint"><?= e($cfWhy) ?></span> + </h2> + <p class="small muted" style="margin:0 0 12px"> + 如果是他,直接加进你的名册就行 —— <b>资料共用同一份</b>,不用再录一遍, + 以后你们改的也是同一个人。 + <br>如果不是(只是重名),点「不是,是另一个人」,我就按你填的当新人建一条。 + </p> + + <div class="person-hit"> + <span class="person-hit-av"> + <?php if (!empty($confirmHit['avatar'])): ?> + <img src="<?= e(media_url((string)$confirmHit['avatar'])) ?>" alt=""> + <?php else: ?> + <span class="avatar-fallback"><?= e(mb_substr((string)($confirmHit['name'] ?? ''), 0, 1, 'UTF-8')) ?></span> + <?php endif; ?> + </span> + <span class="person-hit-info"> + <b><?= e((string)($confirmHit['name'] ?? '')) ?></b> + <?php if (!empty($confirmHit['is_placeholder'])): ?><span class="node-badge">虚拟人物</span><?php endif; ?> + <?php if ($cfInMine): ?><span class="node-badge badge-open">已在你的名册</span><?php endif; ?> + <span class="small muted"> + <?php if ((string)($confirmHit['org'] ?? '') !== ''): ?><?= e((string)$confirmHit['org']) ?> · <?php endif; ?> + <?php if ((string)($confirmHit['title'] ?? '') !== ''): ?><?= e((string)$confirmHit['title']) ?><?php endif; ?> + </span> + </span> + <span class="person-hit-ops"> + <a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => $cfPid])) ?>" + target="_blank" rel="noopener">先看看他的资料</a> + </span> + </div> + + <div class="btn-row" style="margin-top:16px"> + <button class="btn" type="submit" name="confirm_existing" value="1">就是这个人,加进我的名册</button> + <button class="btn btn-ghost" type="submit" name="confirm_existing" value="0">不是,是另一个人</button> + <input type="hidden" name="existing_id" value="<?= $cfPid ?>"> + </div> + </div> + <?php endif; ?> + <div class="card card-pad"> <div class="form-grid"> diff --git a/templates/people/index.php b/templates/people/index.php index fc69ff3..24ec239 100644 --- a/templates/people/index.php +++ b/templates/people/index.php @@ -243,19 +243,20 @@ $renderGrid = function (array $list) use ($meId, $canMove): void { 共 <b data-roster-total><?= $total ?></b> 人<?= $tagCloud && $q === '' ? ',有 ' . count($tagCloud) . ' 个标签' : '' ?> </span> <div class="actions"> - <?php if ($canMove && $pickable > 0): ?> - <a class="btn btn-ghost btn-sm" href="<?= e(url('people/pick')) ?>"> - 从家里已有的人里挑(<?= $pickable ?>) - </a> - <?php endif; ?> <?php if ($canMove): ?> - <?php /* 用户 2026-10-03 第 1 条:名册导入入口。 - ⚠️ 放在「添加成员」**左边**(次要动作在左、主要动作在右)—— - 日常还是一个个加,导入是换手机/换号时的一次性动作。 */ ?> - <a class="btn btn-ghost btn-sm" href="<?= e(url('people/import')) ?>">导入联系人</a> - <?php endif; ?> - <?php if (\App\Auth::isAdmin()): ?> + <?php /* 🔴🔴 v52(用户 2026-10-15 第 6 条):「名册右侧,**只保留一个添加成员按钮**, + 右侧再加一个设置按钮,点击后下拉设置,第一个就是导入联系人, + 第二个就是原来个人设置那里的名册分组,这样个人设置中就不显示名册分组的内容了。」 + ⇒ 右上角只剩两颗:**添加成员** + **☰ 设置**(设置恒在最右,全站统一)。 + ⚠️ 原来那两颗去哪了: + · 「从家里已有的人里挑」→ **整颗删掉**(用户第 5 条:不再让人手动挑, + 改由"新增/导入时自动按姓名/手机号/身份证匹配并问一句"来替代); + · 「导入联系人」→ 收进 ☰ 设置里(第一行)。 */ ?> <a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a> + <?php $plSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?> + <?php elseif (\App\Auth::isAdmin()): ?> + <a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a> + <?php $plSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?> <?php endif; ?> </div> </div> @@ -285,21 +286,18 @@ $renderGrid = function (array $list) use ($meId, $canMove): void { <?php if ($q !== ''): ?> <b>没有匹配的人</b> 换个词试试。 - <?php elseif ($pickable > 0): ?> - <b>你的名册还是空的</b> - 家里已经有 <?= (int)$pickable ?> 个人了 —— 直接挑过来就行,不用重新录一遍。 - <div class="btn-row" style="justify-content:center;margin-top:14px"> - <a class="btn" href="<?= e(url('people/pick')) ?>">从家里已有的人里挑</a> - <?php if (\App\Auth::isAdmin()): ?> - <a class="btn btn-ghost" href="<?= e(url('people/new')) ?>">添加一个新人</a> - <?php endif; ?> - </div> <?php else: ?> <b>名册还是空的</b> <?php if (\App\Auth::isAdmin()): ?> + <?php /* 🔴 v52(用户第 6 条):这里原来挂着「从家里已有的人里挑」按钮 —— + 那条入口已按用户第 5 条的要求**整颗删掉**(改成"新增/导入时自动匹配")。 + 现在只留"添加成员",以及一句说明:家里已有的人会被自动认出来。 */ ?> 把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。 + <br>家里已经有的人(别人先录的)不用重录 —— 录入时填上姓名或手机号, + 系统会认出来并问你要不要直接加进你的名册。 <div class="btn-row" style="justify-content:center;margin-top:14px"> <a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a> + <a class="btn btn-ghost" href="<?= e(url('people/import')) ?>">导入联系人</a> </div> <?php else: ?> 等家里人把你加进来,或者让管理员给你开个账号。 diff --git a/templates/people/settings.php b/templates/people/settings.php new file mode 100644 index 0000000..4d82819 --- /dev/null +++ b/templates/people/settings.php @@ -0,0 +1,29 @@ +<?php +/** + * 名册设置(v52 用户 2026-10-15 第 6 条)。 + * + * 用户原话:「名册右侧,只保留一个添加成员按钮,右侧再加一个设置按钮, + * 点击后下拉设置,第一个就是导入联系人,第二个就是**原来个人设置那里的名册分组**, + * 这样**个人设置中就不显示名册分组的内容**了。」 + * + * ⇒ 这一页 = 「设置 → 名册分组」进去的那一页,内容就是从个人设置**整块搬**过来的 + * `_person_groups.php`(表单 action 一个字段没改,仍指向 settings/groups/*)。 + * + * ⚠️ 为什么不做成"个人设置那一块的副本":两份一模一样的分组管理界面, + * 改一处忘一处是必然的(本项目在"设置页"上已经踩过这个)。 + * 所以是**真的搬走** —— 个人设置里那一块删掉了,这里 require 同一个片段。 + * + * 变量: + * $groups / $groupCounts / $rosterCount 见 _person_groups.php 的说明 + */ +?> +<div class="page-head"> + <h1>名册设置</h1> + <span class="sub">分组</span> + <div class="actions"> + <a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">返回名册</a> + <?php $plSetCurrent = 'groups'; require __DIR__ . '/_settings_menu.php'; ?> + </div> +</div> + +<?php require __DIR__ . '/../_person_groups.php'; ?> \ No newline at end of file diff --git a/templates/settings/_modules.php b/templates/settings/_modules.php deleted file mode 100644 index 6680992..0000000 --- a/templates/settings/_modules.php +++ /dev/null @@ -1,176 +0,0 @@ -<?php -/** - * 个人设置里的两块「模块」设置(用户 2026-10-01 第 10、11 条): - * - * ① **导航条上显示哪些模块、什么顺序** —— 每人一份(App\Module::navFor 读的就是它)。 - * 勾选 = 出现在导航条上;拖动 / ▲▼ = 排序。 - * ② **每个模块的主题色** —— **家庭一份**(settings('module_colors')), - * 所以只有管理员能改:一个家认同一套配色,不然同一个"观影"在爸爸那屏是紫的、 - * 妈妈那屏是绿的,反而记不住。非管理员在这里只看得到色板。 - * - * 变量(require 前设好): - * $navKeys 我当前导航条上的顺序(只含显示的) - * $colors key => '#rrggbb' - */ -$allKeys = \App\Module::keys(); -$onKeys = array_map('strval', (array)($navKeys ?? [])); -// 显示顺序:已勾选的按现有顺序在前,未勾选的按 LIST 顺序在后(这样"关掉再打开"位置不跳) -$ordered = array_values(array_filter($allKeys, static fn($k) => in_array($k, $onKeys, true))); -foreach ($allKeys as $k) { - if (!in_array($k, $ordered, true)) { - $ordered[] = $k; - } -} -$isAdmin = \App\Auth::isAdmin(); -?> -<!-- ==================== 导航条上的模块 ==================== --> -<div class="card card-pad" id="modules" style="margin-top:20px"> - <h2 class="section-title"> - 导航条上的模块 - <span class="hint">勾上才会出现在顶部,拖动可改顺序</span> - </h2> - <p class="small muted" style="margin:0 0 16px"> - 模块越来越多,但每个人关心的不一样 —— 这里只挑你常用的几个上导航条,其余的仍然能 - 从别处进(比如观影里还能进影院管理)。关掉的模块<b>不会丢数据</b>,只是从顶栏收起来。 - </p> - - <form method="post" action="<?= e(url('settings/nav_save')) ?>" id="navForm"> - <?= csrf_field() ?> - <ol class="order-list mod-order" id="navOrderList"> - <?php foreach ($ordered as $i => $k): ?> - <li class="order-row mod-row" draggable="true" data-key="<?= e($k) ?>"> - <span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span> - <?php /* ⚠️ 顺序就藏在 DOM 里:每个 <li> 自带一个 order[] 隐藏域, - 拖动搬的是整个 <li>,所以提交上来的顺序天然就是屏幕上的顺序。 - 没有 JS 时 ▲▼ 按钮走同一份表单(服务端按 move 参数交换后再存)。 */ ?> - <input type="hidden" name="order[]" value="<?= e($k) ?>"> - <label class="mod-pick"> - <input type="checkbox" name="on[]" value="<?= e($k) ?>" - <?= in_array($k, $onKeys, true) ? 'checked' : '' ?>> - <span class="color-preview" style="background: <?= e((string)($colors[$k] ?? '#888')) ?>;"></span> - <span class="order-name"><?= e(\App\Module::name($k)) ?></span> - </label> - <span class="order-move"> - <button class="btn btn-ghost btn-sm" type="submit" name="move" value="up:<?= e($k) ?>" - title="上移" <?= $i === 0 ? 'disabled' : '' ?>>▲</button> - <button class="btn btn-ghost btn-sm" type="submit" name="move" value="down:<?= e($k) ?>" - title="下移" <?= $i === count($ordered) - 1 ? 'disabled' : '' ?>>▼</button> - </span> - </li> - <?php endforeach; ?> - </ol> - <div class="btn-row"> - <button class="btn" type="submit" name="save" value="1">保存导航条</button> - <a class="btn btn-ghost" href="<?= e(url('settings/me')) ?>">重来</a> - </div> - </form> -</div> - -<!-- ==================== 模块主题色 ==================== --> -<div class="card card-pad" style="margin-top:20px"> - <h2 class="section-title"> - 模块主题色 - <span class="hint"><?= $isAdmin ? '全家共用一套' : '由管理员设置' ?></span> - </h2> - <p class="small muted" style="margin:0 0 16px"> - 进到哪个模块,顶栏高亮与按钮就换成那个模块的颜色 —— 一眼就知道自己在哪儿。 - <?php if (!$isAdmin): ?> - <br>这是<b>全家共用</b>的一套配色(不然同一个「观影」在两个人屏幕上两种颜色,反而记不住), - 所以只有管理员能改。 - <?php endif; ?> - </p> - - <?php if (!$isAdmin): ?> - <div class="mod-swatches"> - <?php foreach ($allKeys as $k): ?> - <span class="mod-swatch"> - <span class="color-preview" style="background: <?= e((string)($colors[$k] ?? '#888')) ?>;"></span> - <?= e(\App\Module::name($k)) ?> - </span> - <?php endforeach; ?> - </div> - <?php else: ?> - <form method="post" action="<?= e(url('settings/module_colors')) ?>"> - <?= csrf_field() ?> - <div class="mod-colors"> - <?php foreach ($allKeys as $k): ?> - <?php $cid = 'mc-' . $k; ?> - <div class="mod-color-row"> - <span class="mod-color-name"><?= e(\App\Module::name($k)) ?></span> - <input type="color" class="color-picker" id="<?= e($cid) ?>" name="c[<?= e($k) ?>]" - value="<?= e((string)($colors[$k] ?? '#888888')) ?>"> - <span class="mod-preset"> - <?php foreach (\App\Module::PRESET_COLORS as $pc): ?> - <span class="preset-color" data-color="<?= $pc ?>" data-for="<?= e($cid) ?>" - style="background: <?= $pc ?>;" title="<?= $pc ?>"></span> - <?php endforeach; ?> - </span> - </div> - <?php endforeach; ?> - </div> - <div class="btn-row"> - <button class="btn" type="submit">保存主题色</button> - <button class="btn btn-ghost" type="submit" name="reset" value="1" - onclick="return confirm('恢复成默认配色?')">恢复默认</button> - </div> - </form> - <?php endif; ?> -</div> - -<script> -(function () { - /* ---- 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)---- - ⚠️ 遍历**所有** `ol.order-list`,而不是只找 navOrderList: - 本页现在有两块同构的清单(首页上的模块 / 导航条上的模块), - 写死 id 的话第二块就没有拖动了 —— 而且看起来"功能好好的", - 只是那一块拖不动,很难联想到是这里写死了 id。 */ - function bindSort(list) { - if (!list) return; - var dragEl = null; - Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (li) { - li.addEventListener('dragstart', function (ev) { - dragEl = li; - li.classList.add('is-dragged'); - if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; } - }); - li.addEventListener('dragend', function () { - li.classList.remove('is-dragged'); - dragEl = null; - Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (x) { - x.classList.remove('is-drop-before'); - }); - }); - li.addEventListener('dragover', function (ev) { - if (!dragEl || dragEl === li) return; - ev.preventDefault(); - var box = li.getBoundingClientRect(); - var before = ev.clientY < box.top + box.height / 2; - li.classList.toggle('is-drop-before', before); - li.classList.toggle('is-drop-after', !before); - }); - li.addEventListener('dragleave', function () { - li.classList.remove('is-drop-before', 'is-drop-after'); - }); - li.addEventListener('drop', function (ev) { - if (!dragEl || dragEl === li) return; - ev.preventDefault(); - var box = li.getBoundingClientRect(); - var before = ev.clientY < box.top + box.height / 2; - /* 搬的是整个 <li> —— 它自带的 order[] 隐藏域跟着走, - 所以提交上来的顺序 = 屏幕上的顺序,不用再同步一遍。 */ - if (before) { list.insertBefore(dragEl, li); } else { list.insertBefore(dragEl, li.nextSibling); } - li.classList.remove('is-drop-before', 'is-drop-after'); - }); - }); - } - Array.prototype.forEach.call(document.querySelectorAll('ol.order-list'), bindSort); - - /* ---- 色板点一下就换 ---- */ - Array.prototype.forEach.call(document.querySelectorAll('.preset-color[data-for]'), function (el) { - el.addEventListener('click', function () { - var target = document.getElementById(el.getAttribute('data-for')); - if (target) { target.value = el.getAttribute('data-color'); } - }); - }); -})(); -</script> diff --git a/templates/settings/me.php b/templates/settings/me.php index 3a968af..c07d44c 100644 --- a/templates/settings/me.php +++ b/templates/settings/me.php @@ -313,149 +313,173 @@ $fbCityN = count(\App\Geo::CITIES); 内置了 <b><?= (int)$fbCityN ?></b> 个城市(全国地级市 + 直辖市),一般常用的都在里面。 </p> -<!-- ==================== 我的分组 ==================== --> +<?php /* 🔴 v52(用户 2026-10-15 第 6 条):「原来个人设置那里的名册分组…… + 这样**个人设置中就不显示名册分组的内容**了。」 + ⇒ 整块「我的分组」卡片**搬到了**「名册 → 设置 → 名册分组」 + (`templates/people/settings.php` + `templates/_person_groups.php`)。 + ⚠️ 是**真的搬走**,不是复制一份:两份一模一样的分组管理界面, + 改一处忘一处是必然的。这里只留一句指路,免得老用户找不到。 */ ?> <div class="card card-pad" style="margin-top:20px"> - <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><?= (int)$rosterCount ?></b> 人。 + <h2 class="section-title">名册分组</h2> + <p class="small muted" style="margin:0 0 12px"> + 分组改到**名册页**那边去了 —— 它本来就是"名册怎么分组"的事,与个人设置无关。 </p> - - <?php if (!$groups): ?> - <p class="small muted" style="margin:0 0 14px">还没有分组。加一个吧。</p> - <?php else: ?> - <ol class="order-list"> - <?php foreach ($groups as $i => $g): $gid = (int)$g['id']; $own = (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 ($own === 0): ?><span class="badge">公共</span><?php else: ?><span class="badge badge-open">我的</span><?php endif; ?> - </span> - <span class="order-count"><?= (int)($groupCounts[$gid] ?? 0) ?> 人</span> - <form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move"> - <?= csrf_field() ?> - <input type="hidden" name="id" value="<?= $gid ?>"> - <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 === $groupTotal - 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 - // 只有我自己建的分组才给改名 / 删除 —— 公共分组要在系统设置里改(那是管理员的事), - // 免得有人在自己设置里把"家人"改成别的、影响全家。 - $mineGroups = array_values(array_filter($groups, fn($g) => (int)$g['owner_id'] > 0)); - ?> - <?php if ($mineGroups): ?> - <ul class="group-manage" style="margin-top:14px"> - <?php foreach ($mineGroups as $g): $gid = (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="<?= $gid ?>"> - <input type="hidden" name="scope" value="me"> - <label class="sr-only" for="gname<?= $gid ?>">分组名</label> - <input class="input" id="gname<?= $gid ?>" 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)($groupCounts[$gid] ?? 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="<?= $gid ?>"> - <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 class="btn-row"> + <a class="btn btn-ghost btn-sm" href="<?= e(url('people/settings')) ?>">去名册设置里改分组</a> + </div> </div> -<!-- ==================== 首页上的模块(用户 2026-10-03 第 3 条)==================== --> -<?php /* 与下面「导航条上的模块」是同一套交互(勾选 + 拖动/▲▼ 排序), - 只是作用对象换成首页 dashboard 上那几张模块卡。 - 两块都要有:导航条管"能不能进",这里管"首页上先看到什么"。 */ ?> -<div class="card card-pad" id="home-mods" style="margin-top:20px"> +<!-- ==================== 模块:导航 / 首页 / 颜色 / 顺序(v52 用户第 7 条)==================== --> +<?php /* 🔴🔴 v52(用户 2026-10-15 第 7 条):「个人设置里面,模块相关的设置太多了,应该**整合**, + 先列出来所有模块,中间有一列,让他勾选是否在导航显示,再给他一列,让他选择是否在首页显示, + 再给他一列,让他选择颜色,最后给他一列,让他**调整顺序**,这样就把几个设置,整合到一起了, + 更加简洁。」 + + ⇒ 原来是**三张卡、三个表单、三次保存**: + ① 首页上的模块(勾选 + 排序) + ② 导航条上的模块(勾选 + 排序) + ③ 模块主题色(改色) + 同一串模块名出现两遍、颜色又另起一张 —— 用户看得累、改起来要点三次保存。 + ⇒ 现在合成**一张表**:一行一个模块,四列 = 导航 / 首页 / 颜色 / 顺序。 + 提交走 `settings/modules_save`(它内部把三个 save 依次调一遍,见 index.php 的说明)。 + + ⚠️ 颜色那一列的权限**没有变**:它仍是家庭一份、只有管理员能改; + 非管理员那一列显示成只读色块(看得到、改不了)。 + ⚠️ 拖动排序仍搬整行(行自带 order[]),▲▼ 是没 JS 时的兜底 —— 两条路都在。 */ +$plMAll = \App\Module::keys(); +$plMNav = array_map('strval', (array)(\App\Module::navFor((int)\App\Auth::personId()))); +$plMHome = array_map('strval', (array)(\App\HomeMods::forPerson((int)\App\Auth::personId()))); +$plMColor = \App\Module::colors((int)\App\Auth::familyId()); +$plMIsAdmin = \App\Auth::isAdmin(); +// 行的顺序:导航里显示的按导航顺序在前(那是用户最在意的),其余按默认顺序在后 +$plMOrder = []; +foreach ($plMNav as $k) { if (in_array($k, $plMAll, true)) { $plMOrder[] = $k; } } +foreach ($plMAll as $k) { if (!in_array($k, $plMOrder, true)) { $plMOrder[] = $k; } } +?> +<div class="card card-pad" id="modules" style="margin-top:20px"> <h2 class="section-title"> - 首页上的模块 - <span class="hint">勾上才会出现在首页,拖动可改顺序</span> + 模块设置 + <span class="hint">导航 / 首页 / 颜色 / 顺序,一页改完</span> </h2> <p class="small muted" style="margin:0 0 16px"> - 首页是"一眼看家里近况"的地方,模块太多会变成一面墙 —— 这里只挑你想每天看到的几个。 - 关掉的模块<b>照样能从顶部导航条进去</b>,只是不在首页上占位置。 + 模块越来越多,但每个人关心的不一样 —— 这里一行一个模块: + <b>导航</b>管"顶栏上有没有它",<b>首页</b>管"打开首页看不看得到", + <b>颜色</b>是进到那个模块后顶栏高亮的色(<b>全家共用一套</b>), + <b>顺序</b>用拖动手柄或 ▲▼ 调。 + <br>关掉的模块<b>不会丢数据</b>,只是从顶栏 / 首页收起来,别处仍能进。 </p> - <form method="post" action="<?= e(url('settings/home_save')) ?>" id="homeForm"> + <form method="post" action="<?= e(url('settings/modules_save')) ?>" id="modulesForm"> <?= csrf_field() ?> - <ol class="order-list mod-order" id="homeOrderList"> - <?php - $hOn = array_map('strval', (array)($homeOn ?? [])); - $hOrder = (array)($homeOrder ?? []); - ?> - <?php foreach ($hOrder as $i => $k): ?> - <li class="order-row mod-row" draggable="true" data-key="<?= e($k) ?>"> - <span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span> - <input type="hidden" name="order[]" value="<?= e($k) ?>"> - <label class="mod-pick"> - <input type="checkbox" name="on[]" value="<?= e($k) ?>" - <?= in_array($k, $hOn, true) ? 'checked' : '' ?>> - <span class="color-preview" - style="background: <?= e(\App\Module::color($k, (int)\App\Auth::familyId())) ?>;"></span> - <span class="order-name"><?= e(\App\Module::name($k)) ?></span> - </label> - <span class="order-move"> - <button class="btn btn-ghost btn-sm" type="submit" name="move" value="up:<?= e($k) ?>" - title="上移" <?= $i === 0 ? 'disabled' : '' ?>>▲</button> - <button class="btn btn-ghost btn-sm" type="submit" name="move" value="down:<?= e($k) ?>" - title="下移" <?= $i === count($hOrder) - 1 ? 'disabled' : '' ?>>▼</button> - </span> - </li> - <?php endforeach; ?> - </ol> - <div class="btn-row"> - <button class="btn" type="submit" name="save" value="1">保存首页显示</button> + <div class="mod-table-wrap"> + <table class="mod-table"> + <thead> + <tr> + <th class="mod-th-order">顺序</th> + <th class="mod-th-name">模块</th> + <th class="mod-th-on">导航</th> + <th class="mod-th-on">首页</th> + <th class="mod-th-color">颜色</th> + </tr> + </thead> + <tbody id="modRows"> + <?php foreach ($plMOrder as $i => $k): ?> + <tr class="mod-row" draggable="true" data-key="<?= e($k) ?>"> + <td class="mod-td-order"> + <span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span> + <?php /* ⚠️ 顺序就藏在 DOM 里:每行自带 order[] 隐藏域,拖动搬整行 ⇒ + 提交上来的顺序天然就是屏幕上的顺序(与旧的两块同一手法)。 */ ?> + <input type="hidden" name="order[]" value="<?= e($k) ?>"> + <span class="mod-move"> + <button class="btn btn-ghost btn-sm" type="submit" name="move" value="up:<?= e($k) ?>" + title="上移" <?= $i === 0 ? 'disabled' : '' ?>>▲</button> + <button class="btn btn-ghost btn-sm" type="submit" name="move" value="down:<?= e($k) ?>" + title="下移" <?= $i === count($plMOrder) - 1 ? 'disabled' : '' ?>>▼</button> + </span> + </td> + <td class="mod-td-name"> + <span class="color-preview" style="background: <?= e((string)($plMColor[$k] ?? '#888')) ?>;"></span> + <?= e(\App\Module::name($k)) ?> + </td> + <td class="mod-td-on"> + <label class="mod-chk" title="显示在顶部导航条"> + <input type="checkbox" name="nav[]" value="<?= e($k) ?>" + <?= in_array($k, $plMNav, true) ? 'checked' : '' ?>> + </label> + </td> + <td class="mod-td-on"> + <label class="mod-chk" title="显示在首页"> + <input type="checkbox" name="home[]" value="<?= e($k) ?>" + <?= in_array($k, $plMHome, true) ? 'checked' : '' ?>> + </label> + </td> + <td class="mod-td-color"> + <?php if ($plMIsAdmin): ?> + <input type="color" class="color-picker" name="c[<?= e($k) ?>]" + value="<?= e((string)($plMColor[$k] ?? '#888888')) ?>" + aria-label="<?= e(\App\Module::name($k)) ?>的颜色"> + <?php else: ?> + <?php /* 非管理员:看得到颜色、改不了(家庭一份,见本页说明) */ ?> + <span class="color-preview mod-color-ro" + style="background: <?= e((string)($plMColor[$k] ?? '#888')) ?>;" + title="由管理员设置"></span> + <?php endif; ?> + </td> + </tr> + <?php endforeach; ?> + </tbody> + </table> + </div> + <div class="btn-row" style="margin-top:14px"> + <button class="btn" type="submit" name="save" value="1">保存模块设置</button> + <?php if ($plMIsAdmin): ?> + <span class="small muted">颜色是全家共用的一套,改了对所有人都生效。</span> + <?php else: ?> + <span class="small muted">颜色由管理员设置,你这里只读。</span> + <?php endif; ?> </div> </form> </div> -<!-- ==================== 模块:导航条上显示哪些、各自什么主题色 ==================== --> -<?php -$navKeys = \App\Module::navFor((int)\App\Auth::personId()); -$colors = \App\Module::colors((int)\App\Auth::familyId()); -require __DIR__ . '/_modules.php'; -?> +<script> +(function () { + /* 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)。 + ⚠️ 搬的是整行 <tr> —— 它自带的 order[] 隐藏域跟着走, + 所以提交上来的顺序 = 屏幕上的顺序,不用再同步一遍。 */ + var list = document.getElementById('modRows'); + if (!list) { return; } + var dragEl = null; + Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (tr) { + tr.addEventListener('dragstart', function (ev) { + dragEl = tr; + tr.classList.add('is-dragged'); + if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; } + }); + tr.addEventListener('dragend', function () { + tr.classList.remove('is-dragged'); + dragEl = null; + Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (x) { + x.classList.remove('is-drop-before'); + }); + }); + tr.addEventListener('dragover', function (ev) { + if (!dragEl || dragEl === tr) { return; } + ev.preventDefault(); + var box = tr.getBoundingClientRect(); + tr.classList.toggle('is-drop-before', ev.clientY < box.top + box.height / 2); + }); + tr.addEventListener('dragleave', function () { tr.classList.remove('is-drop-before'); }); + tr.addEventListener('drop', function (ev) { + if (!dragEl || dragEl === tr) { return; } + ev.preventDefault(); + var box = tr.getBoundingClientRect(); + if (ev.clientY < box.top + box.height / 2) { + list.insertBefore(dragEl, tr); + } else { + list.insertBefore(dragEl, tr.nextSibling); + } + tr.classList.remove('is-drop-before'); + }); + }); +})(); +</script>