Files
FamilyBook/app/ContactImport.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

349 lines
15 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 第 1 条)。
*
* 用户原话:「名册模块,需要增加一个导入功能,可以导入常见的联系人格式,例如 vcf 等。
* 导入时,如果名字或手机号在已有的名册中存在,则导入其他信息,
* 如果这些信息在系统中已经存在,则只导入其他信息。」
*
* 读法:**按姓名或手机号判重** ——
* · 命中已有的人 → 这条记录不进新人,只把"他缺的信息"补上(**绝不覆盖已有值**);
* · 没命中 → 新建一条。
*
* 支持格式(都能从手机/邮箱/通讯录导出):
* · **vCard 3.0 / 4.0**(.vcf)—— from iPhone / Android / Google Contacts / Outlook
* · **CSV**(.csv / .txt)—— 表头含 姓名/手机/电话/邮箱 之类即可(中英文表头都认)
*
* ⚠️ 只做"补充缺失字段",不做"覆盖":
* 导入是**批量**操作,一旦覆盖,用户就没法把它退回去(哪个字段被改过都看不出来)。
* 而"补上没填的"是安全的 —— 最坏情况也只是多了一条信息,不会丢掉原有内容。
*/
declare(strict_types=1);
namespace App;
final class ContactImport
{
/** vCard / CSV 认得的字段 → 我们的字段 */
private const HEADER_MAP = [
// 姓名
'name' => 'name', '姓名' => 'name', '名字' => 'name', 'fullname' => 'name',
'displayname' => 'name', 'fn' => 'name',
// 电话
'phone' => 'phone', 'tel' => 'phone', 'mobile' => 'phone', 'cell' => 'phone',
'telephone' => 'phone', '手机' => 'phone', '手机号' => 'phone', '电话' => 'phone',
'电话号码' => 'phone', '手机号码' => 'phone',
// 邮箱
'email' => 'email', 'mail' => 'email', '邮箱' => 'email', '电子邮件' => 'email',
'e-mail' => 'email',
// 单位/职务/职称
'org' => 'org', 'organization' => 'org', 'company' => 'org', '单位' => 'org',
'工作单位' => 'org', '公司' => 'org',
'title' => 'title', '职务' => 'title', '职称' => 'title', 'jobtitle' => 'title',
// 地址
'address' => 'address', 'adr' => 'address', '地址' => 'address', '住址' => 'address',
// 备注/生日(生日只认 YYYY-MM-DD,认不出就不导 —— 猜生日比不导更糟)
'note' => 'note', '备注' => 'note', 'memo' => 'note',
'birthday' => 'birthday', 'bday' => 'birthday', '生日' => 'birthday',
// 其他
'nickname' => 'nickname', '昵称' => 'nickname', '小名' => 'nickname',
'url' => 'url', 'website' => 'url', '网站' => 'url', '主页' => 'url',
];
/**
* 解析一份上传文件 → 记录数组。
*
* @return array<int,array> 每条:name/phone/phones[]/email/emails[]/org/title/address/note/birthday/url
*/
public static function parse(string $raw, string $filename = ''): array
{
$raw = self::toUtf8($raw);
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
// 判据用**内容**而不是扩展名:手机导出的 .vcf 有时被改名成 .txt,
// 而这种文件里的 BEGIN:VCARD 是最硬的信号。
if (stripos($raw, 'BEGIN:VCARD') !== false || $ext === 'vcf') {
return self::parseVcard($raw);
}
if ($ext === 'csv' || strpos($raw, ',') !== false || strpos($raw, "\t") !== false) {
return self::parseCsv($raw);
}
return [];
}
/**
* vCard 3.0 / 4.0。
*
* 三条要注意的:
* · **折行**:iCalendar/vCard 规范要求长行按"字节数"折行、续行以空格/制表开头
* —— 姓名与地址经常被折开,不接回去就会得到半个名字。
* · **转义**:文本值里的 `\,` `\;` `\n` 要还原(否则名字里会留下反斜杠)。
* · **多值**:TEL / EMAIL / ADR 都可能出现多次(手机 + 座机 + 工作邮箱),
* 全部收下来(第 1 个当主值)。
*/
public static function parseVcard(string $raw): array
{
$raw = str_replace(["\r\n", "\r"], "\n", $raw);
// ① 接回折行:以空格或制表开头的行是上一行的续行
$lines = [];
foreach (explode("\n", $raw) as $ln) {
if ($ln !== '' && ($ln[0] === ' ' || $ln[0] === "\t")) {
if ($lines) {
$lines[count($lines) - 1] .= substr($ln, 1);
}
continue;
}
$lines[] = $ln;
}
$out = [];
$cur = null;
foreach ($lines as $ln) {
$ln = trim($ln);
if ($ln === '') {
continue;
}
$up = strtoupper($ln);
if ($up === 'BEGIN:VCARD') {
$cur = ['name' => '', 'phone' => '', 'phones' => [], 'email' => '', 'emails' => [],
'org' => '', 'title' => '', 'address' => '', 'note' => '', 'birthday' => '',
'url' => '', 'nickname' => '', 'jobrank' => ''];
continue;
}
if ($up === 'END:VCARD') {
if ($cur !== null) {
$out[] = $cur;
}
$cur = null;
continue;
}
if ($cur === null) {
continue;
}
$pos = strpos($ln, ':');
if ($pos === false) {
continue;
}
$head = substr($ln, 0, $pos);
$val = substr($ln, $pos + 1);
// 属性名可能带参数:TEL;TYPE=CELL / EMAIL;TYPE=WORK / N;CHARSET=UTF-8
$name = strtoupper(trim(explode(';', $head)[0]));
$val = self::unescapeVcard($val);
switch ($name) {
case 'FN': // 格式化姓名(首选)
$cur['name'] = $val;
break;
case 'N': // 结构化姓名:姓;名;中间名;前缀;后缀 → 拼成"名 姓"更符合中文习惯
if ($cur['name'] === '') {
$parts = explode(';', $val);
$fam = trim((string)($parts[0] ?? ''));
$giv = trim((string)($parts[1] ?? ''));
$up2 = strtoupper(trim(explode(';', $head)[1] ?? ''));
// ⚠️ 中文 vCard 里 N 的姓/名顺序也是"姓;名",但显示要"姓名"连写,
// 不加空格 —— 加了会变成"杨 永全"。
$joined = $fam . $giv;
if ($joined !== '' && !preg_match('/[\x{4e00}-\x{9fa5}]/u', $joined)) {
$joined = trim($giv . ' ' . $fam); // 英文名习惯"名 姓"
}
$cur['name'] = $joined;
}
break;
case 'TEL':
if ($val !== '') {
$cur['phones'][] = $val;
if ($cur['phone'] === '') {
$cur['phone'] = $val;
}
}
break;
case 'EMAIL':
if ($val !== '') {
$cur['emails'][] = $val;
if ($cur['email'] === '') {
$cur['email'] = $val;
}
}
break;
case 'ORG':
if ($cur['org'] === '') {
// ORG 也可能是 "公司;部门" 结构
$cur['org'] = trim(str_replace(';', ' ', $val));
}
break;
case 'TITLE':
$cur['title'] = $val;
break;
case 'ROLE':
// ROLE 常放职务、TITLE 放职称(各家不同)—— 两个都收,空位才填
if ($cur['title'] === '') {
$cur['title'] = $val;
} else {
$cur['jobrank'] = $val;
}
break;
case 'ADR':
if ($cur['address'] === '') {
// ADR: 邮箱;扩展;街道;城市;省;邮编;国家 —— 只要街道+城市这段
$p = explode(';', $val);
$street = trim((string)($p[2] ?? ''));
$city = trim((string)($p[3] ?? ''));
$prov = trim((string)($p[4] ?? ''));
$cur['address'] = trim($prov . $city . $street);
}
break;
case 'NOTE':
if ($cur['note'] === '') {
$cur['note'] = $val;
}
break;
case 'BDAY':
$cur['birthday'] = self::normalizeBirthday($val);
break;
case 'NICKNAME':
$cur['nickname'] = $val;
break;
case 'URL':
if ($cur['url'] === '') {
$cur['url'] = $val;
}
break;
}
}
// 丢掉落不到"能建一条人"的(既没名字也没电话)—— 空卡片没有意义
return array_values(array_filter($out, static fn($r) => trim((string)$r['name']) !== ''
|| trim((string)$r['phone']) !== ''));
}
/**
* CSV / TSV。表头中英文都认(见 HEADER_MAP)。
*
* ⚠️ 分隔符靠**第一行的逗号与制表符数量**投票决定,不写死逗号:
* 从 Excel 另存为"文本文件"得到的是制表符分隔,写死逗号会把整行当成一列。
*/
public static function parseCsv(string $raw): array
{
$raw = str_replace(["\r\n", "\r"], "\n", $raw);
$raw = self::stripBom($raw);
$lines = array_values(array_filter(explode("\n", $raw), static fn($l) => trim($l) !== ''));
if (count($lines) < 2) {
return []; // 只有表头(或全空)→ 没有可导入的行
}
$head = $lines[0];
$delim = (substr_count($head, "\t") > substr_count($head, ',')) ? "\t" : ',';
$cols = array_map(static fn($s) => self::keyOf($s), str_getcsv($head, $delim, '"', '\\'));
$out = [];
for ($i = 1; $i < count($lines); $i++) {
$cells = str_getcsv($lines[$i], $delim, '"', '\\');
$rec = ['name' => '', 'phone' => '', 'phones' => [], 'email' => '', 'emails' => [],
'org' => '', 'title' => '', 'address' => '', 'note' => '', 'birthday' => '',
'url' => '', 'nickname' => '', 'jobrank' => ''];
$filled = false;
foreach ($cols as $idx => $key) {
if ($key === '') {
continue;
}
$v = trim((string)($cells[$idx] ?? ''));
if ($v === '') {
continue;
}
// 一个表头可能对应"多个电话"("手机;电话"这种合并列)
foreach (preg_split('/[;、|]/u', $v) as $piece) {
$piece = trim($piece);
if ($piece === '') {
continue;
}
if ($key === 'phone') {
$rec['phones'][] = $piece;
if ($rec['phone'] === '') {
$rec['phone'] = $piece;
}
} elseif ($key === 'email') {
$rec['emails'][] = $piece;
if ($rec['email'] === '') {
$rec['email'] = $piece;
}
} elseif ($key === 'birthday') {
$rec['birthday'] = self::normalizeBirthday($piece);
} elseif ($key === 'name' && $rec['name'] === '') {
$rec['name'] = $piece;
} elseif (isset($rec[$key]) && $rec[$key] === '') {
$rec[$key] = $piece;
}
}
$filled = true;
}
if ($filled && (trim((string)$rec['name']) !== '' || trim((string)$rec['phone']) !== '')) {
$out[] = $rec;
}
}
return $out;
}
/** 表头归一化:小写、去空格/下划线/全角括号注释 */
private static function keyOf(string $h): string
{
$h = self::stripBom(trim($h));
$h = mb_strtolower($h, 'UTF-8');
$h = preg_replace('/[\s_\-\/]+/u', '', $h) ?? $h;
// "手机号(必填)" 这类 → 只留"手机号"
$h = preg_replace('/[((].*?[))]/u', '', $h) ?? $h;
return self::HEADER_MAP[$h] ?? '';
}
/**
* 生日归一化:只认能**确定**的格式(YYYY-MM-DD / YYYYMMDD / YYYY/MM/DD)。
* ⚠️ 认不出就返回 ''(不导入)—— 生日猜错比不导更糟("1990-03-07" 猜成 "03-07" 会丢年份)。
*/
public static function normalizeBirthday(string $v): string
{
$v = trim($v);
if ($v === '') {
return '';
}
// vCard 的 BDAY 可能带 --MM-DD(只知道月日)或 X.500 时间戳;只认四位年
if (preg_match('/(\d{4})[-\/.]?(\d{1,2})[-\/.]?(\d{1,2})/', $v, $m)) {
$y = (int)$m[1];
$mo = (int)$m[2];
$d = (int)$m[3];
if ($y >= 1900 && $y <= (int)date('Y') && checkdate($mo, $d, $y)) {
return sprintf('%04d-%02d-%02d', $y, $mo, $d);
}
}
return '';
}
/** vCard 文本转义还原:\, \; \n \\ */
private static function unescapeVcard(string $v): string
{
$v = str_replace(['\\n', '\\N'], "\n", $v);
$v = str_replace(['\\,', '\\;', '\\\\'], [',', ';', '\\'], $v);
return trim($v);
}
/** 统一成 UTF-8(手机导出的 vcf 可能是 UTF-16 或 GBK) */
public static function toUtf8(string $raw): string
{
if ($raw === '') {
return '';
}
// UTF-16 BOM
if (strncmp($raw, "\xFF\xFE", 2) === 0) {
$raw = (string)mb_convert_encoding(substr($raw, 2), 'UTF-8', 'UTF-16LE');
} elseif (strncmp($raw, "\xFE\xFF", 2) === 0) {
$raw = (string)mb_convert_encoding(substr($raw, 2), 'UTF-8', 'UTF-16BE');
} elseif (strncmp($raw, "\xEF\xBB\xBF", 3) === 0) {
$raw = substr($raw, 3);
} elseif (!mb_check_encoding($raw, 'UTF-8')) {
// 不是合法 UTF-8 → 多半是 GBK(国内手机导出的常见情况)
$raw = (string)mb_convert_encoding($raw, 'UTF-8', 'GBK');
}
return $raw;
}
private static function stripBom(string $s): string
{
return strncmp($s, "\xEF\xBB\xBF", 3) === 0 ? substr($s, 3) : $s;
}
}