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
+131 -76
View File
@@ -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">
+30
View File
@@ -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; ?>