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:
+4
-2
@@ -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。
|
||||
|
||||
/**
|
||||
* 位置 → 比例与输出尺寸。
|
||||
|
||||
+64
-4
@@ -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
|
||||
{
|
||||
|
||||
+29
-1
@@ -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) {
|
||||
|
||||
+42
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
+27
-19
@@ -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_<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 列表),一次提交写完 ——
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
@@ -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();
|
||||
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();
|
||||
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');
|
||||
|
||||
+131
-76
@@ -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' => '主页、博客、店铺链接……',
|
||||
];
|
||||
?>
|
||||
|
||||
<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">
|
||||
<label>生日</label>
|
||||
<p class="small muted" style="margin:0 0 12px">
|
||||
@@ -134,81 +264,6 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
||||
</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): ?>
|
||||
<div class="field full">
|
||||
<label class="check-item">
|
||||
|
||||
@@ -157,6 +157,12 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
|
||||
<dt>手机号</dt>
|
||||
<dd class="mono"><?= e(\App\Crypto::maskPhone((string)($p['phone_encrypted'] ?? ''))) ?></dd>
|
||||
</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">
|
||||
<dt>身份证号</dt>
|
||||
<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'])): ?>
|
||||
<div class="kv-row"><dt>邮箱</dt><dd><?= e($p['email']) ?></dd></div>
|
||||
<?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'])): ?>
|
||||
<div class="kv-row"><dt>住址</dt><dd><?= e($p['address']) ?></dd></div>
|
||||
<?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'])): ?>
|
||||
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e($p['note'])) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
|
||||
Reference in New Issue
Block a user