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 与本地一致、错误日志无新增。
This commit is contained in:
+109
-13
@@ -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 是 <a>,右下角是独立的 <button>,
|
||||
两者分开,避免"点按钮却进了成员页"的误触。
|
||||
卡片尺寸与 JS 的 setCardDim({w,h}) 必须一致(库按它算间距与连线落点)。 */
|
||||
/* 卡片尺寸:**框宽 = 头像宽**(用户 2026-10-03 第 8 条:「外面那个框,宽度和头像图片一致,
|
||||
/* 卡片尺寸:**框宽 = 头像宽**(用户 2026-10-03:「外面那个框,宽度和头像图片一致,
|
||||
下面显示名字就行了」)。
|
||||
98 = 96(头像实际宽度,见 .kin-ph)+ 左右各 1px 边框 —— 即"边框刚好贴着头像",
|
||||
框里不再有多余的横向空白(原来是 118 宽、比头像宽出 22px)。
|
||||
⚠️ 横向 padding 必须是 0,否则头像(width:100%)会被压窄。
|
||||
⚠️ 这个 98×116 必须与 JS 里 setCardDim({w,h}) **保持一致**:
|
||||
🔴 头像槽位是 **1:1(正方形)**(见 App\ImageSlot::SLOTS['avatar'])
|
||||
—— 所以"框宽 = 头像宽"其实要求框里的头像区**也是正方形**:
|
||||
之前写成 96×72 的长方形 + `object-fit: contain`,正方形照片被**缩到 72 宽**
|
||||
左右各留 ~12px 空白 → 看起来"框比头像宽很多"(用户第 8 条「还是比头像宽很多」)。
|
||||
现在头像区取正方形,照片 `object-fit: cover` 铺满 —— 框就是头像本身那么大。
|
||||
⚠️ 这个 90/128 必须与 JS 里 setCardDim({w,h}) **保持一致**:
|
||||
库按它算卡片间距与连线落点,两边不一致连线就会歪。 */
|
||||
.kin-card {
|
||||
--kin-w: 98px;
|
||||
--kin-h: 116px;
|
||||
--kin-w: 90px;
|
||||
--kin-h: 128px;
|
||||
--kin-ph: 88px; /* 头像区(正方形):卡宽 − 左右各 1px 边框 */
|
||||
position: absolute;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0;
|
||||
@@ -678,17 +695,19 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
.kin-card:hover {
|
||||
box-shadow: var(--shadow-l); border-color: var(--brand-2);
|
||||
}
|
||||
/* 头像外框:**占满卡片内宽**(卡片 padding 横向为 0,所以这里 100% = 卡片内宽),
|
||||
长方形、object-fit: contain 不裁切。
|
||||
用户 2026-10-03 第 8 条:「外面那个框,宽度和头像图片一致,下面显示名字就行了」
|
||||
—— 框宽 == 头像宽,头像下面只有名字。 */
|
||||
/* 头像区:**正方形、铺满**(见上面 --kin-ph 的说明)。
|
||||
用户 2026-10-03 第 8 条:「宽度和头像图片一致,下面显示名字就行了」。 */
|
||||
.kin-card .kin-ph {
|
||||
width: 100%; height: 72px; border-radius: 8px 8px 0 0; overflow: hidden;
|
||||
width: var(--kin-ph); height: var(--kin-ph);
|
||||
border-radius: 8px; overflow: hidden;
|
||||
background: #F1E7D6; flex: 0 0 auto;
|
||||
display: block;
|
||||
}
|
||||
/* 头像槽位是 1:1,所以这里的容器也是 1:1 —— cover 不会裁掉任何内容
|
||||
(照片本来就是按 1:1 裁好的)。⚠️ 别改成 contain:那会在框里留出空白,
|
||||
看起来就是"框比头像宽"。 */
|
||||
.kin-card .kin-ph img {
|
||||
width: 100%; height: 100%; object-fit: contain; display: block;
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
.kin-card .kin-ph:hover { box-shadow: inset 0 0 0 2px var(--brand-2); }
|
||||
/* 姓名:用户 2026-10-03 第 7 条起,点姓名 = **以他为中心**重画家谱
|
||||
@@ -907,11 +926,21 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
/* 卡片改成 div > a + details:表单不能套在链接里(非法 HTML,浏览器会把 DOM 拆开) */
|
||||
.person-main { display: block; color: var(--ink); }
|
||||
.person-main:hover { text-decoration: none; }
|
||||
.person-more { display: none; }
|
||||
/* 列表形态下「⋯ 调整分组」改到行的**右侧**(原来在卡片下方,列表里会撑高一行) */
|
||||
.person-more {
|
||||
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||||
border: 0; background: none;
|
||||
}
|
||||
/* 用户 2026-10-03 第 7 条:⋯ **前面**多了一个「编辑」按钮,所以 ⋯ 要往左让位。
|
||||
编辑按钮同样绝对定位在行右侧(在 ⋯ 左边 46px 处)—— 两个都贴着行的右边缘,
|
||||
这样"改资料(常用)"在前、"更多操作(低频)"在后,手指/鼠标扫过去就是从左到右。 */
|
||||
.person-edit {
|
||||
position: absolute; right: 46px; top: 50%; transform: translateY(-50%);
|
||||
padding: 3px 10px; font-size: 13px;
|
||||
}
|
||||
/* ⚠️ 预留出右侧两个按钮的位置,否则姓名/信息会钻到按钮底下 */
|
||||
.people-grid .person .person-main { padding-right: 96px; }
|
||||
.person-more > summary {
|
||||
list-style: none; cursor: pointer; padding: 4px 10px;
|
||||
font-size: 16px; color: var(--muted); border-radius: 8px;
|
||||
@@ -2144,6 +2173,73 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
.cmt-form { margin-top: 6px; }
|
||||
.task-danger { margin-top: 18px; display: flex; justify-content: flex-end; }
|
||||
|
||||
/* ---- 「危险操作区」(成员详情页的删除,用户 2026-10-03 第 2 条)----
|
||||
刻意做成**页面最下方一块低调的折叠区**:破坏性操作不该一眼看到、更不该一键完成。
|
||||
视觉上只用一条虚线分隔 + 灰色摘要,不抢上面正常内容的注意力。 */
|
||||
.danger-zone {
|
||||
margin-top: 28px; padding-top: 14px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.danger-zone > details > summary { cursor: pointer; font-size: 13px; }
|
||||
.danger-zone .del-confirm {
|
||||
margin-top: 10px; padding: 12px 14px;
|
||||
border: 1px solid rgba(178, 45, 45, .25); border-radius: var(--radius-s);
|
||||
background: #FDF6F4;
|
||||
}
|
||||
|
||||
/* ---- 表单标签更醒目(用户 2026-10-03 第 6 条)----
|
||||
「每一个输入的名字,都不够醒目,例如姓名、性别,这些字应该变大一点,加粗,
|
||||
不能跟提示一样大小」—— 提醒(.tip)是 12px 的灰字,而标签原来是 13-14px,
|
||||
两者太接近,扫一眼分不出"哪一行在哪"。这里把标签统一抬到 15px / 600 字重 /
|
||||
深一点的墨色,与 .tip / .hint 明确拉开层级。 */
|
||||
.form-grid > .field > label,
|
||||
.form-grid > .field > .field-label {
|
||||
font-weight: 600; font-size: 15px; color: var(--ink);
|
||||
}
|
||||
/* 「我的资料 / 编辑成员」那张表里也有不在 form-grid 直接子级的 label(嵌套块),
|
||||
一并覆盖;用 :not(.job-lb) 排除工作经历行里的那些小标签(见下)。 */
|
||||
.card .field > label:not(.job-lb):not(.check-item):not(.chk-inline) {
|
||||
font-weight: 600; font-size: 15px; color: var(--ink);
|
||||
}
|
||||
/* ⚠️ 但 .tip / .hint / .small 这些"说明文字"必须保持小灰字,别被上面那条带大 */
|
||||
.card .field > label.tip,
|
||||
.card .field > label.hint,
|
||||
.card .field > label.small { font-weight: 400; font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* ---- 头像选择器(用户第 6 条:头像放最前 + 去掉「选择文件」按钮)----
|
||||
点那张(默认)头像就是选文件 —— 图本身就是按钮,不再另起一行控件。
|
||||
默认头像用 .avatar-default 叠一个"+"角标,提示这里可点。 */
|
||||
.avatar-field { margin-bottom: 4px; }
|
||||
.avatar-picker { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.avatar-picker #avatar-preview {
|
||||
width: 96px; height: 96px; border-radius: 12px; object-fit: cover;
|
||||
border: 1px solid var(--line); background: #F1E7D6; cursor: pointer;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.avatar-picker #avatar-preview:hover { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(168,74,40,.12); }
|
||||
.avatar-picker #avatar-preview[data-has-avatar="0"] { border-style: dashed; }
|
||||
.avatar-hint { line-height: 1.7; }
|
||||
|
||||
/* ---- 工作经历(用户第 4 条:可以有多个)---- */
|
||||
.job-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.job-row {
|
||||
display: grid; gap: 8px; align-items: end;
|
||||
/* 单位 / 职务 / 职称 各占一份,起止各一份窄的,最后是删除按钮 */
|
||||
grid-template-columns: 1.4fr 1fr 1fr 130px 130px auto;
|
||||
padding: 10px 12px; border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-s); background: #FDFBF7;
|
||||
}
|
||||
.job-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.job-lb { font-size: 12px; color: var(--muted); font-weight: 500; }
|
||||
.job-cell .input { padding: 7px 9px; font-size: 14px; }
|
||||
.job-del { align-self: end; }
|
||||
@media (max-width: 860px) {
|
||||
/* 窄屏:一段经历折成两列,别硬塞六列(会挤成没法填) */
|
||||
.job-row { grid-template-columns: 1fr 1fr; }
|
||||
.job-row .job-cell:first-child { grid-column: 1 / -1; }
|
||||
.job-del { grid-column: 1 / -1; justify-self: end; }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.kanban { grid-template-columns: 1fr; }
|
||||
.nf-grid { grid-template-columns: 1fr; }
|
||||
|
||||
+25
-7
@@ -38,14 +38,32 @@
|
||||
return i === q.length;
|
||||
}
|
||||
|
||||
/** 一个元素能不能被 q 命中 */
|
||||
function hit(el, q) {
|
||||
if (!q) { return true; }
|
||||
var hay = (el.getAttribute('data-search') || '').toLowerCase();
|
||||
if (hay.indexOf(q) >= 0) { return true; }
|
||||
var ini = (el.getAttribute('data-initials') || '').toLowerCase();
|
||||
return ini ? isSubSeq(q, ini) : false;
|
||||
/** 一个元素能不能被 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');
|
||||
|
||||
@@ -73,12 +73,26 @@
|
||||
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 s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || '');
|
||||
var ini = initialsOf ? initialsOf(s) : '';
|
||||
el.setAttribute('data-initials', ini.toLowerCase());
|
||||
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');
|
||||
if (badge && ini) { badge.textContent = ini; badge.hidden = false; }
|
||||
var first = initialsOf ? initialsOf(n1) : '';
|
||||
if (badge && first) { badge.textContent = first; badge.hidden = false; }
|
||||
});
|
||||
|
||||
function syncChips() {
|
||||
@@ -128,10 +142,17 @@
|
||||
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 ||
|
||||
isSubSeq(q, el.getAttribute('data-initials') || '');
|
||||
iniHit;
|
||||
el.hidden = !hit;
|
||||
if (hit) { shown++; }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user