用户 2026-10-16 傍晚五条。 ① 首页「近期任务」标题太长会挤成两行 用户原话:「首页**近期任务**那个地方要注意截断:如果任务标题太长, 会把这个任务**挤成两行**。」 🔴🔴 **真根因是两层,第一版只修了表面**: · 表面:`.dash-what` 只有 `flex:1 1 auto; min-width:0`,**缺** nowrap/hidden/ellipsis ⇒ 收缩后文字在框内换行; · 真身:`.home-duo` 是**两列 grid**,grid 子项默认 `min-width: auto` ("我最小也得装下我的内容")⇒ 一条长标题把那一列顶到 **1763px** (父 `home-duo` 才 1032px)⇒ **整个两列布局被撑破**、页面横向溢出到 **2089px** (视口 1440)。⇒ 行宽 1721px、`scrollWidth === clientWidth`, **`text-overflow: ellipsis` 看起来"没生效"** —— 其实容器自己先爆了。 修法:`.home-duo > * { min-width: 0; }`,且 **整条 flex 链**都补 (`.hd-pane` → `.hd-pane-head` → `.hd-pane-body` → `.dash-list/.tk-list` → `.tk-row`) —— 链上任何一层漏了,收缩就断在那一层、下面的 nowrap 全白写。 配套:任务标题加 `title=` 悬停看全。 ⚠️ 这是本项目**第二次**踩 flex/grid 的 `min-width:auto`(v53 的 `.mv-grid` 是同类)。 **以后"文字截断不生效"先量容器有没有被撑破**。 ② 名册「分组」→「标签」;打标签的候选要**从设置里读** 用户原话:「现在名册里面给人打标签的时候,**这个标签数据是从哪读出来的**呀? 在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候, **却不是从这些分组里读出来的**。……应该叫「名册标签」,是不是? **应该从这里面把所有标签都读出来**吧?……以后就没有分组的概念了, **全部都是标签的概念**。」 🔴 **用户观察得完全正确,这是两套数据源**: · 设置页显示的词表来自 `person_groups`(**分组表**); · 打标签的候选来自 `PersonTag::cloud()`(**你已经打过的词**,现算的); ⇒ 两边根本不是一个数据源 ⇒ "设置里列着一堆、打标签时却只有几个"。 修法:**`PersonTag::choices()` = 设置页词表 ∪ 我用过的词**(并集,词表在前)。 · 词表里的词**还没用过**也能一点就打上(这正是词表的意义); · 用过的词补在后面(历史不丢); · 名册列表 / 新增 / 编辑 / 挑人 / 导入 **五处同一个函数**。 界面口径全面改"标签"(**表名仍是 `person_groups`**:改名要背着 `PersonGroup` 类、 `person_roster.group_id`、以及**另一个会话正在写的 `app/ApiHandler.php`** (它引用了 `PersonGroup::orderFor`)一起动,风险远大于收益): · `_person_groups.php` 标题「名册标签」、按钮「加一个我自己的标签」; · 删除文案改成"只是从候选里去掉这个词,已打在别人身上的不受影响" (**语义真的变了**:标签是各自身上的字符串,不是引用); · `people/_settings_menu.php`、`people/settings.php`、`settings/me.php`、 `settings.php`(「公共分组」→「公共标签」)全部同步。 顺带按"全部都是标签"改另两条入口: · `people/pick.php`「加进哪一组」下拉 → **打标签**; · `people/import.php` 同上;`ContactImporter::run()` 加 `$tags` 参数。 🔴 新增 `PersonTag::addOne()`(**追加**一个标签)—— 这两处**绝不能用 `setFor()`** (它是"先 DELETE 再 INSERT"的整组替换)⇒ 会把人**原有**的标签清光 (症状是"用一次导入,别人原有的标签全没了",极难联想)。 ⚠️ `group_id` 在 pick/import 的**服务端仍收着**(老书签/脚本兼容),只是界面不再展示。 ③ 旅程模块加**地图视图**(读 TripLog 移植) 用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,**可以在地图上显示旅程数据**。 1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」 读 TripLog(`templates/activities/index.php`)得到的要点: · 用**高德 AMap**(合规白名单内,绝不是境外瓦片); · `position` 必须是 **[lng,lat] 数组**(传 `{lng,lat}` 裸对象会让 AMap 抛错、 整个绘制循环当场中断 —— 它注释里写着"批 11 的光秃秃地图就是它"); · `setFitView()` 后**上限 11 级**(只有一趟时会被放大到街道级,那是导航视角); · `.amap-marker` 整层 `pointer-events:none`(否则包装层挡住别家卡片的点击); · **容器必须有固定高度**(0 高度 → 空白地图)。 它那套"同城环形扇开"是**几百行几何**,本仓数据量没压出这个问题 ⇒ **有意不做**。 本仓数据更规整:`trip_nodes` 早有 `from_lng/from_lat/to_lng/to_lat`(v17 批) + `from_city/to_city` ⇒ 新增 `Trip::attachSpots()`: ① 有坐标取**重心**(不是端点 —— 端点会让"青岛→伊春"偏到某一头); ② 没坐标退到**城市重心**(`Geo::lookup` 离线城市表,**不发网络请求**: 列表页几十趟若逐条打高德逆地理,一次开页就是几十个外部请求); ③ 都没有就不上图(列表里照常有,模板会说清"有几趟没上图")。 🔴 城市那里**必须也取重心**:`cities()` 的顺序是**按时间**(出发地在最前), 取"第一个城市"会让**所有旅程都叠在家上**、地图毫无信息量。 布局:两个视图用**标签页**(`?view=map` 是**链接**不是纯 JS 按钮 —— 书签/前进后退/无 JS 都正确;也**有意不**把地图藏在 `display:none` 里, 高德在 0 尺寸容器里会渲染空白)。 高德 SDK 加载器重构:原来 `fbLoadAddr()` 顺带拉 `addr_suggest.js`, 而地图只用 SDK 自带的 `AMap.Map/Marker` ⇒ 抽出 `window.fbAmapSdk()`(只拉 SDK)、 新增 `fb-amap` meta(不带 plugin),`fb-addr` 那份共用同一加载器。 ④ 旅程列表页「新建旅程」**后面**加「设置」按钮 用户原话:「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。」 ⇒ 补 `_settings_menu.php`(设置恒排最右),菜单**第一条**加「显示方式」。 ⑤ 显示方式:默认列表还是地图(用户自配) 用户原话:「3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」 ⇒ 新增 `trips/display`(设置页 + `?set=` 快速设置)与 `trips/display_save`, 存 **`user_settings.trip_default_view`**(每人一份,**不是**全家设置)。 ⚠️ 访客也能改(只影响自己看到的默认,不是数据变更)。 验证(新增 2 套件 + 更新 e2e/deploy 断言): · _fb_v54_ui.py(**33 条**真机断言) · _b54_falsify.py(**11 注入 / 11 全抓**,基线 33/0) · e2e **1518/0**;familybook_deploy.sh 加「第54批形态」段(**24 条**) · 部署 **0 FAIL**;线上 **20 个文件 md5 逐字节一致**; 线上实测:词表 24 条、`choices()` 24 条、**`cloud` 15 条全部 ⊆ `choices`**、 4 趟旅程全部可上图(样例落在济南·青岛重心);诊断页已删复验 404 ⚠️ 本轮踩坑(已写进 pitfalls.md): · 🔴🔴 **"文字截断不生效"要先量容器有没有被撑破**(第一嫌疑是 flex/grid 子项 `min-width:auto`,不是 `text-overflow` 写错); · **反向断言("不该再出现 X")必须用 `_code_has`(剥注释)** —— 需求原话正当留在注释里,裸 `grep -F` 会命中自己的说明(本轮部署假红一次); · **冗余防线会让反向验证打不红**(两处 `min-width:0` 只去掉一条 ⇒ 探针照旧全绿, 注入必须一起去掉); · **界面多加一个字符就可能打歪既有断言**(我在词表管理页给标签名加了 `#`, 把 e2e 的 `my_group_order()` 读成 `#家人`)。
371 lines
19 KiB
PHP
371 lines
19 KiB
PHP
<?php
|
||
/**
|
||
* 把解析出来的联系人**落到名册里**(v24 用户 2026-10-03 第 1 条)。
|
||
*
|
||
* 与 App\ContactImport 的分工:
|
||
* · ContactImport —— 只管"文件 → 记录数组"(纯解析,不碰库);
|
||
* · 本类 —— 只管"记录数组 → 库里"(判重、补缺、建号、挂名册)。
|
||
* 分开的好处:解析器可以单独测(给一段 vCard 看输出),不需要库。
|
||
*
|
||
* 用户要求的两条规则(原话):
|
||
* 「导入时,如果名字或手机号在已有的名册中存在,则导入其他信息,
|
||
* 如果这些信息在系统中已经存在,则只导入其他信息。」
|
||
* 落成三条:
|
||
* ① **判重** 按 手机号(哈希,精确)→ 姓名(大小写无关,精确)依次找;
|
||
* ② 命中已有的人 → **只补空字段**,绝不覆盖(批量覆盖没法回退,见 ContactImport 的说明);
|
||
* ③ 没命中 → 新建一条,并加进导入者的名册。
|
||
*
|
||
* ⚠️ 判重范围是**整个家庭**(不只是我的名册):同一个家里"洪勇"只能有一条记录 ——
|
||
* 否则导入一次会造出一个重复的人,之后生日、关系、观影记录全都分成两半。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
final class ContactImporter
|
||
{
|
||
/**
|
||
* @return array{added:int,updated:int,skipped:int,people:array<int,array{name:string,action:string,added:array}>}
|
||
*/
|
||
public static function run(int $familyId, int $meId, array $records, int $groupId = 0, array $tags = []): array
|
||
{
|
||
$added = 0;
|
||
$updated = 0;
|
||
$skipped = 0;
|
||
$log = [];
|
||
|
||
/* 🔴 v54(用户 2026-10-16 第 2 条):导入时可以**顺手打一串标签**。
|
||
用户原话:「现在已经没有分组的概念了,**全部都是标签的概念**。」
|
||
⚠️ 用 `PersonTag::addOne()`(**追加**)而不是 `setFor()`(整组替换)——
|
||
命中已有的人时,`setFor` 会把他身上**原有**的标签一起清掉
|
||
("用一次导入,别人原有的标签全没了")。语义上这里就是"多加一个"。
|
||
⚠️ 老参数 `$groupId` 保留:不为别的,只为**不改既有调用契约**;
|
||
新界面不再传它(`templates/people/import.php` 已换成标签输入)。 */
|
||
$tags = PersonTag::clean($tags);
|
||
|
||
// 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判)
|
||
// 索引键:phone_hash(精确)+ 姓名(小写)
|
||
$byPhone = [];
|
||
$byName = [];
|
||
foreach (Person::all($familyId, '', 'member', $meId) as $p) {
|
||
if (!empty($p['phone_hash'])) {
|
||
$byPhone[(string)$p['phone_hash']] = $p;
|
||
}
|
||
$n = mb_strtolower(trim((string)$p['name']), 'UTF-8');
|
||
if ($n !== '' && !isset($byName[$n])) {
|
||
$byName[$n] = $p;
|
||
}
|
||
}
|
||
|
||
foreach ($records as $r) {
|
||
$name = trim((string)($r['name'] ?? ''));
|
||
$phone = trim((string)($r['phone'] ?? ''));
|
||
if ($name === '' && $phone === '') {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
// ---- ① 判重:手机号优先(最精确),其次姓名 ----
|
||
$hit = null;
|
||
if ($phone !== '') {
|
||
$h = Crypto::hash($phone);
|
||
if (isset($byPhone[$h])) {
|
||
$hit = $byPhone[$h];
|
||
}
|
||
}
|
||
if ($hit === null && $name !== '') {
|
||
$k = mb_strtolower($name, 'UTF-8');
|
||
if (isset($byName[$k])) {
|
||
$hit = $byName[$k];
|
||
}
|
||
}
|
||
|
||
if ($hit !== null) {
|
||
// ---- ② 命中:只补空字段 ----
|
||
$filled = self::fillMissing($hit, $r, $meId);
|
||
/* 🔴🔴 v52(用户 2026-10-15 第 5 条):「而用户导入数据时,**也是一样的**,
|
||
如果名册中有这个人,就不用再新增了,而是根据名字、手机号等信息
|
||
**自动匹配后添加到这个用户的名册中**。」
|
||
⇒ 命中已有的人时,**必须**把他也挂进**导入者的名册** ——
|
||
否则会出现最费解的一种结果:导入文件里有这个人、系统里也有这个人,
|
||
但导入完**我的名册里还是看不到他**(他只在别人名册里),
|
||
用户只会觉得"导入漏了人"。
|
||
⚠️ 这一段原先只在"没命中 → 新建"那条路上挂名册(见下面 ③ 的 ensure),
|
||
命中那条路**没有** —— 正是本批要补的缺口。
|
||
⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */
|
||
PersonRoster::ensure($familyId, $meId, (int)$hit['id'],
|
||
$groupId > 0 ? $groupId : null, 'self');
|
||
// 🔴 v54:命中已有的人时**追加**标签(不动他原有的 —— 见方法头的说明)
|
||
foreach ($tags as $tg) {
|
||
PersonTag::addOne($familyId, $meId, (int)$hit['id'], $tg);
|
||
}
|
||
if ($filled) {
|
||
$updated++;
|
||
$log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled];
|
||
} else {
|
||
$skipped++;
|
||
$log[] = ['name' => (string)$hit['name'], 'action' => 'same', 'added' => []];
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// ---- ③ 没命中:新建 ----
|
||
$d = array_merge([
|
||
'name' => $name !== '' ? $name : $phone,
|
||
'phone' => $phone,
|
||
'email' => (string)($r['email'] ?? ''),
|
||
'org' => (string)($r['org'] ?? ''),
|
||
// 职务 + 职称合成一列(与 PersonJob::joinTitle 同一口径:
|
||
// 两个都有时用 " · " 分开,只有一个就只用那个)
|
||
'title' => PersonJob::joinTitle(
|
||
(string)($r['title'] ?? ''),
|
||
(string)($r['jobrank'] ?? '')
|
||
) ?? '',
|
||
'note' => (string)($r['note'] ?? ''),
|
||
'kind' => 'member',
|
||
'avatar' => '',
|
||
'birth_rank' => '',
|
||
'is_placeholder' => 0,
|
||
// 生日:解析器只给"能确定"的日期,给了就当阳历
|
||
'birthday_source' => 'solar',
|
||
'birthday_solar' => (string)($r['birthday'] ?? ''),
|
||
'birthday_lunar' => '',
|
||
'birthday_lunar_leap' => 0,
|
||
'birthday_id_card' => '',
|
||
], self::addrFields($r));
|
||
try {
|
||
$pid = Person::create($familyId, $d);
|
||
} catch (\Throwable $ex) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
// 补充联系方式(除主手机号之外的那些)
|
||
$extra = [];
|
||
$extraPhones = array_values(array_filter((array)($r['phones'] ?? []),
|
||
static fn($v) => trim((string)$v) !== '' && (string)$v !== $phone));
|
||
foreach ($extraPhones as $i => $v) {
|
||
$extra['phone'][] = ['label' => $i === 0 ? '其他' : '', 'value' => (string)$v];
|
||
}
|
||
$extraEmails = array_values(array_filter((array)($r['emails'] ?? []),
|
||
static fn($v) => trim((string)$v) !== '' && (string)$v !== (string)($r['email'] ?? '')));
|
||
foreach ($extraEmails as $i => $v) {
|
||
$extra['email'][] = ['label' => $i === 0 ? '其他' : '', 'value' => (string)$v];
|
||
}
|
||
if (($r['url'] ?? '') !== '') {
|
||
$extra['url'][] = ['label' => '', 'value' => (string)$r['url']];
|
||
}
|
||
if ($extra) {
|
||
Person::replaceContacts($pid, $extra);
|
||
}
|
||
// 工作经历(第 4 条那张表):导入来的单位/职务就是一段经历。
|
||
// ⚠️ 必须**同时**建这一段,否则详情页的"工作经历"永远是空的,
|
||
// 而 persons.org 里却有值 —— 两处对不上,用户会以为数据坏了。
|
||
if (trim((string)($r['org'] ?? '')) !== '' || trim((string)($r['title'] ?? '')) !== ''
|
||
|| trim((string)($r['jobrank'] ?? '')) !== '') {
|
||
PersonJob::replace($pid, [[
|
||
'org' => (string)($r['org'] ?? ''), 'title' => (string)($r['title'] ?? ''),
|
||
'rank_name' => (string)($r['jobrank'] ?? ''),
|
||
'start_ym' => '', 'end_ym' => '', 'note' => '',
|
||
]]);
|
||
}
|
||
|
||
// 进导入者的名册(谁导入的,就先出现在谁的名册里)
|
||
PersonRoster::ensure($familyId, $meId, $pid, $groupId > 0 ? $groupId : null, 'self');
|
||
// 🔴 v54:新建的人也打上这串标签
|
||
foreach ($tags as $tg) {
|
||
PersonTag::addOne($familyId, $meId, $pid, $tg);
|
||
}
|
||
|
||
// 建完把索引补上,避免同一个文件里出现两条同名时建出两个人
|
||
$byName[mb_strtolower($d['name'], 'UTF-8')] = ['id' => $pid, 'name' => $d['name']];
|
||
if ($phone !== '') {
|
||
$byPhone[Crypto::hash($phone)] = ['id' => $pid, 'name' => $d['name']];
|
||
}
|
||
$added++;
|
||
$log[] = ['name' => (string)$d['name'], 'action' => 'added', 'added' => ['新建']];
|
||
}
|
||
|
||
return ['added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'people' => $log];
|
||
}
|
||
|
||
/**
|
||
* 把记录里**他没有的值**补上去;已有非空值一律不动。
|
||
*
|
||
* ⚠️ 写库只分两处:
|
||
* ① `persons` 上的标量列(邮箱/住址/单位/职务/生日)→ 一条 UPDATE;
|
||
* ② 多值(电话/邮箱/网址)→ 走 person_contacts 的整组替换(要把现有的并进去)。
|
||
* 第一版把"SQL 片段"与"中文标签"交替塞进同一个数组,读起来极易出错;
|
||
* 这里改成两个独立容器,各司其职。
|
||
*
|
||
* @return string[] 这次补上了哪些(中文标签,用来告诉用户"改了什么")
|
||
*/
|
||
/**
|
||
* 把一条导入记录里的地址整理成 persons 的五个字段。
|
||
*
|
||
* 分两种来源:
|
||
* · vCard 的 ADR **本来就是分段的**(街道/城市/省/国家各占一段)→ 直接用,
|
||
* 再用 Region::parse() 从街道那一段里认出「区 / 县」;
|
||
* · CSV 那种一整串自由文本 → 整串交给 Region::parse() 逐级前缀匹配拆开。
|
||
*
|
||
* ⚠️ 认不出来的部分**一律留在 address(详细地址)里**,绝不丢:
|
||
* 结构化是为了"以后能按省找人",不是为了让地址变短。
|
||
*
|
||
* @return array{address:string,addr_country:string,addr_province:string,addr_city:string,addr_district:string}
|
||
*/
|
||
private static function addrFields(array $r): array
|
||
{
|
||
$country = trim((string)($r['addr_country'] ?? ''));
|
||
$province = trim((string)($r['addr_province'] ?? ''));
|
||
$city = trim((string)($r['addr_city'] ?? ''));
|
||
$district = trim((string)($r['addr_district'] ?? ''));
|
||
$detail = trim((string)($r['address'] ?? ''));
|
||
|
||
if ($province === '' && $city === '' && $detail !== '') {
|
||
// 一整串自由文本 → 逐级前缀匹配
|
||
$p = Region::parse($detail);
|
||
$country = $country !== '' ? $country : (string)($p['country'] ?? '');
|
||
$province = (string)($p['province'] ?? '');
|
||
$city = (string)($p['city'] ?? '');
|
||
$district = (string)($p['district'] ?? '');
|
||
$detail = (string)($p['detail'] ?? '');
|
||
} elseif ($detail !== '' && $district === '') {
|
||
// 已经有省/市(vCard 给的),从剩下的街道里再认一次区县
|
||
$p = Region::parse($detail);
|
||
if (($p['district'] ?? null) !== null) {
|
||
$district = (string)$p['district'];
|
||
$detail = (string)($p['detail'] ?? '');
|
||
}
|
||
}
|
||
|
||
return [
|
||
'address' => $detail,
|
||
'addr_country' => $country,
|
||
'addr_province' => $province,
|
||
'addr_city' => $city,
|
||
'addr_district' => $district,
|
||
];
|
||
}
|
||
|
||
private static function fillMissing(array $person, array $r, int $meId): array
|
||
{
|
||
$pid = (int)$person['id'];
|
||
$cols = []; // "`email` = ?" => 值
|
||
$labels = []; // 给人看的"补了什么"
|
||
|
||
// ---- ① persons 上的标量列:他空着才补 ----
|
||
$addr = self::addrFields($r);
|
||
$scalar = [
|
||
'email' => ['邮箱', (string)($r['email'] ?? ''), 200],
|
||
// ⚠️ 地址要走 addrFields() 拆过的那份:直接拿 $r['address'] 会把
|
||
// vCard 里"省+市+街道"拼成的一整串原样塞进详细地址,等于没结构化。
|
||
'address' => ['住址', $addr['address'], 200],
|
||
'org' => ['工作单位', (string)($r['org'] ?? ''), 120],
|
||
'note' => ['备注', (string)($r['note'] ?? ''), 2000],
|
||
];
|
||
foreach ($scalar as $col => $def) {
|
||
$label = $def[0];
|
||
$new = trim($def[1]);
|
||
$max = (int)$def[2];
|
||
if (trim((string)($person[$col] ?? '')) === '' && $new !== '') {
|
||
$cols["`$col` = ?"] = mb_substr($new, 0, $max, 'UTF-8');
|
||
$labels[] = $label;
|
||
}
|
||
}
|
||
// 职务与职称在库里是同一列 title(他那一列空着才补)
|
||
$newTitle = trim((string)($r['title'] ?? '') . ' ' . (string)($r['jobrank'] ?? ''));
|
||
if (trim((string)($person['title'] ?? '')) === '' && $newTitle !== '') {
|
||
$cols['`title` = ?'] = mb_substr($newTitle, 0, 120, 'UTF-8');
|
||
$labels[] = '职务/职称';
|
||
}
|
||
// 地址的四级结构:同样**只补空着的**(他已经在表单里选过省市区就不动)
|
||
foreach (['addr_country' => '国家', 'addr_province' => '省',
|
||
'addr_city' => '市', 'addr_district' => '区/县'] as $col => $label) {
|
||
if (trim((string)($person[$col] ?? '')) === '' && trim($addr[$col]) !== '') {
|
||
$cols["`$col` = ?"] = mb_substr(trim($addr[$col]), 0, 60, 'UTF-8');
|
||
$labels[] = $label;
|
||
}
|
||
}
|
||
// 生日:他一个生日都没有时才补。有生日就绝不动 —— 生日是强身份信息,错了很难发现。
|
||
$hasBd = trim((string)Birthday::solarOf($person)) !== ''
|
||
|| trim((string)($person['birthday_lunar'] ?? '')) !== '';
|
||
if (!$hasBd && trim((string)($r['birthday'] ?? '')) !== '') {
|
||
$cols['`birthday` = ?'] = (string)$r['birthday'] . ' 00:00:00';
|
||
$labels[] = '生日';
|
||
}
|
||
if ($cols) {
|
||
$sql = [];
|
||
$args = [];
|
||
foreach ($cols as $frag => $val) {
|
||
$sql[] = $frag;
|
||
$args[] = $val;
|
||
}
|
||
$args[] = $pid;
|
||
Database::get()->prepare('UPDATE persons SET ' . implode(', ', $sql) . ' WHERE id = ?')
|
||
->execute($args);
|
||
}
|
||
|
||
// ---- ② 多值(电话/邮箱/网址):只在"这一类他一个都没有"时补 ----
|
||
// ⚠️ 判据是"这一类为空",不是逐条去重:person_contacts 里的手机号是**密文**,
|
||
// 没法拿明文比;而"他这一类本来就空着"是能确定的(也符合"只补空"的要求)。
|
||
$cur = Person::contactsOf($pid);
|
||
$add = [];
|
||
$mainPhone = trim((string)($r['phone'] ?? ''));
|
||
$mainMail = trim((string)($r['email'] ?? ''));
|
||
$extraPhones = array_values(array_filter((array)($r['phones'] ?? []),
|
||
static fn($v) => trim((string)$v) !== '' && trim((string)$v) !== $mainPhone));
|
||
$extraEmails = array_values(array_filter((array)($r['emails'] ?? []),
|
||
static fn($v) => trim((string)$v) !== '' && trim((string)$v) !== $mainMail));
|
||
if (!$cur['phone'] && $extraPhones) {
|
||
foreach ($extraPhones as $v) {
|
||
$add['phone'][] = ['label' => '', 'value' => (string)$v];
|
||
}
|
||
$labels[] = '电话';
|
||
}
|
||
if (!$cur['email'] && $extraEmails) {
|
||
foreach ($extraEmails as $v) {
|
||
$add['email'][] = ['label' => '', 'value' => (string)$v];
|
||
}
|
||
$labels[] = '邮箱';
|
||
}
|
||
if (!$cur['url'] && trim((string)($r['url'] ?? '')) !== '') {
|
||
$add['url'][] = ['label' => '', 'value' => (string)$r['url']];
|
||
$labels[] = '网址';
|
||
}
|
||
if ($add) {
|
||
// replaceContacts 是**整组替换** → 必须把现有的并进去,否则会把已有联系方式清掉
|
||
$merged = $cur;
|
||
foreach ($add as $k => $rows) {
|
||
foreach ($rows as $row) {
|
||
$merged[$k][] = $row;
|
||
}
|
||
}
|
||
Person::replaceContacts($pid, $merged);
|
||
}
|
||
|
||
// ---- ③ "工作经历"里空着、而这次**刚补上**了单位/职务 → 补一段经历 ----
|
||
// ⚠️⚠️ 两个硬条件,缺一不可:
|
||
// ① 这次确实补了(`$cols` 里刚写过 org 或 title)—— 他已经有的值不能碰;
|
||
// ② 经历表里本来就没有 —— 有经历时以经历表为准,不重复插。
|
||
// 为什么条件①这么关键:`PersonJob::replace()` 末尾会调
|
||
// `syncCurrent()` 把"最新那段"回写到 persons.org/title。若在这里对
|
||
// "已有单位的人"也插一段导入来的单位,syncCurrent 就会把
|
||
// **他原来的单位覆盖掉** —— 正好违反"只补空、不覆盖"。
|
||
// (这个 bug 是被 _fb_import_test.py 的 ⑩ 抓出来的。)
|
||
$justFilledOrg = isset($cols['`org` = ?']);
|
||
$justFilledTitle = isset($cols['`title` = ?']);
|
||
if (($justFilledOrg || $justFilledTitle) && !PersonJob::list($pid)) {
|
||
PersonJob::replace($pid, [[
|
||
// 用**刚存进去的值**(不是导入文件里的原值)——
|
||
// 这样 syncCurrent 回写时与库里一致,不会造成二次变动。
|
||
'org' => (string)($cols['`org` = ?'] ?? ''),
|
||
'title' => (string)($r['title'] ?? ''),
|
||
'rank_name' => (string)($r['jobrank'] ?? ''),
|
||
'start_ym' => '', 'end_ym' => '', 'note' => '',
|
||
]]);
|
||
}
|
||
|
||
return array_values(array_unique($labels));
|
||
}
|
||
}
|