Files
FamilyBook/assets/js/person_picker.js
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

211 lines
9.0 KiB
JavaScript
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.
/**
* 全局人员选择器(v24 用户第 11 条)
*
* 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …),
* 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。
*
* 三件事:
* ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖)
* ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下
* ③ chips 与弹窗里的勾选状态双向同步
*/
(function () {
'use strict';
/* ------------------------------------------------------------------
* 汉字 → 拼音首字母
*
* 做法:a..z 各挑一个「锚字」(没有 i / u / v 开头的常用字,所以是 23 个),
* 用拼音 collation 二分定位某个汉字落在哪一段 —— 落在「垃」之后、「妈」之前
* 就是 l。整套零依赖,不需要塞一张几万字的拼音表进来。
*
* ⚠️ 浏览器不支持拼音排序时返回 null:此时只按名字搜,不硬出错误结果。
* ---------------------------------------------------------------- */
var initialsOf = (function () {
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
var letters = 'abcdefghjklmnopqrstwxyz';
var coll;
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
// 排序没真正生效时 compare 会退化成按码点/笔画,这里直接认不出来 → 放弃拼音
if (!coll || coll.compare('阿', '匝') >= 0) { return null; }
return function (s) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
if (ch.charCodeAt(0) < 0x2E80) { continue; } // 非 CJK:不产字母
var lo = 0, hi = anchors.length - 1, best = -1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (coll.compare(ch, anchors.charAt(mid)) >= 0) { best = mid; lo = mid + 1; }
else { hi = mid - 1; }
}
if (best >= 0) { out += letters.charAt(best); }
}
return out;
};
})();
/**
* q 是否是 s 的**子序列**。
* 为什么不用 indexOf:「杨永全」的首字母是 yyq,用户很自然地会打 yq ——
* 连续子串匹配不到,子序列能。选人这种小列表宁可放宽一点,也别让人以为坏了。
*/
function isSubSeq(q, s) {
if (!q) { return true; }
var i = 0;
for (var j = 0; j < s.length && i < q.length; j++) {
if (s.charAt(j) === q.charAt(i)) { i++; }
}
return i === q.length;
}
function init(root) {
var id = root.getAttribute('data-pp') || '';
var isMulti = root.getAttribute('data-pp-mode') !== 'single';
var modal = root.querySelector('.pp-modal');
var openBtn = root.querySelector('.pp-open');
var closeBtn = root.querySelector('.pp-close');
var search = root.querySelector('.pp-search');
var list = root.querySelector('.pp-list');
var chips = root.querySelector('.pp-chips');
var emptyTip = root.querySelector('.pp-empty');
if (!modal || !list || !chips) { return; }
// 预计算每个人的首字母串,挂在 data-initials 上(列表再长也只是算一次)
// ⚠️⚠️ **每个字段单独算,用空格分隔,绝不拼成一串**。
// "姓名 + 称呼" 拼起来会造出**跨字段的巧合命中**:
// 用户 2026-10-03 实测「输 yy 把岳父搜出来」——洪勇的称呼是"岳父",
// `hy` + `yf` = `hyyf`,子序列 `yy` 正好跨过边界命中;
// 单独看 `hy` 与 `yf` 都不含 `yy`。逐字段判才是对的。
// 服务端(App\Pinyin::matches)本来就是逐字段判的,两边口径保持一致。
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
var parts = [];
var n1 = el.getAttribute('data-name') || '';
var n2 = el.getAttribute('data-shown') || '';
[n1, n2].forEach(function (s) {
if (!s) { return; }
var ini = initialsOf ? initialsOf(s) : '';
if (ini && parts.indexOf(ini.toLowerCase()) < 0) { parts.push(ini.toLowerCase()); }
});
el.setAttribute('data-initials', parts.join(' '));
// 徽标只显示**姓名的**首字母(称呼那串太杂,显示出来反而更乱)
var badge = el.querySelector('.pp-initials');
var first = initialsOf ? initialsOf(n1) : '';
if (badge && first) { badge.textContent = first; badge.hidden = false; }
});
function syncChips() {
chips.innerHTML = '';
var any = false;
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
if (box.value === '0') { return; } // 「不选」不进 chips
var item = box.closest('.pp-item');
if (!item) { return; }
any = true;
// ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位,
// 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。
var nameEl = item.querySelector('.pp-name');
var av = item.querySelector('.pp-av');
var chip = document.createElement('span');
chip.className = 'pp-chip';
chip.setAttribute('data-id', box.value);
if (av) {
var im = document.createElement('img');
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
im.alt = '';
chip.appendChild(im);
}
var nm = document.createElement('span');
nm.className = 'pp-name';
nm.textContent = nameEl ? nameEl.textContent : box.value;
chip.appendChild(nm);
if (isMulti) {
var x = document.createElement('button');
x.type = 'button'; x.className = 'pp-x'; x.textContent = '×';
x.title = '移除'; x.setAttribute('data-id', box.value);
chip.appendChild(x);
}
chips.appendChild(chip);
});
// ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex,
// 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。
if (!any && !isMulti) {
var ph = document.createElement('span');
ph.className = 'pp-chip pp-chip-empty';
ph.textContent = '未选择';
chips.appendChild(ph);
}
}
function applyFilter() {
var q = (search.value || '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
// ⚠️ 首字母按**空格分隔的多段**逐段独立判 —— 见上面写 data-initials 时的说明
// (把"姓名+称呼"拼成一串会让 `yy` 跨字段命中"洪勇/岳父")。
var inis = (el.getAttribute('data-initials') || '').split(/\s+/);
var iniHit = false;
for (var i = 0; i < inis.length; i++) {
if (inis[i] && isSubSeq(q, inis[i])) { iniHit = true; break; }
}
var hit = !q ||
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
iniHit;
el.hidden = !hit;
if (hit) { shown++; }
});
if (emptyTip) { emptyTip.hidden = shown > 0; }
}
function openModal() {
modal.hidden = false;
search.value = '';
applyFilter();
syncChips();
search.focus();
}
function closeModal() { modal.hidden = true; }
if (openBtn) { openBtn.addEventListener('click', openModal); }
if (closeBtn) { closeBtn.addEventListener('click', closeModal); }
modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } });
search.addEventListener('input', applyFilter);
chips.addEventListener('click', function (e) {
var x = e.target.closest ? e.target.closest('.pp-x') : null;
if (!x) { return; }
var gid = x.getAttribute('data-id');
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) {
if (box.value === gid) { box.checked = false; }
});
syncChips();
});
/* 多选:勾一个 = 出 chip + 关弹窗(再添加就再点一次「+ 添加人」);
单选:点一个 = 换掉当前这个人 + 关弹窗 */
list.addEventListener('change', function (e) {
if (!e.target || e.target.tagName !== 'INPUT') { return; }
syncChips();
if (e.target.checked) { closeModal(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) { closeModal(); }
});
syncChips();
}
function boot() {
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();