Files
FamilyBook/app/PersonJob.php
T
laoyang b5725b449a v24 第 6 轮:名册导入 / 两种备注 / 多段工作经历 / 家谱卡片与首字母检索修正
用户 2026-10-03 第二批(8 条)。逐条落点:

1. **名册导入**(新):支持 **vCard 3.0/4.0(.vcf)** 与 **CSV/TSV**(中英文表头都认)。
   新增 `app/ContactImport.php`(纯解析)+ `app/ContactImporter.php`(判重+落库)。
   判重按 **手机号(哈希,精确)→ 姓名(大小写无关)**;命中已有的人**只补空字段,
   绝不覆盖**(批量覆盖没法回退)。
   解析上要处理的三件事都处理了:**折行接回**(长名字/地址会被按字节折断)、
   转义还原(`\,` `\;` `\n`)、多值收全(TEL/EMAIL/ADR 可能出现多次)。
   UTF-16/GBK 的 vcf 也认(手机导出的常见编码)。
2. 成员详情页:右上角「移除」→ **「返回」**(回名册);「删除」移到页面**最下方**
   (在「开通登录账号」下面),并改成 `<details>` 两步确认 + 写清会连带删掉什么。
3. 编辑资料里的「这个人的一句话」**去掉** —— 那句话只能**本人**改。
   ⚠️ 落法是「这一栏只在改自己时出现」:用户要的是"别人不能替他写",
   而不是"这句没人能填";服务端也只在 `$isSelf` 时读表单,否则管理员一编辑就把它清空。
4. **工作单位 / 职务 / 职称可以有多段**(新表 `person_jobs`):表单是一组"经历行"
   (单位/职务/职称/起止年月/备注,可 +/×),详情页按"现在 + 历史"列出。
   `persons.org/title` 保留,语义变成"当前那段的快照"(列表页等只要"现在在哪儿"),
   升级时把老的 org/title 回填成一段经历。
5/备注分两种:「自我介绍」(`persons.note`,他本人写的、别人看得到)与
   **「我对他的私人备注」**(新表 `person_notes`,每人每对象一条,**只有写的人自己看得到**)。
   详情页有独立的一块;表单里只在"编辑别人"时出现。
6. 成员表单:**头像放到最前**、去掉「选择文件」按钮(点那张默认头像即选文件);
   表单标签统一 15px/600(原来 13–14px,和 12px 的灰字提示分不出层级)。
7. 名册列表:⋯ 前面加**「编辑」**按钮(改资料是高频动作,藏进 ⋯ 要点两下)。
8. 家谱:
   · **首字母检索 bug**:输入 `yy` 会搜出「岳父/洪勇」。根因是前端把
     **姓名与称呼的首字母拼成一串**再判子序列 —— 洪勇的称呼是"岳父" → `hy`+`yf`=`hyyf`
     → `yy` 跨字段命中。改成**逐字段独立判**(服务端本来就是逐字段判的,两边现在同口径)。
   · **卡片仍比头像宽**:头像是 **1:1(正方形)** 槽位,卡片里却写成 96×72 的长方形 +
     `object-fit: contain` → 照片被缩到 72 宽、左右各留 12px 白 → 看着"框比头像宽"。
     改成**正方形头像区 + `object-fit: cover`**(90×128 卡片,88×88 头像区)。
   · 右上角加**「我的家谱」**按钮(只在看别人的家谱时出现 —— 看自己的时候点它没意义)。

**测试新增**(都能变红,且做过反向验证):
- `_fb_import_test.py` 21 条:新人新建 / 手机号命中不重复建人 / **已有字段一个都不被覆盖** /
  缺的字段被补上 / 反复导入幂等 / 只给姓名也能命中 / 认不出的文件被明确拒绝,
  外加 ⑱ **"有单位但没有经历行"的人**(最容易漏的覆盖路径)。
- `_fb_roster_filter_test.js`(jsdom,用真实渲染出的 HTML)8 条:直击
  「输 yy 不该命中 岳父/洪勇」;反向验证时把实现改回拼接写法 → ① 立刻红。
- 改动过程中被测试抓出**两个真 bug**并已修:
  ① 导入时给"已有单位的人"也插经历 → `PersonJob::syncCurrent()` 回写把这人的单位**覆盖**了;
  ② `syncCurrent()` 在经历表为空时会把 `persons.org/title` **清空** ——
     而它被"编辑资料"这类常规路径调用一次就触发,属于**随手一保存就丢数据**。
     现在空表直接 return("经历表不是唯一的一份")。

**战绩**:e2e **775/0** · 导入 21/0 · 第 5 轮专项 91/0 · 家谱 34/0 · 几何 428/0 ·
移动端 688/0 · 交互 97/0 · 升级 126/0。
线上:schema **v15**、指纹一致、`person_jobs`/`person_notes` 已建、
6 条老的"工作单位"全部回填成经历、13 个文件 md5 与本地一致、错误日志无新增。
2026-10-03 19:27:14 +08:00

160 lines
6.8 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
/**
* 工作经历:单位 / 职务 / 职称,**可以有多段**(v24 用户 2026-10-03 第 4 条)。
*
* 用户原话:「工作单位、职称、职务,都是可以填写多个的,因为一个人的工作可能会变,
* 我们需要记住他工作和职务的变化。」
*
* 所以这里存的是**一条条经历**而不是三个字段:
* - 每段有 单位 / 职务 / 职称 / 起止年月 / 备注;
* - 没有结束年月的就是"当前"那一段(列表里排最前、标「现在」);
* - 有起止的按时间倒序排(最近的在上面)。
*
* ⚠️ `persons.org` / `persons.title` **保留**,但语义变成"当前单位/当前职称的快照":
* 名册列表、家谱、观影人标签这些地方只需要"现在在哪儿",不该为了一行文字去 JOIN
* 一张历史表;而且已有数据不会因为这次改动而消失。
* → 保存时由 syncCurrent() 自动把"最新那段"回写到那两个字段。
*/
declare(strict_types=1);
namespace App;
final class PersonJob
{
/** 一段经历:单位/职务/职称/起止/备注;timeline 里 sort 越小越靠前 */
public const MAX = 20; // 一个人最多留 20 段(再多也不会是真的工作史)
/** 某个人的工作经历(当前那段在前,其余按开始时间倒序) */
public static function list(int $personId): array
{
if ($personId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT * FROM person_jobs WHERE person_id = ?
ORDER BY (end_ym IS NULL) DESC, COALESCE(start_ym, "") DESC, id DESC'
);
$st->execute([$personId]);
return $st->fetchAll();
}
/**
* 整组替换(与 Person::replaceContacts 同一套做法:表单里就是一组行,
* 删行/改行/加行一起提交,最省心 —— 逐行增删要额外的行 id 体系)。
*
* ⚠️ 空行(单位、职务、职称都空)会被丢掉,不留垃圾行。
*/
public static function replace(int $personId, array $rows): void
{
if ($personId <= 0) {
return;
}
$db = Database::get();
$db->prepare('DELETE FROM person_jobs WHERE person_id = ?')->execute([$personId]);
$ins = $db->prepare(
'INSERT INTO person_jobs (person_id, org, title, rank_name, start_ym, end_ym, note)
VALUES (?,?,?,?,?,?,?)'
);
$n = 0;
foreach ($rows as $row) {
$org = mb_substr(trim((string)($row['org'] ?? '')), 0, 120, 'UTF-8');
$title = mb_substr(trim((string)($row['title'] ?? '')), 0, 120, 'UTF-8');
$rank = mb_substr(trim((string)($row['rank_name'] ?? '')), 0, 120, 'UTF-8');
$note = mb_substr(trim((string)($row['note'] ?? '')), 0, 200, 'UTF-8');
if ($org === '' && $title === '' && $rank === '') {
continue; // 整行空 → 丢掉
}
if ($n >= self::MAX) {
break;
}
$ins->execute([
$personId,
$org !== '' ? $org : null,
$title !== '' ? $title : null,
$rank !== '' ? $rank : null,
self::ym($row['start_ym'] ?? ''),
self::ym($row['end_ym'] ?? ''),
$note !== '' ? $note : null,
]);
$n++;
}
self::syncCurrent($personId);
}
/**
* 把"当前那一段"回写到 persons.org / persons.title(列表页等地方读的是那两个字段)。
*
* 取"当前"的规则:**没有结束年月的那一段**优先;都填了结束时间就取开始时间最晚的。
*
* 🔴🔴 **经历表为空时什么都不做**(绝不把 org/title 清空)。
* 这一条是实测踩出来的:老库里 `persons.org` 有值、但 `person_jobs` 还没有行
* (升级回填之前、或某次操作删光了经历)。此时如果按"取不到就置空"来写,
* 任何一次 `replace()` 都会把**用户填的单位抹掉** —— 而 replace 被调用在
* "编辑资料"“导入联系人”这类常规路径上,等于随手一保存就丢数据。
* 正确的语义是:**经历表是"更详细的那一份",不是"唯一的一份"** ——
* 它没内容时,就去尊重 org/title 里已有的值。
*/
public static function syncCurrent(int $personId): void
{
$list = self::list($personId);
if (!$list) {
return; // 见上面的说明:没有经历 ≠ 没有单位,别动
}
$cur = null;
foreach ($list as $r) {
if ($r['end_ym'] === null || $r['end_ym'] === '') {
$cur = $r;
break; // list() 已把"无结束"的排在最前
}
}
if ($cur === null) {
$cur = $list[0]; // 全是历史:取最近一段(list 已按开始时间倒序)
}
Database::get()->prepare('UPDATE persons SET org = ?, title = ? WHERE id = ?')
->execute([
$cur['org'] ?: null,
// ⚠️ title 列里放「职务」,rank_name 放「职称」——两个都要能显示,
// 列表页只显示 title,所以把职称拼在后面(有职务时"教授 · 博导"这样)。
self::joinTitle($cur['title'] ?? '', $cur['rank_name'] ?? ''),
$personId,
]);
}
/** 职务 + 职称 合成一个显示串(哪个空就只用另一个;都空返回 null) */
public static function joinTitle(?string $title, ?string $rank): ?string
{
$t = trim((string)$title);
$r = trim((string)$rank);
if ($t !== '' && $r !== '') {
return $t . ' · ' . $r;
}
$v = $t !== '' ? $t : $r;
return $v !== '' ? $v : null;
}
/**
* 从**旧的两个字段**造一段经历(升级回填用)。
* @return array 一行 ['org','title','rank_name','start_ym','end_ym','note']
*/
public static function rowFromLegacy(?string $org, ?string $title): ?array
{
$o = trim((string)$org);
$t = trim((string)$title);
if ($o === '' && $t === '') {
return null;
}
// 老数据的 title 是"职务 / 职称"随手填的,分不出哪个是哪个 →
// 整串放进 title(职务),rank_name 留空。不猜。
return ['org' => $o, 'title' => $t, 'rank_name' => '',
'start_ym' => '', 'end_ym' => '', 'note' => ''];
}
/** 'YYYY-MM' 归一化;不合法返回 null */
private static function ym($v): ?string
{
$v = trim((string)$v);
return preg_match('/^\d{4}-\d{2}$/', $v) ? $v : null;
}
}