diff --git a/app/ImageSlot.php b/app/ImageSlot.php index 1c1296c..9ac2e00 100644 --- a/app/ImageSlot.php +++ b/app/ImageSlot.php @@ -18,8 +18,10 @@ namespace App; final class ImageSlot { - /** 上传体积上限(前端也会提示,但以这里为准) */ - public const MAX_BYTES = 5 * 1024 * 1024; + // ⚠️ 应用层不再设「兆数」上限(用户原话:上传照片不要有大小限制)。 + // 服务器 ini(upload_max_filesize / post_max_size,线上是 50M)是唯一的天花板; + // 像素与内存的守卫在 Upload::image() 里按图动态算。 + // 输出尺寸恒为槽位的 w×h(头像 512×512)——原图再大,落库的都是小 JPEG。 /** * 位置 → 比例与输出尺寸。 diff --git a/app/Person.php b/app/Person.php index f9222dc..ef74c3e 100644 --- a/app/Person.php +++ b/app/Person.php @@ -127,9 +127,9 @@ final class Person $sql = 'INSERT INTO persons (family_id, kind, name, gender, birth_rank, birthday, birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar, - phone_hash, phone_encrypted, id_card_encrypted, email, address, note, + phone_hash, phone_encrypted, id_card_encrypted, email, address, org, title, note, is_placeholder) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; $st = Database::get()->prepare($sql); $st->execute([ $familyId, @@ -148,6 +148,8 @@ final class Person ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null, ($d['email'] ?? '') !== '' ? $d['email'] : null, ($d['address'] ?? '') !== '' ? $d['address'] : null, + ($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null, + ($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null, ($d['note'] ?? '') !== '' ? $d['note'] : null, !empty($d['is_placeholder']) ? 1 : 0, ]); @@ -162,7 +164,7 @@ final class Person name = ?, gender = ?, birth_rank = ?, birthday = ?, birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?, phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?, - email = ?, address = ?, note = ?, is_placeholder = ? + email = ?, address = ?, org = ?, title = ?, note = ?, is_placeholder = ? WHERE id = ?'; Database::get()->prepare($sql)->execute([ $d['name'], @@ -179,6 +181,8 @@ final class Person ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null, ($d['email'] ?? '') !== '' ? $d['email'] : null, ($d['address'] ?? '') !== '' ? $d['address'] : null, + ($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null, + ($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null, ($d['note'] ?? '') !== '' ? $d['note'] : null, !empty($d['is_placeholder']) ? 1 : 0, $id, @@ -202,7 +206,7 @@ final class Person 'UPDATE persons SET name = ?, gender = ?, birthday = ?, birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?, - email = ?, address = ?, note = ? + email = ?, address = ?, org = ?, title = ?, note = ? WHERE id = ?' )->execute([ $d['name'], @@ -215,11 +219,67 @@ final class Person ($d['avatar'] ?? '') !== '' ? $d['avatar'] : null, ($d['email'] ?? '') !== '' ? $d['email'] : null, ($d['address'] ?? '') !== '' ? $d['address'] : null, + ($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null, + ($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null, ($d['note'] ?? '') !== '' ? $d['note'] : null, $id, ]); } + // ---------------- 补充联系方式(vCard 对齐,v6 起) ---------------- + // + // 「主手机号」不在这里:它是登录凭据(phone_hash 唯一约束、按它找人), + // 永远留在 persons.phone_*。contacts 存的是**额外的**电话 / 邮箱 / 地址 / 网址。 + // 电话与主手机号同一把钥匙加密存(value 是密文),展示时用 Crypto::maskPhone 打码。 + + /** 某个人的全部补充联系方式,按 kind 分组;kind=phone 的 value 是**密文** */ + public static function contactsOf(int $personId): array + { + $st = Database::get()->prepare( + 'SELECT kind, label, value FROM person_contacts + WHERE person_id = ? ORDER BY kind, position, id' + ); + $st->execute([$personId]); + $out = ['phone' => [], 'email' => [], 'address' => [], 'url' => []]; + foreach ($st->fetchAll() as $r) { + $out[(string)$r['kind']][] = ['label' => (string)$r['label'], 'value' => (string)$r['value']]; + } + return $out; + } + + /** + * 整组替换一个人的补充联系方式(delete + insert,幂等且不留下悬旧行)。 + * + * @param string[][][] $byKind kind => 行列表,每行 ['label' => .., 'value' => ..] + * value 一律传**明文**;kind=phone 在这里统一加密。 + * value 为空的行直接丢弃(表单会带出一排空行)。 + */ + public static function replaceContacts(int $personId, array $byKind): void + { + $db = Database::get(); + $ins = $db->prepare( + 'INSERT INTO person_contacts (person_id, kind, label, value, position) VALUES (?,?,?,?,?)' + ); + $db->prepare('DELETE FROM person_contacts WHERE person_id = ?')->execute([$personId]); + foreach (['phone', 'email', 'address', 'url'] as $kind) { + $pos = 100; + foreach (($byKind[$kind] ?? []) as $row) { + $value = trim((string)($row['value'] ?? '')); + if ($value === '') { + continue; // 空行是表单带出来的占位,不是数据 + } + $label = mb_substr(trim((string)($row['label'] ?? '')), 0, 20, 'UTF-8'); + if ($kind === 'phone') { + $value = Crypto::encrypt(mb_substr($value, 0, 60, 'UTF-8')); + } else { + $value = mb_substr($value, 0, 500, 'UTF-8'); + } + $ins->execute([$personId, $kind, $label, $value, $pos]); + $pos += 10; + } + } + } + /** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */ public static function setAvatar(int $id, ?string $avatar): void { diff --git a/app/Schema.php b/app/Schema.php index d12a237..4329810 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -29,8 +29,11 @@ final class Schema * 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、 * person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组); * 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上) + * 6 → **成员信息对齐 vCard**:person_contacts(多个电话 / 邮箱 / 地址 / 网址, + * 对应 vCard 的 TEL / EMAIL / ADR / URL)+ persons.org / persons.title + * (工作单位 ORG / 职务职称 TITLE)。主手机号仍留在 persons(登录凭据)。 */ - public const VERSION = '5'; + public const VERSION = '6'; /** @return string[] */ public static function statements(): array @@ -171,6 +174,20 @@ CREATE TABLE IF NOT EXISTS `user_settings` ( PRIMARY KEY (`person_id`, `k`), CONSTRAINT `fk_uset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='「我自己的」设置:只影响我自己看到的样子' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `person_contacts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `person_id` INT UNSIGNED NOT NULL COMMENT '属于哪个人', + `kind` ENUM('phone','email','address','url') NOT NULL COMMENT '对应 vCard 的 TEL / EMAIL / ADR / URL', + `label` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '用途标注,如「工作」「住宅」「奶奶家」;空 = 不标注', + `value` VARCHAR(500) NOT NULL COMMENT '内容。kind=phone 时是 AES 密文(与主手机号同一把钥匙)', + `position` INT NOT NULL DEFAULT 100 COMMENT '同 kinds 里的先后', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_pc_person` (`person_id`, `kind`, `position`), + CONSTRAINT `fk_pc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成员的联系方式(可多条):电话 / 邮箱 / 地址 / 网址,对齐 vCard' SQL, ]; } @@ -297,6 +314,17 @@ SQL, self::dropColumnIfExists($db, 'persons', 'group_id'); self::dropColumnIfExists($db, 'persons', 'sort_order'); + // ---- 成员信息对齐 vCard(版本 6)---- + // 工作单位(ORG)与职务职称(TITLE):一个人的属性,单值,直接挂在 persons 上。 + self::addColumn($db, 'persons', 'org', + "ADD COLUMN `org` VARCHAR(120) DEFAULT NULL COMMENT '工作单位(vCard ORG)' AFTER `address`"); + self::addColumn($db, 'persons', 'title', + "ADD COLUMN `title` VARCHAR(120) DEFAULT NULL COMMENT '职务 / 职称(vCard TITLE)' AFTER `org`"); + // 可多条的联系方式:person_contacts 建表在 statements() 里(IF NOT EXISTS 幂等)。 + // ⚠️ 主手机号**不搬**:它是登录凭据(phone_hash 唯一约束、按它找人), + // 搬进 contacts 反而把「登录凭据」和「联系信息」搅在一起。 + // contacts 里的 phone 是**额外的**电话,一样加密存。 + // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 if (self::$stepErrors) { diff --git a/app/Upload.php b/app/Upload.php index 034b7e8..b746773 100644 --- a/app/Upload.php +++ b/app/Upload.php @@ -14,7 +14,14 @@ * 安全要点: * · 用 finfo 判断真实 MIME,不信浏览器给的扩展名 * · 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过) - * · 内存先设上限:超大图直接判错,别把 PHP 进程撑爆 + * · 不设「兆数」上限(用户原话:上传照片不要有大小限制;真正会进来的照片 + * 是手机原图)—— 改成**按像素数动态上调 memory_limit**: + * GD 解码一张真彩图约要 5 字节/像素,默认 128M 只够 ~2500 万像素, + * 一张 4800 万的原图会直接 OOM Fatal(不可捕获、整页白屏)。 + * 这里按 w*h*6B 预算内存,不够就 ini_set 上调(封顶 768M), + * 超过 1.5 亿像素(约 1.2 万 × 1.2 万)才明确拒绝 —— 那已经不是照片是扫描件了。 + * · 落库前**一律缩放到槽位尺寸**(头像 512×512):原图 30MB 还是 3MB, + * 存下来都是一张小 JPEG —— 所以"上传大图"不会把磁盘和流量撑爆。 */ declare(strict_types=1); @@ -56,15 +63,13 @@ final class Upload return ['ok' => false, 'error' => '']; case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: - return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')']; + // 应用层不再设「兆数」上限;这条只会在超过服务器 ini 上限时发生。 + return ['ok' => false, 'error' => '图片太大了(服务器单次上传上限 ' + . self::iniUploadWord() . '),请压缩后再试']; default: return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')']; } - if ((int)$file['size'] > ImageSlot::MAX_BYTES) { - return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')']; - } - $finfo = new \finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); $allow = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng', @@ -78,8 +83,16 @@ final class Upload if (!$info || $info[0] < 1 || $info[1] < 1) { return ['ok' => false, 'error' => '图片读不出来']; } - if ($info[0] * $info[1] > 40_000_000) { - return ['ok' => false, 'error' => '图片像素太多(超过 4000 万),请先压缩再传']; + $px = $info[0] * $info[1]; + if ($px > 150_000_000) { + return ['ok' => false, 'error' => '图片像素实在太大(超过 1.5 亿),请先压缩再传']; + } + // 按需上调 memory_limit:GD 真彩解码约 5B/px,再加 64M 给缩放画布与脚本本身。 + // (memory_limit 是 PHP_INI_ALL,运行时可以调;这是"大图也能传"的关键一步。) + $need = $px * 6 + 64 * 1024 * 1024; + $have = self::iniBytes('memory_limit'); + if ($need > $have) { + @ini_set('memory_limit', (string)min($need, 768 * 1024 * 1024)); } $src = @$allow[$mime]($file['tmp_name']); @@ -238,9 +251,28 @@ final class Upload return [$ix, $iy, $iw, $ih]; } - private static function maxMbWord(): string + /** 把 ini 里的 upload_max_filesize 读成人话(如 "50M")——报错信息里用 */ + private static function iniUploadWord(): string { - return (int)round(ImageSlot::MAX_BYTES / 1024 / 1024) . 'MB'; + $b = self::iniBytes('upload_max_filesize'); + return $b > 0 ? (int)round($b / 1024 / 1024) . 'MB' : '50MB'; + } + + /** 解析 ini 的简写字节数("128M" / "1G" / "65536K");读不到回退 0 */ + private static function iniBytes(string $key): int + { + $v = trim((string)@ini_get($key)); + if ($v === '' || !is_numeric($v[0])) { + return 0; + } + $n = (float)$v; + $unit = strtolower(substr($v, -1)); + return (int)match (true) { + $unit === 'g' => $n * 1024 ** 3, + $unit === 'm' => $n * 1024 ** 2, + $unit === 'k' => $n * 1024, + default => $n, + }; } /** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */ diff --git a/assets/css/app.css b/assets/css/app.css index 08e0c00..2260069 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -749,3 +749,12 @@ textarea.input { min-height: 76px; resize: vertical; } .group-manage-row { align-items: flex-start; } .crop-panel { padding: 12px; } } + +/* ===== 补充联系方式(可多条):标注 + 内容 一行两组,窄屏摞起来 ===== */ +.ct-row { display: flex; gap: 8px; margin-bottom: 8px; } +.ct-row .ct-label { flex: 0 0 140px; } +.ct-row .input { flex: 1; } +@media (max-width: 720px) { + .ct-row { flex-direction: column; gap: 6px; } + .ct-row .ct-label { flex: none; } +} diff --git a/assets/js/app.js b/assets/js/app.js index bf1c7e3..feaf58a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -168,28 +168,19 @@ boxEl.style.height = h + 'px'; } - /** 当前框的归一化矩形(相对**显示出来的图片**,与倍率无关) */ - function normOf() { - if (!st || !st.dispW || !st.dispH) { return { x: 0, y: 0, w: 1, h: 1 }; } - return { - x: boxEl.offsetLeft / st.dispW, - y: boxEl.offsetTop / st.dispH, - w: boxEl.offsetWidth / st.dispW, - h: boxEl.offsetHeight / st.dispH, - }; - } - /** * 改缩放。 * - * 关键:**框住的归一化区域保持不变**,只是把像素坐标整体重算 —— - * 于是"放大"只让你看得更清楚、能框得更细,绝不会把已经选好的区域改掉。 - * 放大后把框滚回视野中间,不然用户会以为框不见了(框在滚动区外面)。 + * 关键:**框在屏幕上的位置和大小都不动**,动的是它底下那张图 —— + * 放大=同一块区域看得更清楚;缩小=框进更多内容。 + * (2026-09-30 用户反馈:之前的做法是"归一化区域不变",结果框跟着图一起变大, + * 缩放几下框就大得找不到了。) + * 落库的归一化矩形由 place() 夹取后自动重算 —— 缩放改变选中的区域,这是本意。 */ function setZoom(z) { if (!st) { return; } z = Math.max(1, Math.min(MAX_ZOOM, z)); - var before = normOf(); + var b = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight }; st.zoom = z; st.dispW = Math.max(1, Math.round(st.baseW * z)); st.dispH = Math.max(1, Math.round(st.baseH * z)); @@ -205,10 +196,8 @@ var valEl = overlay.querySelector('.crop-zoom-val'); if (valEl) { valEl.textContent = z.toFixed(1) + '×'; } - place(before.x * st.dispW, before.y * st.dispH, - before.w * st.dispW, before.h * st.dispH); - stage.scrollLeft = Math.max(0, boxEl.offsetLeft - (st.baseW - boxEl.offsetWidth) / 2); - stage.scrollTop = Math.max(0, boxEl.offsetTop - (st.baseH - boxEl.offsetHeight) / 2); + // 框保持屏显大小;位置也保持(place 只做越界夹取,缩小到 1× 时兜底用) + place(b.x, b.y, b.w, b.h); } function open(input, file) { @@ -417,6 +406,25 @@ calc(); })(); + // ===== 补充联系方式:「再加一个」克隆出下一行 ===== + // 平行数组命名(ct__label[] / ct__value[])→ 克隆行就是合法的追加, + // 服务端按下标 zip,不需要任何计数器字段。 + (function () { + document.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.ct-add') : null; + if (!btn) { return; } + var block = btn.closest('.ct-block'); + if (!block) { return; } + var rows = block.querySelectorAll('[data-ct-row]'); + if (!rows.length) { return; } + var clone = rows[rows.length - 1].cloneNode(true); + Array.prototype.forEach.call(clone.querySelectorAll('input'), function (i) { i.value = ''; }); + rows[rows.length - 1].parentNode.insertBefore(clone, btn); + var first = clone.querySelector('input'); + if (first) { first.focus(); } + }); + })(); + // ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 ===== // // 落库的是**整版布局**(每栏的有序 id 列表),一次提交写完 —— diff --git a/index.php b/index.php index 3232d26..67ae7d3 100644 --- a/index.php +++ b/index.php @@ -108,6 +108,57 @@ function read_birthday_input(array $existing = []): array ]; } +/** + * 从表单解析「补充联系方式」(vCard 对齐,v6 起)。 + * + * 每类两个平行数组:ct__label[] / ct__value[] —— + * 平行数组而不是二维键名(ct[phone][0][value]),是因为 label 与 value + * 分列在两个 input 里,PHP 自动按数字补齐下标,zip 起来最直观。 + * + * @return string[][][] kind => 行列表(label/value,value 保留原样,空行不丢 —— + * 交给 Person::replaceContacts 丢弃;校验失败重画表单时还要用它回填) + */ +function read_contacts_input(): array +{ + $out = []; + foreach (['phone', 'email', 'address', 'url'] as $kind) { + $labels = $_POST['ct_' . $kind . '_label'] ?? []; + $values = $_POST['ct_' . $kind . '_value'] ?? []; + if (!is_array($labels) || !is_array($values)) { + $out[$kind] = []; + continue; + } + $rows = []; + $n = max(count($labels), count($values)); + for ($i = 0; $i < $n; $i++) { + $rows[] = ['label' => (string)($labels[$i] ?? ''), 'value' => trim((string)($values[$i] ?? ''))]; + } + $out[$kind] = $rows; + } + return $out; +} + +/** 补充联系方式的宽松校验(主手机号/主邮箱的校验在 Person::validate 里) */ +function validate_contacts(array $contacts): string +{ + foreach ($contacts as $kind => $rows) { + foreach ($rows as $i => $row) { + $v = trim((string)($row['value'] ?? '')); + if ($v === '') { + continue; + } + $no = $i + 1; + 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; + } + } + } + return ''; +} + /** * 渲染「个人设置」页(settings/me)。 * @@ -669,6 +720,8 @@ switch ($r) { 'id_card' => trim((string)($_POST['id_card'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), + 'org' => trim((string)($_POST['org'] ?? '')), + 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'avatar' => '', @@ -679,12 +732,18 @@ switch ($r) { 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, ] + read_birthday_input(); $errors = Person::validate($d); + $contacts = read_contacts_input(); + $ctErr = validate_contacts($contacts); + if ($ctErr !== '') { + $errors['contacts'] = $ctErr; + } + // ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」—— + // 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。 + // 现在是:人先存进去,头像的事单独提示。 $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); - if (!$up['ok'] && ($up['error'] ?? '') !== '') { - $errors['avatar'] = $up['error']; - } + $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : ''; if ($errors) { set_old($_POST); View::render('people/form', [ @@ -695,6 +754,7 @@ switch ($r) { 'idCardValue'=> $d['id_card'], 'canEditAll' => true, 'isSelf' => false, + 'contacts' => $contacts, ], 'people'); exit; } @@ -713,14 +773,20 @@ switch ($r) { 'idCardValue'=> $d['id_card'], 'canEditAll' => true, 'isSelf' => false, + 'contacts' => $contacts, ], 'people'); exit; } // 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。 // 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。 PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self'); + Person::replaceContacts($id, $contacts); clear_old(); - flash('ok', '已把「' . $d['name'] . '」加进名册。'); + if ($avatarWarn !== '') { + flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn); + } else { + flash('ok', '已把「' . $d['name'] . '」加进名册。'); + } redirect('people/show', ['id' => $id]); break; @@ -743,6 +809,12 @@ switch ($r) { redirect('people'); } clear_old(); + // 补充联系方式:kind=phone 存的是密文,编辑表单里要给本人(能写的人)看明文 + $editContacts = Person::contactsOf($id); + foreach ($editContacts['phone'] as &$r) { + $r['value'] = Crypto::decrypt($r['value']); + } + unset($r); View::render('people/form', [ 'title' => $isSelf ? '我的资料' : '编辑成员', 'person' => $p, @@ -751,6 +823,7 @@ switch ($r) { 'isSelf' => $isSelf, 'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']), 'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']), + 'contacts' => $editContacts, ], 'people'); break; @@ -777,6 +850,8 @@ switch ($r) { 'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), + 'org' => trim((string)($_POST['org'] ?? '')), + 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'avatar' => (string)$p['avatar'], @@ -786,11 +861,15 @@ switch ($r) { : (int)($p['is_placeholder'] ?? 0), ] + read_birthday_input($p); $errors = Person::validate($d); + $contacts = read_contacts_input(); + $ctErr = validate_contacts($contacts); + if ($ctErr !== '') { + $errors['contacts'] = $ctErr; + } + // 同 people/create:头像失败不挡保存(见 create 分支里的说明) $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); - if (!$up['ok'] && ($up['error'] ?? '') !== '') { - $errors['avatar'] = $up['error']; - } + $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : ''; if ($errors) { set_old($_POST); View::render('people/form', [ @@ -801,6 +880,7 @@ switch ($r) { 'isSelf' => $isSelf, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], + 'contacts' => $contacts, ], 'people'); exit; } @@ -827,16 +907,22 @@ switch ($r) { 'isSelf' => $isSelf, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], + 'contacts' => $contacts, ], 'people'); exit; } + Person::replaceContacts($id, $contacts); if ((int)$p['id'] === (int)Auth::personId()) { Auth::refreshAvatar((string)$d['avatar']); // 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字 $_SESSION['name'] = (string)$d['name']; } clear_old(); - flash('ok', '已保存。'); + if ($avatarWarn !== '') { + flash('ok', '已保存。头像没有更换:' . $avatarWarn); + } else { + flash('ok', '已保存。'); + } redirect('people/show', ['id' => $id]); break; @@ -873,6 +959,8 @@ switch ($r) { 'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']), 'birthdays' => Birthday::describe($p), 'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400), + // 补充联系方式(vCard 对齐):kind=phone 的 value 是密文,模板里用 maskPhone 展示 + 'contacts' => Person::contactsOf($id), // 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁 'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true), ], 'people'); diff --git a/templates/people/form.php b/templates/people/form.php index c5281b7..4e0adf7 100644 --- a/templates/people/form.php +++ b/templates/people/form.php @@ -29,6 +29,27 @@ if ($lunVal === '') { $lunVal = $lv; } $idBd = trim((string)($person['birthday_id_card'] ?? '')); +// 补充联系方式(v6 起):新增时是空数组;编辑时是库里解密后的行; +// 校验失败重画时是用户刚提交的那批(连同填错的也要原样带回)。 +$contacts = $contacts ?? []; +foreach (['phone', 'email', 'address', 'url'] as $ctKind) { + if (!isset($contacts[$ctKind]) || !is_array($contacts[$ctKind])) { + $contacts[$ctKind] = []; + } +} +$ctEmpty = ['label' => '', 'value' => '']; +$ctTitles = [ + 'phone' => '补充电话', + 'email' => '补充邮箱', + 'address' => '其他地址', + 'url' => '网址', +]; +$ctHints = [ + 'phone' => '主手机号在「手机号」一栏(开通登录用);这里放工作号、老人机、家里座机等', + 'email' => '主邮箱在「邮箱」一栏;这里放其他邮箱', + 'address' => '住址在「住址」一栏;这里放单位地址、老家地址等', + 'url' => '主页、博客、店铺链接……', +]; ?>
@@ -80,7 +101,116 @@ $idBd = trim((string)($person['birthday_id_card'] ?? '')); 写进这张(全家人共用的)资料表单里是错的:我把他归成"同事" 不该把妈妈那边的归类也改掉。 */ ?> - + + +
+ + + 显示在这个人自己的首页标题下面。本人也可以在自己的资料页里改。 + +
+ + +
+ + + 开通登录后用它登录;加密存储,列表里只显示部分。其他号码放下面的「补充电话」。 + +
+ + +
+ + + +
+ +
+ + +
+ +
+ + +
+ + +
+ + + + 加密存储,页面上一律只显示前 6 后 4 位。 + 填了它,最下面的「身份证生日」会自动读出来(当前:), + 这个日期系统永远不会改动。家庭场景里真正需要它的场合很少 —— 能不填就别填。 + + +
+ + +
+ + +
+ +
+ + +
+ + + +
+ +

+ +
+ + +
+ + + +
+ + + +
+ + + + + 选好图片后会弹出裁剪框,把要留的部分框住。比例按它在页面上的形状定(头像是 1:1 正方形)。多大都能传,保存时自动缩放成 ×。 +
+
+ + +
+ +

@@ -134,81 +264,6 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));

-
- - - 显示在这个人自己的首页标题下面。本人也可以在自己的资料页里改。 - -
- - -
- - - 开通登录后用它登录;加密存储,列表里只显示部分。 - -
- - -
- - - -
- - -
- - - - 加密存储,页面上一律只显示前 6 后 4 位。 - 填了它,上面的「身份证生日」会自动读出来(当前:), - 这个日期系统永远不会改动。家庭场景里真正需要它的场合很少 —— 能不填就别填。 - - -
- - -
- - -
- -
- - -
- - -
- - - - - 选好图片后会弹出裁剪框,把要留的部分框住。比例按它在页面上的形状定(头像是 1:1 正方形)。上限 5MB。 -
-
- - -
-
+ +
+
电话
+
+
+
身份证号
@@ -164,9 +170,33 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
邮箱
+ +
+
邮箱
+
+
+ + +
+
工作
+
+
+
住址
+ +
+
地址
+
+
+ + +
+
网址
+
+
+
备注