Files
FamilyBook/templates/people/form.php
T
laoyang 71a7ec35a0 v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00

317 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 成员表单(新增 / 编辑)。
*
* 三处与早期版本不同的地方:
* · 「小名」没了 —— 称呼是**视角属性**(妈妈叫"明明"、姐姐叫"小弟"),
* 在「成员详情」页里各自设置自己怎么叫,不写在这个人的档案里。
* · 生日变成**三个**:身份证生日(由证件推导、只读)、阳历生日、农历生日。
* 阳历与农历是同一个人的同一天,所以用「以哪个为准」明确这次填的是哪一个,
* 服务端据此反推另一个 —— 不靠 JS,避免"JS 没跑到就静默存错"。
* · 生日精确到**时分秒**:原生 datetime-local 只有时分、且中文环境会显示成
* 「下午 03:30」,所以用 _datetime.php 的「日期 + 24 小时制时:分:秒」组合控件。
*/
$isEdit = !empty($person['id']);
$avatarUrl = avatar_url($person['avatar'] ?? null);
// 这张表单有**两个入口**(名册/详情页的「编辑资料」、个人设置里的「编辑我的资料」),
// 但只有一套字段。改自己、且不是能写的人(访客)时,只给属于他自己的那些 ——
// 见 index.php 的 people/update(那条分支调 Person::updateSelf)。
$canEditAll = $canEditAll ?? \App\Auth::canWrite();
$isSelf = $isSelf ?? false;
$bdSource = old('birthday_source', 'solar');
// 库里这列叫 birthday、表单叫 birthday_solar,一律走 solarOf() 取,别直接下标
$solVal = old('birthday_solar', \App\Birthday::pretty(\App\Birthday::solarOf($person ?? [])));
$lunVal = old('birthday_lunar', '');
if ($lunVal === '') {
// 农历控件的值 = 库里存的农历年月日 + 时间
$lv = (string)($person['birthday_lunar'] ?? '');
$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">
<h1><?= $isEdit ? ($isSelf ? '我的资料' : '编辑成员') : '添加成员' ?></h1>
<span class="sub"><?= $isEdit ? e($person['name']) : '填姓名就行,其他都可以以后再补' ?></span>
</div>
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEdit ? 'people/update' : 'people/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$person['id'] ?>">
<?php endif; ?>
<div class="card card-pad">
<div class="form-grid">
<div class="field">
<label for="name">姓名 *</label>
<input class="input" id="name" name="name" value="<?= e(old('name', $person['name'] ?? '')) ?>" required>
<?php if (!empty($errors['name'])): ?><span class="err"><?= e($errors['name']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="gender">性别</label>
<select class="select" id="gender" name="gender">
<?php $g = old('gender', $person['gender'] ?? ''); ?>
<option value="" <?= $g === '' ? 'selected' : '' ?>>不填</option>
<option value="m" <?= $g === 'm' ? 'selected' : '' ?>>男</option>
<option value="f" <?= $g === 'f' ? 'selected' : '' ?>>女</option>
</select>
</div>
<?php if ($canEditAll): ?>
<div class="field">
<label for="birth_rank">家中排行</label>
<input class="input" id="birth_rank" name="birth_rank" type="number" min="1" max="99"
value="<?= e(old('birth_rank', $person['birth_rank'] ?? '')) ?>" placeholder="老大填 1">
<span class="tip">用来认兄弟姐妹的长幼:长子、次女、三弟…</span>
<?php if (!empty($errors['birth_rank'])): ?><span class="err"><?= e($errors['birth_rank']) ?></span><?php endif; ?>
</div>
<?php endif; ?>
<?php /* ⚠️ 这里**没有**「排序号」(已彻底取消,用户原话:「排序号,不要让用户填写」),
名册里的先后靠**拖动卡片**调。
⚠️「所属分组」**只在新增时出现**($isEdit 为假):
分组是"我和他的关系",写在我自己的名册条目上 ——
· 新增时这个人还不存在,别人名册里不可能有他,这里选了只影响我自己,
正是用户要的「增加名册时可选择所属分组」;
· 编辑时**不能**在这里给:我把他归成"同事"不该把妈妈那边的归类也改掉 ——
改分组走名册里拖动,或那张卡片的「调整分组」。 */ ?>
<?php if (!$isEdit): ?>
<div class="field">
<label for="group_id">所属分组</label>
<select class="select" id="group_id" name="group_id">
<option value="0">先不分类</option>
<?php foreach (\App\PersonGroup::orderFor((int)\App\Auth::familyId(), (int)\App\Auth::personId()) as $g): ?>
<option value="<?= (int)$g['id'] ?>"
<?= (int)old('group_id', 0) === (int)$g['id'] ? 'selected' : '' ?>>
<?= e((string)$g['name']) ?><?= (int)$g['owner_id'] === 0 ? '(公共)' : '' ?>
</option>
<?php endforeach; ?>
</select>
<span class="tip">加进来之后也能改:在名册里把他拖到那一栏就行。</span>
</div>
<?php endif; ?>
<!-- 生日块已挪到表单最底部:大多数情况下生日由身份证号读出、三个都对,
不需要动(用户原话:生日什么的应该放下面)。 -->
<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">
一个人有三个生日:<b>身份证上的</b>(由证件推导,系统永远不会改它)、
<b>真实阳历生日</b>、<b>农历生日</b>。填了身份证号会自动读出第一个;
阳历与农历只要填一个,另一个自动算出来。三个不在同一天时,首页会分别提醒。
</p>
<div class="field" style="margin-bottom:12px">
<label for="birthday_id_card_display">身份证生日(只读)</label>
<span class="bd-readonly" id="birthday_id_card_display"><?= $idBd !== '' ? e($idBd) : '填了身份证号后自动读出' ?></span>
</div>
<div class="bd-source">
<label for="bdSource">我这次填的是</label>
<select class="select" id="bdSource" name="birthday_source" style="width:auto">
<option value="solar" <?= $bdSource === 'solar' ? 'selected' : '' ?>>阳历(公历)</option>
<option value="lunar" <?= $bdSource === 'lunar' ? 'selected' : '' ?>>农历(阴历)</option>
</select>
<span class="small muted">选中的那一边为准,另一边自动算</span>
</div>
<div class="bd-pane" id="bdSolarWrap">
<p class="bd-pane-k">阳历生日</p>
<?php
$dtName = 'birthday_solar';
$dtValue = $solVal;
$dtId = 'birthday_solar_date';
$dtRequired = false;
require __DIR__ . '/../_datetime.php';
?>
<span class="tip">精确到时分秒。不填时分秒就按 00:00:00 记。</span>
<?php if (!empty($errors['birthday_solar'])): ?><span class="err"><?= e($errors['birthday_solar']) ?></span><?php endif; ?>
</div>
<div class="bd-pane" id="bdLunarWrap">
<p class="bd-pane-k">农历生日</p>
<?php
$dtName = 'birthday_lunar';
$dtValue = $lunVal;
$dtId = 'birthday_lunar_date';
$dtRequired = false;
require __DIR__ . '/../_datetime.php';
?>
<label class="check-item" style="margin-top:8px">
<input type="checkbox" name="birthday_lunar_leap" value="1"
<?= old('birthday_lunar_leap', $person['birthday_lunar_leap'] ?? 0) ? 'checked' : '' ?>>
<span>这个月是<b>闰月</b>(如「闰六月」)</span>
</label>
<?php if (!empty($errors['birthday_lunar'])): ?><span class="err"><?= e($errors['birthday_lunar']) ?></span><?php endif; ?>
</div>
</div>
<?php if ($canEditAll): ?>
<div class="field full">
<label class="check-item">
<input type="checkbox" name="is_placeholder" value="1"
<?= old('is_placeholder', $person['is_placeholder'] ?? 0) ? 'checked' : '' ?>>
<span>
<b>这是「虚拟人物」</b><br>
<span class="muted small">
还没弄清真实身份时先占个位,比如起名「我的父亲」「外婆」。
他能参与亲属关系,等弄清真名再改过来即可;家谱上会标「待确认」。
</span>
</span>
</label>
</div>
<?php endif; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加入名册' ?></button>
<?php
// 取消:改自己的话回「个人设置」更顺手(那是他来的地方),
// 改别人 / 新增则回他的详情页 / 名册。
$cancelUrl = $isEdit
? ($isSelf ? url('settings/me') : url('people/show', ['id' => (int)$person['id']]))
: url('people');
?>
<a class="btn btn-ghost" href="<?= e($cancelUrl) ?>">取消</a>
<?php if ($isSelf): ?>
<a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
<?php endif; ?>
</div>
</div>
</form>