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

169 lines
7.8 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 用户 2026-10-03 第 2 条)
*
* 用户原话:「所有人员检索的地方,输入汉字或者首字母,自动筛选,而不需要按回车。」
*
* 这个模块只做**前端显隐**:数据已经全在页面上了,输入就立刻筛,不发请求。
* 为什么不做成"输入 → AJAX 重查":
* · 名册 / 挑人 / 选人弹窗都是几十条的小列表,整份渲染出来的成本可以忽略;
* · 一发请求就要处理"响应回来时输入框已经变了""光标跳到末尾"这些竞态,
* 而这个功能的价值恰恰是"打字的时候列表在动" —— 有一帧延迟就不对了;
* · 没 JS 时页面上的「搜索」按钮/form 提交照旧能用(渐进增强,不是二选一)。
*
* 三种用法(都是声明式,靠 data-* 挂上去,不用改 JS):
*
* ① 列表行:<input data-filter="#roster"> … 行上带 data-search="姓名 称呼 邮箱 首字母"
* 行被筛掉 = hidden 属性(⚠️ CSS 里必须给 [hidden] 写 display:none,
* 作者样式里的 display:flex 会盖过浏览器默认的 [hidden] —— 项目里踩过两次)
*
* ② 下拉选项:<input data-filter-select="#foo"> → 把不匹配的 <option> 藏起来。
* 应用在"共享给谁 / 负责人 / 纪要人"这类原生下拉上(名册一长就难找)。
*
* ③ 计数与空态:容器上带 data-filter-count="#id"(显示"命中 N 条")、
* data-filter-empty="#id"(一条都没命中时露出来的提示)。
*
* 拼音首字母:服务端已经算好放进 data-initials(见 App\Pinyin::initials)。
* 这里对 data-search 做"包含",对 data-initials 做**子序列**(yyq / yy / y 都能中)。
*/
(function () {
'use strict';
/** q 是否是 s 的子序列(与 person_picker.js 同一判据) */
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;
}
/** 一个元素能不能被 q 命中
*
* ⚠️⚠️ 首字母必须**逐字段独立匹配**,绝不能把多个字段的首字母拼成一串再判子序列。
* 这条是实测踩出来的(用户 2026-10-03:「我用 yy 搜索,为什么把岳父、洪勇搜索出来了?」):
* 名册行的 data-initials 原先是 initials(姓名 + 称呼) 的结果,
* 而"洪勇"在他这儿显示的称呼是"岳父" → `hy` + `yf` = **`hyyf`** → 输入 `yy` 命中。
* 用户抱怨的"把岳父搜出来"正是这个跨字段串:**每个字段单独看都不含 yy**,
* 拼在一起才撞出来。所以这里对每个字段分别判,任一段命中就算这个人命中。
* 同名同姓之外的"跨字段巧合"还有很多(张伟的称呼是"二姨"→ zw+ey=zwey,
* 输 we 就会误命中),逐字段判才是对的。
*
* 服务端那一半(App\Pinyin::matches)本来就是逐字段判的,两边现在口径一致。
*/
function hit(el, q) {
if (!q) { return true; }
var hay = (el.getAttribute('data-search') || '').toLowerCase();
if (hay.indexOf(q) >= 0) { return true; }
// data-initials 里可能放了**多段**首字母(用空格分隔)—— 每段独立判子序列
var ini = (el.getAttribute('data-initials') || '').toLowerCase();
if (!ini) { return false; }
var parts = ini.split(/\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && isSubSeq(q, parts[i])) { return true; }
}
return false;
}
function bindList(input) {
var sel = input.getAttribute('data-filter');
var box = sel ? document.querySelector(sel) : null;
if (!box) { return; }
// 行本身可能不是"直接子元素",统一找带 data-search 的
var items = box.querySelectorAll('[data-search]');
var countEl = input.getAttribute('data-filter-count')
? document.querySelector(input.getAttribute('data-filter-count')) : null;
var emptyEl = input.getAttribute('data-filter-empty')
? document.querySelector(input.getAttribute('data-filter-empty')) : null;
function apply() {
var q = (input.value || '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(items, function (el) {
var ok = hit(el, q);
el.hidden = !ok;
if (ok) { shown++; }
});
if (countEl) { countEl.textContent = q === '' ? '' : ('命中 ' + shown + ' 条'); }
if (emptyEl) { emptyEl.hidden = !(q !== '' && shown === 0); }
// 分组栏里一个人都不剩时,整栏也收起来(否则留一排空标题)
Array.prototype.forEach.call(box.querySelectorAll('[data-filter-group]'), function (g) {
var n = 0;
Array.prototype.forEach.call(g.querySelectorAll('[data-search]'), function (el) {
if (!el.hidden) { n++; }
});
g.hidden = n === 0;
});
}
input.addEventListener('input', apply);
// ⚠️ 回车**不要**提交表单:一提交页面就整页刷新、输入框失焦,
// 而列表已经实时筛过了 —— 用户按回车只会看到"闪一下、什么都没变"。
var form = input.form;
if (form) {
form.addEventListener('submit', function (e) {
// 没有 JS 时的兜底是 form 提交;有 JS 时拦下来,避免无意义的整页刷新。
if (input.getAttribute('data-filter-submit') !== '1') {
e.preventDefault();
apply();
}
});
}
apply();
// 清除按钮:与「搜索」同形同尺寸(用户第 3 条),点了 = 清空 + 立刻重筛
var clearSel = input.getAttribute('data-filter-clear');
if (clearSel) {
Array.prototype.forEach.call(document.querySelectorAll(clearSel), function (btn) {
btn.addEventListener('click', function () {
input.value = '';
apply();
input.focus();
});
});
}
return apply;
}
function bindSelect(input) {
var box = document.querySelector(input.getAttribute('data-filter-select'));
if (!box) { return; }
var opts = Array.prototype.slice.call(box.options);
// 每个 option 首次筛之前先备份(中文名 + 服务端给的 data-initials)
Array.prototype.forEach.call(opts, function (o) {
if (o.getAttribute('data-initials') === null) {
o.setAttribute('data-initials', (o.getAttribute('data-initials-raw') || '').toLowerCase());
}
if (o.getAttribute('data-search') === null) {
o.setAttribute('data-search', (o.textContent || '').trim().toLowerCase());
}
});
input.addEventListener('input', function () {
var q = (input.value || '').trim().toLowerCase();
Array.prototype.forEach.call(opts, function (o) {
if (o.value === '' || o.value === '0') { o.hidden = false; return; } // 留「全部/不指定」
var hay = o.getAttribute('data-search') || '';
var ini = o.getAttribute('data-initials') || '';
o.hidden = !(q === '' || hay.indexOf(q) >= 0 || (ini && isSubSeq(q, ini)));
});
// 当前选中的那项被筛掉了就把选择收回到「全部/不指定」,免得提交一个看不见的值
if (box.selectedOptions.length && box.selectedOptions[0].hidden) {
box.selectedIndex = 0;
}
});
}
function boot() {
// 给页面一个"我到了"的标记:有些页面(名册的「清除」按钮)要据此决定
// 是走"就地清空重筛"还是回落到跳转。没这个标记它们只能靠猜。
window.__fbFilterLoaded = true;
Array.prototype.forEach.call(document.querySelectorAll('[data-filter]'), bindList);
Array.prototype.forEach.call(document.querySelectorAll('[data-filter-select]'), bindSelect);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();