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:
2026-10-03 19:27:14 +08:00
parent 218cdf74fc
commit b5725b449a
14 changed files with 1639 additions and 81 deletions
+109 -13
View File
@@ -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; }