Files
FamilyBook/app/ContactImport.php
T
laoyang 52cc1ecccb v24 第 9 批:名册九条(分组按钮/加入名册/生日月日/地址四级/千人性能/拖动修复/删除人员/返回名册/Semi DatePicker)
- #131 名册卡片加「分组」按钮(details+radio,显示当前分组,选中即改)
- #132 修复「从已有人里加入名册」无反应
- #133 生日只显示月和日(阳历 MM-DD / 农历中文名),PersonQuery::birthdaysOf 取代 birthdayOf
- #134 地址结构化为 国家/省/市/区+详细:regions 表(3429 行全国区划)+ Region.php +
  ?r=regions 级联接口 + 表单 .addr-grid + 老地址回填(Schema v16)
- #135 千人名册性能:.person content-visibility:auto + 名册页 ob_gzhandler
  (实测 服务端 1.15s/3.8MB → 0.17s/87KB;客户端导航→load 1.35s → 0.37s)
- #136 拖动排序改 Pointer Events(Chrome 下 HTML5 DnD 在 DOM 变动时断)
- #137 个人详情页底部「删除人员」
- #138 编辑资料页加「返回名册」
- #139 所有日期选择换 Semi Design DatePicker(渐进增强:原生四字段保 POST 契约,
  懒加载 vendor semi UMD,值回写原字段;工作经历 type=month 一并升级;宣纸主题对齐)

验证:e2e 785/0、batch9 37/0、upgrade 126/0、picker 28/0、mobile 688/0、interact 97/0、
batch_e 91/0、import 21/0、douban 22/0、drag 5/0、semi-integrate 28/0、semi-probe 17/0、
geom 5/5;部署后线上自检 16/16(含 4 个 semi 资源 200 + regions 路由)。
2026-10-04 08:56:40 +08:00

360 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
/**
* 联系人导入(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' => '',
'addr_country' => '', 'addr_province' => '', 'addr_city' => '', 'addr_district' => '',
'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'] === '' && $cur['addr_province'] === '') {
/* vCard 的 ADR **本身就是分段的**(RFC 6350):
PO盒;扩展;街道;城市;省/州;邮编;国家
所以这里不再拼成一整串 —— 直接拆成国家/省/市/街道四段存下来,
再由 ContactImporter 拿它们去对标准行政区划名。
⚠️ 区县(第 8 段)几乎没人填,剩下那段街道再交给 Region::parse()
去认区 —— 它本来就是干这个的。 */
$p = explode(';', $val);
$street = trim((string)($p[2] ?? ''));
$city = trim((string)($p[3] ?? ''));
$prov = trim((string)($p[4] ?? ''));
$ctry = trim((string)($p[6] ?? ''));
$cur['addr_country'] = $ctry;
$cur['addr_province'] = $prov;
$cur['addr_city'] = $city;
$cur['address'] = $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' => '',
'addr_country' => '', 'addr_province' => '', 'addr_city' => '', 'addr_district' => '',
'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;
}
}