v6:名册联系信息对齐 vCard + 头像与裁剪四项修正

- ① 头像上传失败(超限/非图片)不再挡保存,改为 flash 提示「已加进名册。头像没有保存:…」
  ——这是「添加不了」的真根因(错误被塞进 errors.avatar 把整条新增打回)
- ② 表单顺序:生日整块下移(身份证能读出生日时无需常改)
- ③ 裁剪缩放语义反转:框在屏幕上的位置与大小不动、动它底下的图;
  落库归一化矩形随倍率缩小(放大=同一块区域看得更清楚)
- ④ 取消应用层 5MB 兆数上限;按像素动态上调 memory_limit(px*6+64M,封顶 768M);
  头像输出恒为 512x512
- ⑤ 成员信息对齐 vCard:person_contacts 表(phone/email/address/url 可多行,
  value 加密落库)+ persons.org(工作单位)/title(职务职称);均可留空

Schema v5→v6:person_contacts 建表 + persons.org/title 加列
This commit is contained in:
2026-09-30 15:02:27 +08:00
parent ed38a40c08
commit f7609a3ed9
9 changed files with 432 additions and 120 deletions
+4 -2
View File
@@ -18,8 +18,10 @@ namespace App;
final class ImageSlot 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。
/** /**
* 位置 → 比例与输出尺寸。 * 位置 → 比例与输出尺寸。
+64 -4
View File
@@ -127,9 +127,9 @@ final class Person
$sql = 'INSERT INTO persons $sql = 'INSERT INTO persons
(family_id, kind, name, gender, birth_rank, birthday, (family_id, kind, name, gender, birth_rank, birthday,
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar, 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) is_placeholder)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
$st = Database::get()->prepare($sql); $st = Database::get()->prepare($sql);
$st->execute([ $st->execute([
$familyId, $familyId,
@@ -148,6 +148,8 @@ final class Person
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null, ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null, ($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : 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, ($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0, !empty($d['is_placeholder']) ? 1 : 0,
]); ]);
@@ -162,7 +164,7 @@ final class Person
name = ?, gender = ?, birth_rank = ?, birthday = ?, name = ?, gender = ?, birth_rank = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?, birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?, phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
email = ?, address = ?, note = ?, is_placeholder = ? email = ?, address = ?, org = ?, title = ?, note = ?, is_placeholder = ?
WHERE id = ?'; WHERE id = ?';
Database::get()->prepare($sql)->execute([ Database::get()->prepare($sql)->execute([
$d['name'], $d['name'],
@@ -179,6 +181,8 @@ final class Person
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null, ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null, ($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : 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, ($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0, !empty($d['is_placeholder']) ? 1 : 0,
$id, $id,
@@ -202,7 +206,7 @@ final class Person
'UPDATE persons SET 'UPDATE persons SET
name = ?, gender = ?, birthday = ?, name = ?, gender = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?, birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
email = ?, address = ?, note = ? email = ?, address = ?, org = ?, title = ?, note = ?
WHERE id = ?' WHERE id = ?'
)->execute([ )->execute([
$d['name'], $d['name'],
@@ -215,11 +219,67 @@ final class Person
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null, ($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
($d['email'] ?? '') !== '' ? $d['email'] : null, ($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : 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, ($d['note'] ?? '') !== '' ? $d['note'] : null,
$id, $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 public static function setAvatar(int $id, ?string $avatar): void
{ {
+29 -1
View File
@@ -29,8 +29,11 @@ final class Schema
* 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、 * 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、
* person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组); * person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组);
* 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上) * 同时**删掉 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[] */ /** @return string[] */
public static function statements(): array public static function statements(): array
@@ -171,6 +174,20 @@ CREATE TABLE IF NOT EXISTS `user_settings` (
PRIMARY KEY (`person_id`, `k`), PRIMARY KEY (`person_id`, `k`),
CONSTRAINT `fk_uset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE CONSTRAINT `fk_uset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='「我自己的」设置:只影响我自己看到的样子' ) 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, SQL,
]; ];
} }
@@ -297,6 +314,17 @@ SQL,
self::dropColumnIfExists($db, 'persons', 'group_id'); self::dropColumnIfExists($db, 'persons', 'group_id');
self::dropColumnIfExists($db, 'persons', 'sort_order'); 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) { if (self::$stepErrors) {
+42 -10
View File
@@ -14,7 +14,14 @@
* 安全要点: * 安全要点:
* · 用 finfo 判断真实 MIME,不信浏览器给的扩展名 * · 用 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); declare(strict_types=1);
@@ -56,15 +63,13 @@ final class Upload
return ['ok' => false, 'error' => '']; return ['ok' => false, 'error' => ''];
case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE: case UPLOAD_ERR_FORM_SIZE:
return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')']; // 应用层不再设「兆数」上限;这条只会在超过服务器 ini 上限时发生。
return ['ok' => false, 'error' => '图片太大了(服务器单次上传上限 '
. self::iniUploadWord() . '),请压缩后再试'];
default: default:
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')']; 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); $finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']); $mime = $finfo->file($file['tmp_name']);
$allow = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng', $allow = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng',
@@ -78,8 +83,16 @@ final class Upload
if (!$info || $info[0] < 1 || $info[1] < 1) { if (!$info || $info[0] < 1 || $info[1] < 1) {
return ['ok' => false, 'error' => '图片读不出来']; return ['ok' => false, 'error' => '图片读不出来'];
} }
if ($info[0] * $info[1] > 40_000_000) { $px = $info[0] * $info[1];
return ['ok' => false, 'error' => '图片像素太多(超过 4000 万),请先压缩再传']; 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']); $src = @$allow[$mime]($file['tmp_name']);
@@ -238,9 +251,28 @@ final class Upload
return [$ix, $iy, $iw, $ih]; 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,
};
} }
/** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */ /** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */
+9
View File
@@ -749,3 +749,12 @@ textarea.input { min-height: 76px; resize: vertical; }
.group-manage-row { align-items: flex-start; } .group-manage-row { align-items: flex-start; }
.crop-panel { padding: 12px; } .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; }
}
+27 -19
View File
@@ -168,28 +168,19 @@
boxEl.style.height = h + 'px'; 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) { function setZoom(z) {
if (!st) { return; } if (!st) { return; }
z = Math.max(1, Math.min(MAX_ZOOM, z)); 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.zoom = z;
st.dispW = Math.max(1, Math.round(st.baseW * z)); st.dispW = Math.max(1, Math.round(st.baseW * z));
st.dispH = Math.max(1, Math.round(st.baseH * z)); st.dispH = Math.max(1, Math.round(st.baseH * z));
@@ -205,10 +196,8 @@
var valEl = overlay.querySelector('.crop-zoom-val'); var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; } if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
place(before.x * st.dispW, before.y * st.dispH, // 框保持屏显大小;位置也保持(place 只做越界夹取,缩小到 1× 时兜底用)
before.w * st.dispW, before.h * st.dispH); place(b.x, b.y, b.w, b.h);
stage.scrollLeft = Math.max(0, boxEl.offsetLeft - (st.baseW - boxEl.offsetWidth) / 2);
stage.scrollTop = Math.max(0, boxEl.offsetTop - (st.baseH - boxEl.offsetHeight) / 2);
} }
function open(input, file) { function open(input, file) {
@@ -417,6 +406,25 @@
calc(); calc();
})(); })();
// ===== 补充联系方式:「再加一个」克隆出下一行 =====
// 平行数组命名(ct_<kind>_label[] / ct_<kind>_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 列表),一次提交写完 —— // 落库的是**整版布局**(每栏的有序 id 列表),一次提交写完 ——
+96 -8
View File
@@ -108,6 +108,57 @@ function read_birthday_input(array $existing = []): array
]; ];
} }
/**
* 从表单解析「补充联系方式」(vCard 对齐,v6 起)。
*
* 每类两个平行数组:ct_<kind>_label[] / ct_<kind>_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)。 * 渲染「个人设置」页(settings/me)。
* *
@@ -669,6 +720,8 @@ switch ($r) {
'id_card' => trim((string)($_POST['id_card'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => '', 'avatar' => '',
@@ -679,12 +732,18 @@ switch ($r) {
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
] + read_birthday_input(); ] + read_birthday_input();
$errors = Person::validate($d); $errors = Person::validate($d);
$contacts = read_contacts_input();
$ctErr = validate_contacts($contacts);
if ($ctErr !== '') {
$errors['contacts'] = $ctErr;
}
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
// 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。
// 现在是:人先存进去,头像的事单独提示。
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [], $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? '')); (string)($_POST['avatar_crop'] ?? ''));
if (!$up['ok'] && ($up['error'] ?? '') !== '') { $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
$errors['avatar'] = $up['error'];
}
if ($errors) { if ($errors) {
set_old($_POST); set_old($_POST);
View::render('people/form', [ View::render('people/form', [
@@ -695,6 +754,7 @@ switch ($r) {
'idCardValue'=> $d['id_card'], 'idCardValue'=> $d['id_card'],
'canEditAll' => true, 'canEditAll' => true,
'isSelf' => false, 'isSelf' => false,
'contacts' => $contacts,
], 'people'); ], 'people');
exit; exit;
} }
@@ -713,14 +773,20 @@ switch ($r) {
'idCardValue'=> $d['id_card'], 'idCardValue'=> $d['id_card'],
'canEditAll' => true, 'canEditAll' => true,
'isSelf' => false, 'isSelf' => false,
'contacts' => $contacts,
], 'people'); ], 'people');
exit; exit;
} }
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。 // 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
// 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。 // 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self'); PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self');
Person::replaceContacts($id, $contacts);
clear_old(); clear_old();
flash('ok', '已把「' . $d['name'] . '」加进名册。'); if ($avatarWarn !== '') {
flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn);
} else {
flash('ok', '已把「' . $d['name'] . '」加进名册。');
}
redirect('people/show', ['id' => $id]); redirect('people/show', ['id' => $id]);
break; break;
@@ -743,6 +809,12 @@ switch ($r) {
redirect('people'); redirect('people');
} }
clear_old(); 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', [ View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员', 'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p, 'person' => $p,
@@ -751,6 +823,7 @@ switch ($r) {
'isSelf' => $isSelf, 'isSelf' => $isSelf,
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']), 'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']), 'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
'contacts' => $editContacts,
], 'people'); ], 'people');
break; break;
@@ -777,6 +850,8 @@ switch ($r) {
'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']), 'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']),
'email' => trim((string)($_POST['email'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => (string)$p['avatar'], 'avatar' => (string)$p['avatar'],
@@ -786,11 +861,15 @@ switch ($r) {
: (int)($p['is_placeholder'] ?? 0), : (int)($p['is_placeholder'] ?? 0),
] + read_birthday_input($p); ] + read_birthday_input($p);
$errors = Person::validate($d); $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'] ?? [], $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? '')); (string)($_POST['avatar_crop'] ?? ''));
if (!$up['ok'] && ($up['error'] ?? '') !== '') { $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
$errors['avatar'] = $up['error'];
}
if ($errors) { if ($errors) {
set_old($_POST); set_old($_POST);
View::render('people/form', [ View::render('people/form', [
@@ -801,6 +880,7 @@ switch ($r) {
'isSelf' => $isSelf, 'isSelf' => $isSelf,
'phoneValue' => $d['phone'], 'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'], 'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people'); ], 'people');
exit; exit;
} }
@@ -827,16 +907,22 @@ switch ($r) {
'isSelf' => $isSelf, 'isSelf' => $isSelf,
'phoneValue' => $d['phone'], 'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'], 'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people'); ], 'people');
exit; exit;
} }
Person::replaceContacts($id, $contacts);
if ((int)$p['id'] === (int)Auth::personId()) { if ((int)$p['id'] === (int)Auth::personId()) {
Auth::refreshAvatar((string)$d['avatar']); Auth::refreshAvatar((string)$d['avatar']);
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字 // 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
$_SESSION['name'] = (string)$d['name']; $_SESSION['name'] = (string)$d['name'];
} }
clear_old(); clear_old();
flash('ok', '已保存。'); if ($avatarWarn !== '') {
flash('ok', '已保存。头像没有更换:' . $avatarWarn);
} else {
flash('ok', '已保存。');
}
redirect('people/show', ['id' => $id]); redirect('people/show', ['id' => $id]);
break; break;
@@ -873,6 +959,8 @@ switch ($r) {
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']), 'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
'birthdays' => Birthday::describe($p), 'birthdays' => Birthday::describe($p),
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400), '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), 'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
], 'people'); ], 'people');
+131 -76
View File
@@ -29,6 +29,27 @@ if ($lunVal === '') {
$lunVal = $lv; $lunVal = $lv;
} }
$idBd = trim((string)($person['birthday_id_card'] ?? '')); $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' => '主页、博客、店铺链接……',
];
?> ?>
<div class="page-head"> <div class="page-head">
@@ -80,7 +101,116 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
写进这张(全家人共用的)资料表单里是错的:我把他归成"同事" 写进这张(全家人共用的)资料表单里是错的:我把他归成"同事"
不该把妈妈那边的归类也改掉。 */ ?> 不该把妈妈那边的归类也改掉。 */ ?>
<!-- ==================== 三个生日 ==================== --> <!-- 生日块已挪到表单最底部:大多数情况下生日由身份证号读出、三个都对,
不需要动(用户原话:生日什么的应该放下面)。 -->
<div class="field full">
<label for="slogan">这个人的一句话</label>
<input class="input" id="slogan" name="slogan" maxlength="200"
value="<?= e(old('slogan', $person['slogan'] ?? '')) ?>"
placeholder="可留空;留空时用家庭统一的那句">
<span class="tip">显示在这个人自己的首页标题下面。本人也可以在自己的资料页里改。</span>
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
</div>
<?php if ($canEditAll): ?>
<div class="field">
<label for="phone">手机号</label>
<input class="input" id="phone" name="phone" inputmode="numeric"
value="<?= e(old('phone', $phoneValue ?? '')) ?>" placeholder="可留空">
<span class="tip">开通登录后用它登录;加密存储,列表里只显示部分。其他号码放下面的「补充电话」。</span>
<?php if (!empty($errors['phone'])): ?><span class="err"><?= e($errors['phone']) ?></span><?php endif; ?>
</div>
<?php endif; ?>
<div class="field">
<label for="email">邮箱</label>
<input class="input" id="email" name="email" type="email"
value="<?= e(old('email', $person['email'] ?? '')) ?>" placeholder="可留空">
<?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="org">工作单位</label>
<input class="input" id="org" name="org"
value="<?= e(old('org', $person['org'] ?? '')) ?>" placeholder="可留空">
</div>
<div class="field">
<label for="title">职务 / 职称</label>
<input class="input" id="title" name="title"
value="<?= e(old('title', $person['title'] ?? '')) ?>" placeholder="可留空">
</div>
<?php if ($canEditAll): ?>
<div class="field full">
<label for="id_card">身份证号</label>
<input class="input mono" id="id_card" name="id_card"
value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空">
<span class="tip">
加密存储,页面上一律只显示前 6 后 4 位。
<b>填了它,最下面的「身份证生日」会自动读出来</b>(当前:<span id="idCardBirthday"><?= $idBd !== '' ? e($idBd) : '' ?></span>),
这个日期系统永远不会改动。家庭场景里真正需要它的场合很少 —— 能不填就别填。
</span>
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
</div>
<?php endif; ?>
<div class="field full">
<label for="address">住址</label>
<input class="input" id="address" name="address"
value="<?= e(old('address', $person['address'] ?? '')) ?>" placeholder="可留空">
</div>
<div class="field full">
<label for="note">备注</label>
<textarea class="input" id="note" name="note" placeholder="爱吃辣、对芒果过敏、怕坐过山车……"><?= e(old('note', $person['note'] ?? '')) ?></textarea>
</div>
<!-- ==================== 补充联系方式(可多条,对齐 vCard) ==================== -->
<?php foreach (['phone', 'email', 'address', 'url'] as $ctKind): ?>
<div class="field full ct-block" data-ct-kind="<?= $ctKind ?>">
<label><?= $ctTitles[$ctKind] ?></label>
<p class="small muted" style="margin:0 0 8px"><?= $ctHints[$ctKind] ?></p>
<?php foreach (array_merge($contacts[$ctKind], [$ctEmpty]) as $ctRow): ?>
<div class="ct-row" data-ct-row>
<input class="input ct-label" name="ct_<?= $ctKind ?>_label[]" maxlength="20"
value="<?= e((string)($ctRow['label'] ?? '')) ?>" placeholder="标注(如:工作)">
<input class="input" name="ct_<?= $ctKind ?>_value[]"
value="<?= e((string)($ctRow['value'] ?? '')) ?>"
placeholder="<?= $ctKind === 'phone' ? '号码' : ($ctKind === 'url' ? 'https://…' : '内容') ?>">
</div>
<?php endforeach; ?>
<button type="button" class="btn btn-ghost btn-sm ct-add">再加一个</button>
<?php if (!empty($errors['contacts'])): ?><span class="err"><?= e($errors['contacts']) ?></span><?php endif; ?>
</div>
<?php endforeach; ?>
<?php
// 裁剪比例不是拍脑袋定的,而是由「这个位置显示成什么形状」决定(见 App\ImageSlot)。
// 前端拿这些 data-* 去生成裁剪框,服务端拿同一个槽位去最终裁切 —— 一处定义、两处使用。
$avSlot = \App\ImageSlot::get('avatar');
?>
<div class="field">
<label for="avatar">头像</label>
<input class="input" id="avatar" name="avatar" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$avSlot['ratio']) ?>"
data-crop-w="<?= (int)$avSlot['w'] ?>"
data-crop-h="<?= (int)$avSlot['h'] ?>"
data-crop-label="<?= e((string)$avSlot['label']) ?>"
data-crop-hint="<?= e((string)$avSlot['hint']) ?>"
data-crop-preview="#avatar-preview">
<?php /* 用户挑的裁剪框(归一化的,相对原图 0..1);不裁就留空 = 居中裁切 */ ?>
<input type="hidden" name="avatar_crop" value="">
<span class="tip">选好图片后会弹出裁剪框,把要留的部分框住。<b>比例按它在页面上的形状定</b>(头像是 1:1 正方形)。多大都能传,保存时自动缩放成 <?= (int)$avSlot['w'] ?>×<?= (int)$avSlot['h'] ?>。</span>
</div>
<div class="field">
<label>当前头像</label>
<img id="avatar-preview" src="<?= e($avatarUrl) ?>" alt=""
style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)">
</div>
<!-- ==================== 三个生日(放在最后:多数情况不用动) ==================== -->
<div class="field full"> <div class="field full">
<label>生日</label> <label>生日</label>
<p class="small muted" style="margin:0 0 12px"> <p class="small muted" style="margin:0 0 12px">
@@ -134,81 +264,6 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
</div> </div>
</div> </div>
<div class="field full">
<label for="slogan">这个人的一句话</label>
<input class="input" id="slogan" name="slogan" maxlength="200"
value="<?= e(old('slogan', $person['slogan'] ?? '')) ?>"
placeholder="可留空;留空时用家庭统一的那句">
<span class="tip">显示在这个人自己的首页标题下面。本人也可以在自己的资料页里改。</span>
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
</div>
<?php if ($canEditAll): ?>
<div class="field">
<label for="phone">手机号</label>
<input class="input" id="phone" name="phone" inputmode="numeric"
value="<?= e(old('phone', $phoneValue ?? '')) ?>" placeholder="可留空">
<span class="tip">开通登录后用它登录;加密存储,列表里只显示部分。</span>
<?php if (!empty($errors['phone'])): ?><span class="err"><?= e($errors['phone']) ?></span><?php endif; ?>
</div>
<?php endif; ?>
<div class="field">
<label for="email">邮箱</label>
<input class="input" id="email" name="email" type="email"
value="<?= e(old('email', $person['email'] ?? '')) ?>">
<?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?>
</div>
<?php if ($canEditAll): ?>
<div class="field full">
<label for="id_card">身份证号</label>
<input class="input mono" id="id_card" name="id_card"
value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空">
<span class="tip">
加密存储,页面上一律只显示前 6 后 4 位。
<b>填了它,上面的「身份证生日」会自动读出来</b>(当前:<span id="idCardBirthday"><?= $idBd !== '' ? e($idBd) : '' ?></span>),
这个日期系统永远不会改动。家庭场景里真正需要它的场合很少 —— 能不填就别填。
</span>
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
</div>
<?php endif; ?>
<div class="field full">
<label for="address">住址</label>
<input class="input" id="address" name="address"
value="<?= e(old('address', $person['address'] ?? '')) ?>">
</div>
<div class="field full">
<label for="note">备注</label>
<textarea class="input" id="note" name="note" placeholder="爱吃辣、对芒果过敏、怕坐过山车……"><?= e(old('note', $person['note'] ?? '')) ?></textarea>
</div>
<?php
// 裁剪比例不是拍脑袋定的,而是由「这个位置显示成什么形状」决定(见 App\ImageSlot)。
// 前端拿这些 data-* 去生成裁剪框,服务端拿同一个槽位去最终裁切 —— 一处定义、两处使用。
$avSlot = \App\ImageSlot::get('avatar');
?>
<div class="field">
<label for="avatar">头像</label>
<input class="input" id="avatar" name="avatar" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$avSlot['ratio']) ?>"
data-crop-w="<?= (int)$avSlot['w'] ?>"
data-crop-h="<?= (int)$avSlot['h'] ?>"
data-crop-label="<?= e((string)$avSlot['label']) ?>"
data-crop-hint="<?= e((string)$avSlot['hint']) ?>"
data-crop-preview="#avatar-preview">
<?php /* 用户挑的裁剪框(归一化的,相对原图 0..1);不裁就留空 = 居中裁切 */ ?>
<input type="hidden" name="avatar_crop" value="">
<span class="tip">选好图片后会弹出裁剪框,把要留的部分框住。<b>比例按它在页面上的形状定</b>(头像是 1:1 正方形)。上限 5MB。</span>
</div>
<div class="field">
<label>当前头像</label>
<img id="avatar-preview" src="<?= e($avatarUrl) ?>" alt=""
style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)">
</div>
<?php if ($canEditAll): ?> <?php if ($canEditAll): ?>
<div class="field full"> <div class="field full">
<label class="check-item"> <label class="check-item">
+30
View File
@@ -157,6 +157,12 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
<dt>手机号</dt> <dt>手机号</dt>
<dd class="mono"><?= e(\App\Crypto::maskPhone((string)($p['phone_encrypted'] ?? ''))) ?></dd> <dd class="mono"><?= e(\App\Crypto::maskPhone((string)($p['phone_encrypted'] ?? ''))) ?></dd>
</div> </div>
<?php foreach (($contacts['phone'] ?? []) as $r): ?>
<div class="kv-row">
<dt>电话<?= (string)$r['label'] !== '' ? '(' . e((string)$r['label']) . ')' : '' ?></dt>
<dd class="mono"><?= e(\App\Crypto::maskPhone((string)$r['value'])) ?></dd>
</div>
<?php endforeach; ?>
<div class="kv-row"> <div class="kv-row">
<dt>身份证号</dt> <dt>身份证号</dt>
<dd class="mono"><?= e(\App\Crypto::maskIdCard((string)($p['id_card_encrypted'] ?? ''))) ?></dd> <dd class="mono"><?= e(\App\Crypto::maskIdCard((string)($p['id_card_encrypted'] ?? ''))) ?></dd>
@@ -164,9 +170,33 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
<?php if (!empty($p['email'])): ?> <?php if (!empty($p['email'])): ?>
<div class="kv-row"><dt>邮箱</dt><dd><?= e($p['email']) ?></dd></div> <div class="kv-row"><dt>邮箱</dt><dd><?= e($p['email']) ?></dd></div>
<?php endif; ?> <?php endif; ?>
<?php foreach (($contacts['email'] ?? []) as $r): ?>
<div class="kv-row">
<dt>邮箱<?= (string)$r['label'] !== '' ? '(' . e((string)$r['label']) . ')' : '' ?></dt>
<dd><?= e((string)$r['value']) ?></dd>
</div>
<?php endforeach; ?>
<?php if (!empty($p['org']) || !empty($p['title'])): ?>
<div class="kv-row">
<dt>工作</dt>
<dd><?= e(trim((string)$p['org'] . (($p['org'] ?? '') !== '' && ($p['title'] ?? '') !== '' ? ' · ' : '') . (string)$p['title'])) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($p['address'])): ?> <?php if (!empty($p['address'])): ?>
<div class="kv-row"><dt>住址</dt><dd><?= e($p['address']) ?></dd></div> <div class="kv-row"><dt>住址</dt><dd><?= e($p['address']) ?></dd></div>
<?php endif; ?> <?php endif; ?>
<?php foreach (($contacts['address'] ?? []) as $r): ?>
<div class="kv-row">
<dt>地址<?= (string)$r['label'] !== '' ? '(' . e((string)$r['label']) . ')' : '' ?></dt>
<dd><?= e((string)$r['value']) ?></dd>
</div>
<?php endforeach; ?>
<?php foreach (($contacts['url'] ?? []) as $r): ?>
<div class="kv-row">
<dt>网址<?= (string)$r['label'] !== '' ? '(' . e((string)$r['label']) . ')' : '' ?></dt>
<dd class="mono"><?= e((string)$r['value']) ?></dd>
</div>
<?php endforeach; ?>
<?php if (!empty($p['note'])): ?> <?php if (!empty($p['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e($p['note'])) ?></dd></div> <div class="kv-row"><dt>备注</dt><dd><?= nl2br(e($p['note'])) ?></dd></div>
<?php endif; ?> <?php endif; ?>