diff --git a/app/ContactImport.php b/app/ContactImport.php new file mode 100644 index 0000000..1356065 --- /dev/null +++ b/app/ContactImport.php @@ -0,0 +1,349 @@ + '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 每条: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; + } +} \ No newline at end of file diff --git a/app/ContactImporter.php b/app/ContactImporter.php new file mode 100644 index 0000000..ce1069f --- /dev/null +++ b/app/ContactImporter.php @@ -0,0 +1,284 @@ +} + */ + public static function run(int $familyId, int $meId, array $records, int $groupId = 0): array + { + $added = 0; + $updated = 0; + $skipped = 0; + $log = []; + + // 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判) + // 索引键: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); + if ($filled) { + $updated++; + $log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled]; + } else { + $skipped++; + $log[] = ['name' => (string)$hit['name'], 'action' => 'same', 'added' => []]; + } + continue; + } + + // ---- ③ 没命中:新建 ---- + $d = [ + 'name' => $name !== '' ? $name : $phone, + 'phone' => $phone, + 'email' => (string)($r['email'] ?? ''), + 'address' => (string)($r['address'] ?? ''), + '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' => '', + ]; + 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'); + + // 建完把索引补上,避免同一个文件里出现两条同名时建出两个人 + $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[] 这次补上了哪些(中文标签,用来告诉用户"改了什么") + */ + private static function fillMissing(array $person, array $r, int $meId): array + { + $pid = (int)$person['id']; + $cols = []; // "`email` = ?" => 值 + $labels = []; // 给人看的"补了什么" + + // ---- ① persons 上的标量列:他空着才补 ---- + $scalar = [ + 'email' => ['邮箱', (string)($r['email'] ?? ''), 200], + 'address' => ['住址', (string)($r['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[] = '职务/职称'; + } + // 生日:他一个生日都没有时才补。有生日就绝不动 —— 生日是强身份信息,错了很难发现。 + $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)); + } +} diff --git a/app/PersonJob.php b/app/PersonJob.php new file mode 100644 index 0000000..d502683 --- /dev/null +++ b/app/PersonJob.php @@ -0,0 +1,160 @@ +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; + } +} \ No newline at end of file diff --git a/app/PersonNote.php b/app/PersonNote.php new file mode 100644 index 0000000..e70f62e --- /dev/null +++ b/app/PersonNote.php @@ -0,0 +1,82 @@ +prepare( + 'SELECT note FROM person_notes WHERE person_id = ? AND viewer_id = ?' + ); + $st->execute([$personId, $viewerId]); + $v = $st->fetchColumn(); + return $v === false ? '' : (string)$v; + } + + /** + * 写入/清空。**空串等于删除这条备注** —— 不留一行空记录, + * 否则"我写过又删掉"会留下一条 forever 的空行,日后分不清"没写过"与"删掉了"。 + */ + public static function set(int $personId, int $viewerId, string $note): void + { + if ($personId <= 0 || $viewerId <= 0) { + return; + } + $note = trim($note); + if ($note === '') { + Database::get()->prepare( + 'DELETE FROM person_notes WHERE person_id = ? AND viewer_id = ?' + )->execute([$personId, $viewerId]); + return; + } + Database::get()->prepare( + 'INSERT INTO person_notes (person_id, viewer_id, note) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE note = VALUES(note)' + )->execute([$personId, $viewerId, mb_substr($note, 0, 4000, 'UTF-8')]); + } + + /** + * 一批人的私人备注(列表页要一次取完,别逐行查 —— N+1 在名册几十人时很明显)。 + * @return array person_id => note + */ + public static function mapFor(int $viewerId): array + { + if ($viewerId <= 0) { + return []; + } + $st = Database::get()->prepare( + 'SELECT person_id, note FROM person_notes WHERE viewer_id = ? AND note IS NOT NULL AND note <> ""' + ); + $st->execute([$viewerId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['person_id']] = (string)$r['note']; + } + return $out; + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index 402c5dd..adc4c38 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -71,7 +71,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '14'; + public const VERSION = '15'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -1035,8 +1035,68 @@ CREATE TABLE IF NOT EXISTS `ledger_shares` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本共享' SQL); - // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— - // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 + // ---- 工作经历(版本 15,用户 2026-10-03 第 4 条)---- + // 用户原话:「工作单位、职称、职务,都是可以填写多个的,因为一个人的工作可能会变, + // 我们需要记住他工作和职务的变化。」 + // 于是一个人不再只有"一个单位 + 一个职称",而是一串经历(见 App\PersonJob)。 + // ⚠️ persons.org / title **保留不动**,语义变成"当前那一段的快照": + // 名册/家谱/观影标签这些地方只要"现在在哪儿",不该为一行字去 JOIN 历史表; + // 而且老数据也不会因为这次改动而消失。 + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `person_jobs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `person_id` INT UNSIGNED NOT NULL, + `org` VARCHAR(120) DEFAULT NULL COMMENT '单位', + `title` VARCHAR(120) DEFAULT NULL COMMENT '职务(如 主任 / 副院长)', + `rank_name` VARCHAR(120) DEFAULT NULL COMMENT '职称(如 副教授 / 高级工程师)', + `start_ym` CHAR(7) DEFAULT NULL COMMENT '起始年月 YYYY-MM(可空)', + `end_ym` CHAR(7) DEFAULT NULL COMMENT '结束年月;**空 = 现在还在**(列表里标「现在」)', + `note` VARCHAR(200) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_pj_person` (`person_id`, `end_ym`), + CONSTRAINT `fk_pj_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工作经历:一个人可以有多段(单位/职务/职称/起止年月)' +SQL); + + // 升级回填:老数据只有 persons.org / title 两个字段 → 造一段"没有起止"的当前经历。 + // ⚠️ 只在**这个人还没有任何经历**时补,重复跑不会重复插(幂等)。 + try { + $db->exec( + 'INSERT INTO person_jobs (person_id, org, title, rank_name, start_ym, end_ym, note) + SELECT p.id, p.org, p.title, NULL, NULL, NULL, NULL + FROM persons p + WHERE (p.org IS NOT NULL AND p.org <> "" OR p.title IS NOT NULL AND p.title <> "") + AND NOT EXISTS (SELECT 1 FROM person_jobs j WHERE j.person_id = p.id)' + ); + } catch (\Throwable $ex) { + self::$stepErrors[] = '工作经历回填:' . $ex->getMessage(); + } + + // ---- 「我对他的备注」(版本 15,用户 2026-10-03 第 5 条)---- + // 用户原话:「名册中,每个人的备注,都有两个,一个是他自己填写的,就好像他自己的 + // 一个格言或者自我介绍;如果其他人打开这个人的信息,编辑备注, + // 是这个人对他的独有的备注,例如记录怎么跟这个人认识的等等。」 + // + // 所以两种备注分开放: + // · persons.note —— **本人写的**自我介绍(每个人自己一份,见 form 的说明); + // · person_notes —— **"我"对他写的**私人备注(每人每对象一份,别人看不到)。 + // ⚠️ 不塞进 persons:那是"一个人的属性",而"我怎么认识他的"是**我和他之间**的事, + // 混在一起必然出现"我把备注写在别人身上、全家都看得见"。 + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `person_notes` ( + `person_id` INT UNSIGNED NOT NULL COMMENT '被写备注的那个人', + `viewer_id` INT UNSIGNED NOT NULL COMMENT '谁写的(只有他自己看得到)', + `note` TEXT DEFAULT NULL COMMENT '我对他的私人备注(怎么认识的、要注意什么…)', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`person_id`, `viewer_id`), + CONSTRAINT `fk_pn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_pn_viewer` FOREIGN KEY (`viewer_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='「我对某个人的私人备注」:每人每对象一条,只有自己看得到' +SQL); + // ---- 账本盘点(版本 14,用户 2026-10-03 第 8 条)---- // 用户原话:「账本要增加盘点功能,因为某一个账目可能会变,要记住每一次变化, // 用盘点来完成。我点击盘点,让我对一个账目的金额进行重新输入, diff --git a/assets/css/app.css b/assets/css/app.css index c020431..ade8a8c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -457,6 +457,20 @@ textarea.input { min-height: 76px; resize: vertical; } .kv-row dd { margin: 0; font-size: 14px; word-break: break-all; } .mono { font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; letter-spacing: .5px; } +/* ---- 工作经历(详情页,用户 2026-10-03 第 4 条)---- + 一个人可以有多段,逐条列出来;"现在还在那儿"的那段排最前并带徽标。 */ +.job-history { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.job-history-item { + display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; + word-break: normal; /* ⚠️ .kv-row dd 上有 break-all,这里要收回来, + 否则"中国海洋大学"会被从中间断开 */ +} +.job-history-item .job-org { font-weight: 600; } +.job-history-item .job-title { color: var(--brand-ink); } +.job-history-item .job-span { font-variant-numeric: tabular-nums; } +/* 「我对他的私人备注」那一块:淡一点,别和"公开资料"抢注意力 */ +.my-note-card { background: #FCFAF6; } + /* ---------------- 提示 ---------------- */ .flash { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; } .flash-item { @@ -621,16 +635,19 @@ textarea.input { min-height: 76px; resize: vertical; } · 卡片不再整张是链接:只有 .kin-nm 是 ,右下角是独立的 + + + +
+ +
@@ -176,11 +255,32 @@ $ctHints = [ value="" placeholder="可留空"> +
- + + 这是他本人的自我介绍,别人看得到。改的是同一份 —— 他也能在个人设置里改。
+ + +
+ + + + 只有你自己看得到,别人(包括他本人)都看不到 —— + 这里写的是"我和他之间的事",不是他的公开资料。 + +
+ +
@@ -200,30 +300,6 @@ $ctHints = [
- -
- - - - - 选好图片后会弹出裁剪框,把要留的部分框住。比例按它在页面上的形状定(头像是 1:1 正方形)。多大都能传,保存时自动缩放成 ×。 -
-
- - -
-
@@ -306,3 +382,53 @@ $ctHints = [
+ + diff --git a/templates/people/import.php b/templates/people/import.php new file mode 100644 index 0000000..f530b84 --- /dev/null +++ b/templates/people/import.php @@ -0,0 +1,95 @@ + +
+

导入联系人

+ 从手机 / 邮箱导出的通讯录批量加进名册 + +
+ +
+

选一份文件

+

+ 支持 .vcf(vCard,iPhone / 安卓 / 谷歌联系人 / Outlook 都能导出)与 + .csv / .txt(表头里有「姓名 / 手机 / 邮箱」之类就能认,中英文表头都行)。 +
+ 导入规则:手机号或姓名已经在名册里的,不会重复建人, + 只把「他还没有的信息」补上去(已有的一律不动);其余才新建。 +

+ +
+ +
+
+ + +
+
+ + + 只影响新建的那些人;已有的人保持他原来的分组。 +
+
+
+ +
+
+
+ + +
+

+ 导入结果 + 新增 · 补充 · 无需改动 +

+ +

文件里没有可导入的联系人。

+ + +
+ + + + + + + + + + + + +
联系人结果补上了什么
+ +
+
+ + +
+ \ No newline at end of file diff --git a/templates/people/index.php b/templates/people/index.php index 686474d..c324a94 100644 --- a/templates/people/index.php +++ b/templates/people/index.php @@ -68,7 +68,11 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void { ?>
data-search="" - data-initials=""> + + data-initials=""> @@ -122,6 +126,14 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
+ ,且必须在 person-main 这个 **外面** + —— a 套 a 是非法 HTML,浏览器会把 DOM 拆开。 */ ?> + 编辑
⋯ @@ -183,6 +195,12 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): 从家里已有的人里挑() + + + 导入联系人 + 添加成员 diff --git a/templates/people/show.php b/templates/people/show.php index 5812230..8f9f6a0 100644 --- a/templates/people/show.php +++ b/templates/people/show.php @@ -55,14 +55,11 @@ $shortKind = \App\Birthday::KINDS; - -
- - - -
- + + 返回 @@ -177,7 +174,36 @@ $shortKind = \App\Birthday::KINDS;
- + + +
+
工作
+
+
    + + +
  • + 现在 + + + + + + ~ + + + + () + +
  • + +
+
+
+
工作
@@ -198,8 +224,11 @@ $shortKind = \App\Birthday::KINDS;
+ -
备注
+
自我介绍
最近登录
@@ -208,6 +237,31 @@ $shortKind = \App\Birthday::KINDS;
+ + +
+

+ 我对 的备注 + 只有我自己看得到 +

+
+ + + + +
+ + 别人看不到这一栏(包括他本人) +
+
+
+ +
@@ -507,6 +561,32 @@ $shortKind = \App\Birthday::KINDS;
+ 两步确认:破坏性操作不该一眼就看到、更不该一键完成。 + ⚠️ 措辞改成了「从名册中删除」并写明会连带删掉什么 —— + 原来那句"从名册里移除"听着像"只是从我的名册里移走", + 实际会删账号与亲属关系,说法与后果不符。 */ ?> + +
+
+ 删除这个人… +
+

+ 会把他从名册中删除,**并且**删掉他的登录账号与全部亲属关系连线(不可恢复)。 + 如果你只是想让他从你的名册里消失,用名册列表上的「⋯ → 从我的名册移走」。 +

+
+ + + +
+
+
+
+ +