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 { + 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 = + '' + + '' + + ''; + 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),这里保留原来的
+ inner = '

(图片块)

'; + } else { + inner = '
' + esc(d.block.html) + '
'; + } + 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 ''; + } + + /* ---------------- 事件委托 ---------------- */ + 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 @@ + 人数 + * $rosterCount 我的名册人数 + */ +/* ⚠️ 变量名**必须带片段前缀**(pg*):模板片段与宿主 `require` 是**共享作用域**, + 用 `$groups` 这种通名会把宿主模板的同名变量覆盖掉(本项目有静态断言专门查这个)。 */ +$pgGroups = (array)($groups ?? []); +$pgCounts = (array)($groupCounts ?? []); +$pgRosterN = (int)($rosterCount ?? 0); +$pgTotal = count($pgGroups); +?> +
+

+ 名册分组 + 公共的 + 我自己加的 +

+

+ 名册是按「关系分组」分栏显示的。这里有两层: + 公共分组由管理员在「系统设置」里维护(全家都能用); + 你要是觉得不够,就在下面加只给自己看的分组,别人看不到、也不受影响。 +
+ 列表里 公共 标的是大家共用的那几个。 + 哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。 +
+ 你的名册现在有 人。 +

+ + +

还没有分组。加一个吧。

+ +
    + $g): $pgGid = (int)$g['id']; $pgOwn = (int)$g['owner_id']; ?> +
  1. + + + + 公共我的 + + 人 +
    + + + + + +
    +
  2. + +
+ + +
+ + + + + +
+ + (int)$g['owner_id'] > 0)); + ?> + + + + + +

+ 公共分组在「系统设置」里维护。 +

+ +
+ \ 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 = { + + 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 { }; ?>
-

+ ,见 View::render 的 title 参数)—— + 由路由传进来,见 index.php 的 notes/form 分支。 */ + $noteTitle = trim((string)($editing['title'] ?? '')); + $headText = (!$editMode && $isEditing && $noteTitle !== '') ? $noteTitle + : ($editMode ? ($isEditing ? '改笔记' : '写笔记') : '看笔记'); + ?> +

@@ -116,23 +129,39 @@ $imgParts = static function (string $c): array {
-
+
- - - - -
- - 0 ? 'style="width:' . (int)$w . '%"' : '' ?> - loading="lazy" decoding="async"> - -
+ + 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 @@ + '', + 'groups' => '' + . '', + ]; + $d = $paths[$name] ?? ''; + if ($d === '') { + return ''; + } + return ''; +}; +?> + + \ 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 = [ + + + '手机号一样', + 'id_card' => '身份证号一样', + 'name' => '姓名一样', + ][(string)($confirmWhy ?? 'name')] ?? '信息很像'; + $cfPid = (int)($confirmHit['id'] ?? 0); + $cfInMine = \App\PersonRoster::has((int)\App\Auth::familyId(), (int)\App\Auth::personId(), $cfPid); + ?> +
+

+ 家里已经有这个人了 —— 是他吗? + +

+

+ 如果是他,直接加进你的名册就行 —— 资料共用同一份,不用再录一遍, + 以后你们改的也是同一个人。 +
如果不是(只是重名),点「不是,是另一个人」,我就按你填的当新人建一条。 +

+ +
+ + + + + + + + + + 虚拟人物 + 已在你的名册 + + · + + + + + 先看看他的资料 + +
+ +
+ + + +
+
+ +
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 { 共 人
@@ -285,21 +286,18 @@ $renderGrid = function (array $list) use ($meId, $canMove): void { 没有匹配的人 换个词试试。 - 0): ?> - 你的名册还是空的 - 家里已经有 个人了 —— 直接挑过来就行,不用重新录一遍。 - 名册还是空的 + 把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。 +
家里已经有的人(别人先录的)不用重录 —— 录入时填上姓名或手机号, + 系统会认出来并问你要不要直接加进你的名册。 等家里人把你加进来,或者让管理员给你开个账号。 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 @@ + +
+

名册设置

+ 分组 +
+ 返回名册 + +
+
+ + \ 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 @@ - '#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(); -?> - -
-

- 导航条上的模块 - 勾上才会出现在顶部,拖动可改顺序 -

-

- 模块越来越多,但每个人关心的不一样 —— 这里只挑你常用的几个上导航条,其余的仍然能 - 从别处进(比如观影里还能进影院管理)。关掉的模块不会丢数据,只是从顶栏收起来。 -

- - -
- - -
-

- 模块主题色 - -

-

- 进到哪个模块,顶栏高亮与按钮就换成那个模块的颜色 —— 一眼就知道自己在哪儿。 - -
这是全家共用的一套配色(不然同一个「观影」在两个人屏幕上两种颜色,反而记不住), - 所以只有管理员能改。 - -

- - -
- - - - - - -
- -
- -
- - -
- - - - - - - -
- -
-
- - -
-
- -
- - 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); 内置了 个城市(全国地级市 + 直辖市),一般常用的都在里面。

- +
-

- 我的分组 - 公共的 + 我自己加的 -

-

- 名册是按「关系分组」分栏显示的。这里有两层: - 公共分组由管理员在「系统设置」里维护(全家都能用); - 你要是觉得不够,就在下面加只给自己看的分组,别人看不到、也不受影响。 -
- 列表里 {{ 公共 }} 标的是大家共用的那几个。 - 哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。 -
- 你的名册现在有 人。 +

名册分组

+

+ 分组改到**名册页**那边去了 —— 它本来就是"名册怎么分组"的事,与个人设置无关。

- - -

还没有分组。加一个吧。

- -
    - $g): $gid = (int)$g['id']; $own = (int)$g['owner_id']; ?> -
  1. - - - - 公共我的 - - 人 -
    - - - - - -
    -
  2. - -
- - -
- - - - - -
- - (int)$g['owner_id'] > 0)); - ?> - -
    - -
  • -
    - - - - - - -
    - 人 - ),不用 JS 也能防手滑 —— 一点就没了太容易出事 */ ?> -
    - 删除 -
    - - - - 这组的人会变成「未分类」,确定? - -
    -
    -
  • - -
- - - -

- 公共分组在「系统设置」里维护。 -

- +
- - -
+ + +

- 首页上的模块 - 勾上才会出现在首页,拖动可改顺序 + 模块设置 + 导航 / 首页 / 颜色 / 顺序,一页改完

- 首页是"一眼看家里近况"的地方,模块太多会变成一面墙 —— 这里只挑你想每天看到的几个。 - 关掉的模块照样能从顶部导航条进去,只是不在首页上占位置。 + 模块越来越多,但每个人关心的不一样 —— 这里一行一个模块: + 导航管"顶栏上有没有它",首页管"打开首页看不看得到", + 颜色是进到那个模块后顶栏高亮的色(全家共用一套), + 顺序用拖动手柄或 ▲▼ 调。 +
关掉的模块不会丢数据,只是从顶栏 / 首页收起来,别处仍能进。

-
+ -
    - - $k): ?> -
  1. - - - - - - - -
  2. - -
-
- +
+ + + + + + + + + + + + $k): ?> + + + + + + + + + +
顺序模块导航首页颜色
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + 颜色是全家共用的一套,改了对所有人都生效。 + + 颜色由管理员设置,你这里只读。 +
- - +