diff --git a/app/Person.php b/app/Person.php
index 38457ce..e25057e 100644
--- a/app/Person.php
+++ b/app/Person.php
@@ -160,7 +160,14 @@ final class Person
*/
public static function create(int $familyId, array $d): int
{
- $phone = (string)($d['phone'] ?? '');
+ /* 🔴 v51(用户第 5 条):主手机号**入库前归一**成纯数字。
+ `Crypto::hash()` 内部本来就归一(所以"138 0000 0000"与"13800000000"
+ 一直是**同一个人**,登录不受影响),但**密文里存的是原样**
+ ⇒ 名册列表/详情页显示出来就是"138 0000 0000"这种带空格的形态。
+ 用户这次要求"统一处理一下" ⇒ 这里也归一,两处产物(hash / 密文)口径一致。
+ ⚠️ 只用 `normalizePhone` 的"去分隔符"这一半,**不**去掉国别前缀 86
+ (那是登录查找用的宽松口径;显示与存储要保留用户填的完整号码)。 */
+ $phone = self::normPhone($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'INSERT INTO persons
(family_id, kind, name, gender, birth_rank, birthday,
@@ -201,7 +208,8 @@ final class Person
public static function update(int $id, array $d): void
{
- $phone = (string)($d['phone'] ?? '');
+ // v51:与 create 同一口径(主手机号入库前归一成纯数字,见上面的说明)
+ $phone = self::normPhone($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'UPDATE persons SET
name = ?, gender = ?, birth_rank = ?, birthday = ?,
@@ -251,6 +259,33 @@ final class Person
return $v === '' ? null : mb_substr($v, 0, 60, 'UTF-8');
}
+ /**
+ * 主手机号入库前的归一(v51 用户第 5 条)。
+ *
+ * 用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字,
+ * 中间有两个空格。这个看看能不能把它给处理一下,**统一处理一下**。……
+ * 都提示我数据不对,当我编辑它的时候,提示我数据的格式不对。」
+ *
+ * 做什么:去掉号码里的**空格 / 短横 / 圆括号**(号码里最常见的人类排版),
+ * 并把全角空格一并处理掉 —— 通讯录导出里真的有全角空格。
+ *
+ * ⚠️ 与 `Crypto::normalizePhone()` 的差别(别把两者合并):
+ * · `normalizePhone` 是**登录查找**口径,还会把前缀 `+86`/`86` 去掉
+ * ("13800000000" 与 "+8613800000000" 必须是同一个人);
+ * · 本方法是**存储与显示**口径,**不去国别前缀** ——
+ * 用户填的是什么号就存什么号(只是把分隔符抹平),显示出来才是他认得的号码。
+ * 两者都从同一套分隔符集合出发,所以 hash 与密文永远指向同一个号码。
+ * ⚠️ 保留 `+`:`+8613800000000` 这种要原样留着(去掉会变成另一个语义的号码)。
+ */
+ private static function normPhone($raw): string
+ {
+ $s = (string)$raw;
+ // 全角空格 → 半角(通讯录导出的常见形态),再去掉所有分隔符
+ $s = str_replace(["\xE3\x80\x80", "\xC2\xA0"], ' ', $s); // U+3000 / U+00A0
+ $s = (string)preg_replace('/[\s\-()]/u', '', trim($s));
+ return $s;
+ }
+
/**
* 自助改自己的资料。
*
@@ -338,6 +373,13 @@ final class Person
}
$label = mb_substr(trim((string)($row['label'] ?? '')), 0, 20, 'UTF-8');
if ($kind === 'phone') {
+ /* 🔴 v51(用户第 5 条):补充电话与主手机号**同一口径** —— 入库前去掉
+ 分隔符(空格 / 短横 / 括号 / 全角空格),避免同一个人在"主号 / 补充号"
+ 两处存出两种排版(名册列表显示时一个带空格一个不带,看着像两种数据)。 */
+ $value = self::normPhone($value);
+ if ($value === '') {
+ continue;
+ }
$value = Crypto::encrypt(mb_substr($value, 0, 60, 'UTF-8'));
} else {
$value = mb_substr($value, 0, 500, 'UTF-8');
@@ -444,7 +486,21 @@ final class Person
$err['name'] = '姓名不能为空';
}
$phone = trim((string)($d['phone'] ?? ''));
- if ($phone !== '' && !preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
+ /* 🔴🔴 v51(用户 2026-10-15 第 5 条):「名册的导进来的手机号,有些它是一共 11 个数字,
+ 前 3 个数字和后 4 个数字,中间有两个空格……看起来还有不少这样的数据呢,
+ 都提示我数据不对,当我编辑它的时候,提示我数据的格式不对。」
+ ⇒ 根因就是这个正则:原来只收 `[0-9-]`,**空格一律判错** ⇒
+ 通讯录导入进来的 "138 0000 0000" 这种(vCard/CSV 里很常见的人类排版)
+ 一进编辑页就红着,保存不了。
+ ⇒ 做法:**先按存储口径归一、再校验归一后的串**。
+ 这样校验的与真正入库的是**同一个值**,两边不会互相打脸
+ (分隔符、全角空格都已被 `normPhone()` 抹平)。
+ ⚠️ 曾经试过"把正则的字符集放宽到含 `\s`"——**不够**:
+ PCRE 的 `\s`(不加 /u / UCP)只认 ASCII 空白,**不认全角空格 U+3000**,
+ 而通讯录导出里真的会出现全角空格 ⇒ 那种号码照样被判错。
+ "先归一再校验"从根上避免了这类"字符集列举不全"的问题。 */
+ $phone = self::normPhone($phone);
+ if ($phone !== '' && !preg_match('/^\+?[0-9]{5,25}$/', $phone)) {
$err['phone'] = '手机号格式看起来不对';
}
$email = trim((string)($d['email'] ?? ''));
diff --git a/app/Schema.php b/app/Schema.php
index d855115..ec063ae 100644
--- a/app/Schema.php
+++ b/app/Schema.php
@@ -2183,6 +2183,58 @@ SQL);
}
});
+ /* 🔴 v51(用户 2026-10-15 第 5 条):把**已入库**的带分隔符手机号归一成纯数字。
+ 用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字,
+ 中间有两个空格……看起来还有不少这样的数据呢,都提示我数据不对。」
+ ⇒ 光放宽校验(`Person::validate`)只能让**以后**编辑不再报错;
+ 库里**已经存进去**的那些仍带着空格,名单上显示出来还是"138 0000 0000"。
+ 用户明确要"统一处理一下" ⇒ 这里做一次性数据归一。
+
+ ⚠️ 手机号是**加密存**的(`phone_encrypted`),SQL 里没法直接 LIKE/REPLACE ——
+ 只能把整表读出来、解密、归一、再写回(见下面)。
+ ⚠️ 用**哨兵键**保证"只做一次"(与 `person_tags_migrated_v28` 同一套路):
+ 这是**一次性数据转换**,不是持续同步;否则用户哪天存回一个带空格的号,
+ 下次 ensure 又给他抹掉,会出现"我改的号码自己变了"。
+ 超长/超大库也不怕:条件只挑"解密后含分隔符"的行,
+ 绝大多数行一条 SQL 的代价都没有(先全量解密是可接受的量级:
+ 名册千人级,一次 ensure 跑完就再也不跑)。
+ ⚠️ 幂等:写完 `phone_hash` 与原来**同一个值**(`Crypto::hash` 本来就归一),
+ 所以唯一约束不会因为这次改写而冲突。 */
+ self::tryStep('手机号归一(去掉导入数据里的空格/短横)', static function () use ($db) {
+ $flag = 'phone_normalized_v51';
+ $has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
+ if ((int)$has > 0) {
+ return; // 已经归过一次,永不再做(见上面的说明)
+ }
+ if (!self::hasColumn($db, 'persons', 'phone_encrypted')) {
+ return;
+ }
+ $rows = $db->query(
+ "SELECT id, phone_encrypted FROM persons
+ WHERE phone_encrypted IS NOT NULL AND phone_encrypted <> ''"
+ )->fetchAll();
+ $upd = $db->prepare('UPDATE persons SET phone_encrypted = ? WHERE id = ?');
+ foreach ($rows as $r) {
+ $plain = \App\Crypto::decrypt((string)$r['phone_encrypted']);
+ if ($plain === '') {
+ continue;
+ }
+ // 只有"含有分隔符"的才需要改写(其余原样,避免无谓的写库)
+ $norm = (string)preg_replace('/[\s\-()]+/u', '', str_replace(
+ ["\xE3\x80\x80", "\xC2\xA0"], ' ', $plain));
+ if ($norm === $plain || $norm === '') {
+ continue;
+ }
+ $upd->execute([\App\Crypto::encrypt($norm), (int)$r['id']]);
+ }
+ // 记哨兵(settings 是家庭级的,用最小 family_id 归属,与其它迁移一致)
+ $fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
+ if ($fid > 0) {
+ $db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
+ ->execute([$fid, $flag, date('Y-m-d H:i:s')]);
+ }
+ });
+
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
if (self::$stepErrors) {
diff --git a/assets/css/app.css b/assets/css/app.css
index 7765e7e..5bf9980 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -179,6 +179,15 @@ a:hover { text-decoration: underline; }
.cal-set-view input { accent-color: var(--brand); }
/* 本子清单:最多占 42vh,超了内部滚动 —— 菜单本身不滚,否则保存按钮会被推出可视区 */
.cal-set-books { max-height: 42vh; overflow-y: auto; }
+/* 🔴 v51(用户 2026-10-15 第 6 条):显示设置**独立成页之后**,
+ `.cal-set-books` 那条 `max-height:42vh; overflow-y:auto` 就不该再套用了 ——
+ 它是"塞在下拉里"时的妥协(下拉不能太高)。整页里再套一个 42vh 的滚动区,
+ 等于"页面里再开一个小窗",日历本多的时候要**滚动两次**才能看完。
+ ⚠️ 用 `.cal-set-page` 作用域覆盖,**不动**上面那条基础规则 ——
+ 它现在没有别的使用者,但留着万一将来又要在别处(弹框里)用同一套列表。 */
+.cal-set-page .cal-set-books { max-height: none; overflow-y: visible; }
+/* 页面上把每一本排成一行(与下拉里同一套行样式,只是不滚动) */
+.cal-set-page .cal-set-book { font-size: 14px; }
.cal-set-book {
display: flex; align-items: center; gap: 7px; padding: 5px 4px;
font-size: 13px; cursor: pointer; border-radius: 6px; white-space: normal;
diff --git a/assets/js/app.js b/assets/js/app.js
index e61ac76..a519392 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1585,11 +1585,49 @@
`mountAntd` **后**跑并把 `group.__fbRemount` **覆盖**成"重读原生值 + 重绘 antd"。
所以:
· 有 antd 的页面 → 最终是 mountAntd 那版(下面的空壳被覆盖);
- · 没有 antd 的页面 → 就是这一版,把两个端点的下拉对齐到原生值。 */
- group.__fbRemount = function () {
+ · 没有 antd 的页面 → 就是这一版,把两个端点的下拉对齐到原生值。
+
+ 🔴🔴 v51(用户 2026-10-15)——**这里原来是"降级覆盖",害了日程页的带入时间**。
+ 用户原话:「在周视图和日视图的某一个地方点击,它会把当前的时间带进来,
+ 但是**只有第一次**带进来的时间是对的……当我关闭了它,再次点击的时候,
+ 无论我点什么地方,它带进来的还是 8 点钟。」
+
+ **真根因(真机插桩实测,不是猜的)**:
+ 日程页首屏那对起止字段是**藏在弹框里**的(`#calAddCard` 默认 `hidden`)
+ ⇒ boot 时 `upgradeRange()` 因为"两端不可见"**直接 return**(见上面的守卫)
+ ⇒ 那一刻 **`mountAntd` 是唯一给 `group.__fbRemount` 赋值的人**
+ (它那一版会"重读原生值 + `renderPicker()` 重绘 antd")。
+ 而**一打开弹框**(元素第一次可见),页内 `openNew()` 会调
+ `window.fbUpgradeRange()` 补升级 ⇒ `upgradeRange()` 这次跑到底,
+ 在**它的尾部**把 `group.__fbRemount` **重新赋成了下面这一版** ——
+ 只会对齐两个原生下拉、**根本不碰 antd**。
+ ⇒ 此后每次 `fbSetDT`(点日历带入时间)都走到这一版:
+ 隐藏字段与原生下拉都更新了,**可见的 antd 控件永远停在第一次的值**。
+ 这正是"第一次对、后面都不动"。
+
+ ⇒ 修法:**别覆盖已经更强的重绘实现**。
+ · 已存在 `__fbRemount`(= mountAntd 那版)时:把"对齐原生下拉"**串在它前面**,
+ 而不是把它顶掉;
+ · 不存在时:才用下面这版(纯原生页面的兜底)。
+ ⚠️ 顺序:先对齐下拉再重绘 antd —— mountAntd 那版会从原生值重读,
+ 先让原生侧一致,重绘出来的才对。
+ ⚠️ 幂等:`upgradeRange` 若因任何原因再跑一次,也不会把自己套进自己
+ (用 `__fbRangeSync` 标记,套过的就不重复套)。 */
+ var syncPartsOnce = function () {
if (a.__fbSyncParts) { a.__fbSyncParts(); }
if (b.__fbSyncParts) { b.__fbSyncParts(); }
};
+ if (typeof group.__fbRemount === 'function' && !group.__fbRangeSync) {
+ /* 已有更强的实现(mountAntd)→ 只把"对齐下拉"挂到它前面 */
+ var stronger = group.__fbRemount;
+ group.__fbRangeSync = true;
+ group.__fbRemount = function () {
+ syncPartsOnce();
+ return stronger();
+ };
+ } else if (!group.__fbRemount) {
+ group.__fbRemount = syncPartsOnce;
+ }
guardRequired(a);
guardRequired(b);
group.setAttribute('data-semi-range-done', '1');
diff --git a/index.php b/index.php
index 2ce82ac..a96b01c 100644
--- a/index.php
+++ b/index.php
@@ -248,8 +248,17 @@ function validate_contacts(array $contacts): string
if ($kind === 'email' && !filter_var($v, FILTER_VALIDATE_EMAIL)) {
return '第 ' . $no . ' 个补充邮箱的格式看起来不对:' . $v;
}
- if ($kind === 'phone' && !preg_match('/^\+?[0-9\- ]{6,25}$/', $v)) {
- return '第 ' . $no . ' 个补充电话的格式看起来不对:' . $v;
+ if ($kind === 'phone') {
+ /* 🔴 v51(用户第 5 条):与主手机号**同一套口径** —— 先去分隔符再校验。
+ 原来这里收 `[0-9\- ]`(含半角空格),
+ 但**全角空格 U+3000** 同样会被判错(PCRE 的 `\s` 不加 /u 不认它)。
+ 两处口径不同 = 同一个号码填主字段报错、填补充字段却通过 ⇒ 用户懵。
+ ⇒ 统一成"先归一(抹掉空格/短横/括号/全角空格)、再判纯数字"。 */
+ $n = (string)preg_replace('/[\s\-()]+/u', '', str_replace(
+ ["\xE3\x80\x80", "\xC2\xA0"], ' ', $v));
+ if (!preg_match('/^\+?[0-9]{5,25}$/', $n)) {
+ return '第 ' . $no . ' 个补充电话的格式看起来不对:' . $v;
+ }
}
}
}
@@ -1981,6 +1990,18 @@ switch ($r) {
'jobRows' => PersonJob::list($id),
// 我对他的私备注(第 5 条):**只有我自己**那一份
'myNote' => $isSelf ? '' : PersonNote::get($id, (int)Auth::personId()),
+ /* 🔴 v51(用户 2026-10-15 第 4 条):「名册这个功能里面,当我编辑资料的时候,
+ 在这个页面竟然**没有办法选择它的标签**。按道理讲,名册编辑成员这个地方
+ 应该能编辑这一个人的**所有信息**才对。」
+ ⇒ 这一页补上「标签」编辑(与名册列表上那颗标签按钮同一份数据源)。
+ ⚠️ 标签是**每人一份**的(owner = 我),所以只有"我"名册里的标签有意义:
+ · `myTags` 这个人当前在我名下的标签 → 回填输入框;
+ · `tagChoices` 我名下**用过**的全部标签 → 给可点选的候选(与列表页同一个
+ `PersonTag::cloud()`,两处口径一致)。
+ ⚠️ 改自己($isSelf)不给标签:标签是"我给别人打的",给自己打没有意义
+ (名册页那颗标签按钮也只在 `$canMove` 时出现)。 */
+ 'myTags' => $isSelf ? [] : PersonTag::of((int)Auth::personId(), $id),
+ 'tagChoices' => $isSelf ? [] : array_column(PersonTag::cloud((int)Auth::familyId(), (int)Auth::personId()), 'tag'),
], 'people');
break;
@@ -2052,6 +2073,12 @@ switch ($r) {
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
+ /* 🔴 v51:校验失败**必须把标签也带回去**,否则用户刚打的一串标签
+ 随着这一屏重画就没了("改了半天的东西突然消失")。
+ ⚠️ 取自 `$_POST['tags']`(用户刚提交的那份),**不是**库里的旧值 ——
+ 这才叫"保留用户填的东西",`clear_old`/`old()` 那一套同理。 */
+ 'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')),
+ 'tagChoices' => $isSelf ? [] : array_column(PersonTag::cloud($familyId, (int)Auth::personId()), 'tag'),
], 'people');
exit;
}
@@ -2081,12 +2108,27 @@ switch ($r) {
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
+ // v51:同上面那条 —— 重画时保留用户刚提交的标签
+ 'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')),
+ 'tagChoices' => $isSelf ? [] : array_column(PersonTag::cloud($familyId, (int)Auth::personId()), 'tag'),
], 'people');
exit;
}
Person::replaceContacts($id, $contacts);
// 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。
PersonJob::replace($id, (array)($d['jobs'] ?? []));
+ /* 🔴 v51(用户 2026-10-15 第 4 条):编辑页也能改「标签」了。
+ ⚠️ **owner 永远是"我"**(`PersonTag::setFor(家庭, 我, 他, ...)`)——
+ 标签是"我给别人打的",改它不能影响全家的标签(与列表页那颗标签按钮同一口径)。
+ ⚠️ 守卫与"私备注"同一条思路,但不同:
+ · 改自己时表单里**没有** tags 字段 → 不能读(读到空串会把我的标签清空);
+ · 所以先用 `array_key_exists` 判"这次提交里有没有这个字段",
+ 没有就**不碰**(而不是"当成清空")。
+ ⚠️ 这正是 v49 那次踩过的形状("字段不在就当成空"会把数据弄丢)——
+ 凡"条件出现的字段"都必须按"存在与否"分流,不能按"值是否为空"。 */
+ if ((int)$id !== (int)Auth::personId() && array_key_exists('tags', $_POST)) {
+ PersonTag::setFor($familyId, (int)Auth::personId(), $id, (string)$_POST['tags']);
+ }
// 「我对他的私人备注」(第 5 条):落在**写的人自己**名下(person_notes)。
// ⚠️ 两条守卫:
// ① 编辑自己时表单里没有这个字段(不需要"我对我的备注")→ 别把已有的清掉;
@@ -4693,6 +4735,29 @@ $want = array_keys($want);
redirect('settings/me', [], 'api');
break;
+ /**
+ * 日程「显示设置」独立页(v51 用户 2026-10-15 第 6 条)。
+ *
+ * 原来「默认视图 + 显示哪些日历本」是**铺在设置下拉里**的一个表单;
+ * 日历本一多,下拉就被撑到屏幕外面(用户原话:"下面的设置就看不到了,
+ * 要拉下来才能看到")⇒ 挪成独立页面,下拉里只留一行「显示设置」。
+ *
+ * ⚠️ 保存仍走既有的 `calendar/prefs`(一条没改)—— 它只是从"下拉里的表单"
+ * 变成"这一页上的表单",提交契约、字段名、服务端逻辑完全不动。
+ */
+ case 'calendar/display':
+ $familyId = (int)Auth::familyId();
+ $me = (int)Auth::personId();
+ $cals = Calendar::mine($familyId, $me);
+ View::render('calendar/display', [
+ 'title' => '显示设置',
+ 'cals' => $cals,
+ 'hiddenCals' => array_keys(Calendar::hiddenCalendars($me)),
+ 'view' => (string)($_GET['view'] ?? Calendar::defaultView($me)),
+ 'anchor' => (string)($_GET['anchor'] ?? date('Y-m-d')),
+ ], 'calendar');
+ break;
+
case 'calendar/calendars':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
diff --git a/templates/calendar/_settings_menu.php b/templates/calendar/_settings_menu.php
index 6e5f376..b7852c9 100644
--- a/templates/calendar/_settings_menu.php
+++ b/templates/calendar/_settings_menu.php
@@ -1,26 +1,36 @@
'