名册编辑:表单取消分组、网址补常驻输入框、地址合一(#92)

用户 2026-11-06 的要求:
1. 「联系方式」这一句(分组标题)去掉;
2. 网址要跟邮箱一样有标签、有常驻空输入框;
3. 地址的格式跟邮箱/手机号一致:国家、省市、区县、详细地址
   都放进同一个输入框的 placeholder 里;
4. 「工作」「其它」两个分组标题也去掉,直接写单位和职务、直接写座右铭;
5. 不需要分多级,就是一层。

改动:
- 删掉全部 4 处 this.section() 调用与 section() 构建器(表单只剩一层);
- 新增 urlField():常驻「网址」标签 + 空输入框;服务端无「主网址」列,
  取 person_contacts kind=url 的第一条当主,提交时由 urlsOut() 拼回组首;
- 地址五级合成一个 addressField(),层级写进 placeholder;
  没改地址时原样回传旧的 address 列 + 旧五级,改了才整串写入并清空五级。

同时修掉验证时抓到的**数据损坏**(不是假想,已在真机复现):
从名册列表点「编辑」时传进来的是本地列表行,列表行缺
birth_rank / 补充联系方式 / 工作经历 / 完整地址,而服务端
replaceContacts / PersonJob::replace 是整组替换语义
⇒ 用户随手保存一次就会把这些字段清空(实测 birth_rank 2 → null)。
修法:编辑模式无条件补拉一次详情,逐字段/逐组只覆盖「用户没动过」的部分;
save() 先 await 补拉任务,未拿到权威值时对没动过的字段一律不提交。

附带:
- addressField()/urlField() 用内部直读字段的写法(@Builder 值参数不参与
  状态观察,异步回填刷不出来 —— 实测停在中途值);
- 保存时 contacts 增加「原本有条目、现在删空」与「动过任意一组」两个
  提交条件,否则删掉仅有的那条网址永远删不掉。
This commit is contained in:
2026-11-06 12:40:04 +08:00
parent d1f27937b9
commit 7d7973a319
@@ -7,9 +7,16 @@
* *
* 于是这里覆盖了**网页端表单的全部字段**(`templates/people/form.php`): * 于是这里覆盖了**网页端表单的全部字段**(`templates/people/form.php`):
* 姓名 / 性别 / 家中排行 / 生日(阳历+农历+闰月) / 主手机号 / 补充电话 * 姓名 / 性别 / 家中排行 / 生日(阳历+农历+闰月) / 主手机号 / 补充电话
* 单位 / 职务 / 座右铭 / 邮箱 / 网址 / 身份证 / 结构化地址五级 / 详细地址 * 单位 / 职务 / 座右铭 / 邮箱 / 网址 / 身份证 / 地址 / 详细地址
* 公共备注 / **我的私人备注** / 标签 / 工作经历(多段) / 虚拟人物标记 * 公共备注 / **我的私人备注** / 标签 / 工作经历(多段) / 虚拟人物标记
* *
* 🔴 v93(用户 2026-11-06):**整张表单取消分组标题,字段平铺一层**。
* 「联系方式,这一句话去掉吧。……工作这个也去掉,直接写单位和职务;
* 其他也去掉,直接写座右铭就可以了。不需要分多级,就是一层。」
* ⇒ 原来的「联系方式 / 地址 / 工作 / 其它」四个 `section()` 全部删掉,
* 字段按人读资料的顺序依次排下来。分组标题消失后表单反而更短,
* 因为每个标题本身就要占一行、还要带 14vp 上边距。
*
* ⚠️ 服务端同步扩过了(`PATCH /api/v1/people/<id>` 收全字段), * ⚠️ 服务端同步扩过了(`PATCH /api/v1/people/<id>` 收全字段),
* 并且**没传的字段一律沿用旧值** ⇒ 本页"全量发一遍"也不会误清空。 * 并且**没传的字段一律沿用旧值** ⇒ 本页"全量发一遍"也不会误清空。
* ⚠️ 头像走**单独接口**(`POST /people/<id>/avatar`,multipart), * ⚠️ 头像走**单独接口**(`POST /people/<id>/avatar`,multipart),
@@ -25,7 +32,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric, FB_MASK } from '../common/Theme'; import { FbColors, FbMetric, FB_MASK } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbSheetRing } from './FbSheetRing'; import { FbSheetRing } from './FbSheetRing';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FbRosterPerson, FbJobRow, FbContactRow, FbPersonRepo } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbApiOptions, FbApiResult, fbApi } from '../data/FbHttp'; import { FbApiOptions, FbApiResult, fbApi } from '../data/FbHttp';
import { fbUploadOne } from '../data/FbUpload'; import { fbUploadOne } from '../data/FbUpload';
@@ -105,6 +112,42 @@ interface FbEditJobRow {
note: string; note: string;
} }
/**
* 表单**标量字段的初值快照**。
*
* 🔴 为什么需要(本轮真机验证抓到的**数据损坏**,不是假想):
* 从名册**列表**点「编辑」进来时,很多字段在列表行里根本没有
* (`FbPersonRepo.rowToPerson()` 写死成空 / `false`):
* `birth_rank` / `birthday_lunar` / `birthday_lunar_leap` / `slogan` …
* 于是 `fillFrom()` 把它们填成空,用户点保存 = 把这些字段**写空**。
* 实测:爸爸的 `birth_rank` 从 2 变成了 `null`。
*
* ⇒ 修法:`applyDetail()` 拿详情回来时**逐字段比对初值** ——
* 当前值还等于初值(用户没动过)就用详情的真值覆盖;
* 用户改过了就保留他的。这样既补全了字段,又不会冲掉用户刚打的字。
*
* ⚠️ ArkTS 的对象字面量必须对应显式声明的 interface
* (`arkts-no-untyped-obj-literals`)⇒ 所以要有这个声明。
*/
interface FbEditSnap {
name: string;
gender: string;
birthRank: string;
bdSolar: string;
bdLunar: string;
lunarLeap: boolean;
phone: string;
org: string;
title: string;
slogan: string;
email: string;
nickname: string;
note: string;
myNote: string;
placeholder: boolean;
tags: string;
}
@ComponentV2 @ComponentV2
export struct FbPersonEditSheet { export struct FbPersonEditSheet {
/** 要编辑的人(null = 没打开 / 或"新建"模式,见 isCreate) */ /** 要编辑的人(null = 没打开 / 或"新建"模式,见 isCreate) */
@@ -177,11 +220,122 @@ export struct FbPersonEditSheet {
@Local fTitle: string = ''; @Local fTitle: string = '';
@Local fSlogan: string = ''; @Local fSlogan: string = '';
@Local fEmail: string = ''; @Local fEmail: string = '';
/**
* **主网址**(用户 2026-11-06:「网址这个地方应该跟邮箱一样,是有一个标签的。
* 如果现在没有,也跟邮箱一样,得有一个空的输入框。」)
*
* 🔴 为什么需要一个单独的字段、而不是直接读 `cUrls`:
* 原来是「没有网址就一行输入框都不显示,只有一个『+ 网址』」——
* 用户看到的就是"这一项没有输入的地方"。
* 邮箱那一路是 `field('邮箱', …)` 常驻 + 补充行 ⇒ 网址对齐成同一个形状。
*
* ⚠️ 服务端**没有**「主网址」这一列(`persons` 表只有 email/address),
* 网址一直是 `person_contacts` 里的 `kind='url'` 一**组**。
* ⇒ 这里把**第一条**当主网址显示在主输入框,其余走"补充网址"行,
* 提交时再拼回一组(见 `urlsOut()`)—— 数据形状不变,不新增迁移。
*/
@Local fUrl: string = '';
/**
* 地址(**一个输入框**,国家/省/市/区县/详细地址全都写在这一条里)。
*
* 🔴 用户 2026-11-06:「地址的格式应该跟上面的邮箱、手机号保持一致。
* 国家、省市、区县、详细地址这些东西,就放在那个输入框里作为 placeholder 就可以了。」
* ⇒ 原来五级结构化(国家 / 省 / 市 / 区县 / 详细)五个输入框,现在合成一个。
*/
@Local fAddress: string = ''; @Local fAddress: string = '';
/**
* 结构化地址的**原始值**(国家/省/市/区县)。
*
* ⚠️ 它们**不再显示**,只在"用户没动过地址"时原样回传 ——
* 保有旧数据的结构化信息(网页端住址下拉还认它),不清库。
* 一旦用户真的改了地址输入框,就整串写进 `address` 并把五级清空
* (见 `save()` 里的比对)。
*/
@Local fAddrCountry: string = ''; @Local fAddrCountry: string = '';
@Local fAddrProvince: string = ''; @Local fAddrProvince: string = '';
@Local fAddrCity: string = ''; @Local fAddrCity: string = '';
@Local fAddrDistrict: string = ''; @Local fAddrDistrict: string = '';
/**
* 打开表单时地址框里的**原值**(用于判断"用户到底改没改地址")。
*
* ⚠️ 不是 `@Local`:它不参与渲染,只是 save() 时的比对基准。
* 写 `@Local` 会让它进状态观察系统,白白多一份依赖关系。
*/
private origAddress: string = '';
/**
* 旧的 `persons.address` 列**原值**(只有街道门牌那一段)。
*
* 🔴 为什么还要单独记它:地址输入框里显示的是"五级 + 详细"的**拼接串**,
* 而 `address` 列只存了其中一段。用户没改地址时必须**原样回传那一段**,
* 若把拼接串写回去,下次再打开地址框就会变成
* "中国 山东省 青岛市 黄岛区 **中国 山东省 青岛市 黄岛区 xx路**"(越拼越长)。
*/
private origAddressCol: string = '';
/**
* 统一处理"表单还没回填完、用户已经点了保存"。
*
* 🔴 这是本轮验证时发现的**真数据隐患**,不是假想:
* 从名册**列表**点「编辑」时,`person` 传进来的是**本地列表行**
* (`rowToPerson` 里写死 `addrProvince/addrCountry/addrDistrict = ''`,
* `address` 也只有 `addr_city`)—— 详见 `FbPersonRepo` 那段注释。
* 于是地址框只能拼出"菏泽市"。用户如果没动地址就点保存,
* `address` 会被写成"菏泽市",**把真实住址覆盖掉**(实测原值是
* "工业路与曙光路交叉口北200米信和家园2号楼3单元05005")。
*
* ⇒ 两个补救:
* ① `aboutToAppear` 里发现是"列表行"(缺结构化地址)就**异步拉详情**补全;
* ② 保存时如果详情还没回来(`detailLoaded === false`)且地址**没被改过**,
* **不提交任何地址字段**(服务端"没传就沿用旧值")—— 宁可这次不写,
* 也不能把用户的住址写坏。
*/
private detailLoaded: boolean = false;
/**
* 详情补拉的**飞行任务**(`save()` 会 await 它)。
*
* 🔴 为什么保存要等它:列表行缺字段(`birth_rank` / 工作经历 / 补充联系方式…),
* 如果用户在详情回来之前就点了保存,那些字段会以"空"的状态提交上去
* ⇒ 一次保存就把服务端真值清空。实测踩过(`birth_rank` 2 → NULL)。
* ⇒ 让 `save()` 先 `await this.detailTask`,等合并完成再拼请求体。
* ⚠️ 合并逻辑本身是"用户动过的以用户为准"(见 `loadDetail`),
* 所以等它不会覆盖用户在等待期间打进去的字。
*/
private detailTask: Promise<void> | null = null;
/**
* 打开表单时这人**有没有**补充联系方式(电话/邮箱/地址/网址任一条)。
*
* 用途见 `save()` 里 `contacts` 的提交条件。
*/
private hadContacts: boolean = false;
/**
* 回填时各"子表组"的**签名快照**(用于判断用户动过哪一组)。
*
* 🔴 为什么需要按组而不是整体判(本轮验证时发现):
* 从名册**列表**点「编辑」进来时,`contacts` 与 `jobs` 都是
* `rowToPerson` 写死的**空数组**(列表接口只有画一栏的字段)。
* 而服务端 `Person::replaceContacts()` / `PersonJob::replace()`
* 是**整组替换**(先 DELETE 再 INSERT,且无条件调用)
* ⇒ 用户在表单里随便改个姓名点保存,这个人的
* **补充电话 / 补充邮箱 / 补充地址 / 网址 / 工作经历 会被全部清空**。
*
* 所以详情拉回来后必须回填这几组。但又不能无条件回填 ——
* 用户在等详情的那几百毫秒里可能已经在填网址了,覆盖掉等于白填。
* ⇒ 逐组比对签名:**没动过的组才用详情的覆盖**。
*/
private origPhonesSig: string = '';
private origEmailsSig: string = '';
private origAddrsSig: string = '';
private origUrlsSig: string = '';
private origJobsSig: string = '';
/**
* 打开表单时各**标量字段**的值(用于"用户动没动过"判定)。
*
* 详见 `FbEditSnap` 的注释:作用是让 `loadDetail()` 只覆盖用户没动过的字段。
*/
private snap: FbEditSnap = {
name: '', gender: '', birthRank: '', bdSolar: '', bdLunar: '', lunarLeap: false,
phone: '', org: '', title: '', slogan: '', email: '', nickname: '',
note: '', myNote: '', placeholder: false, tags: ''
};
@Local fIdCard: string = ''; @Local fIdCard: string = '';
@Local fNote: string = ''; @Local fNote: string = '';
/** /**
@@ -250,6 +404,190 @@ export struct FbPersonEditSheet {
if (p === null) { if (p === null) {
return; return;
} }
this.fillFrom(p);
/* 🔴 **无条件补拉一次详情**(编辑模式)。
*
* 踩过的坑:一开始想"只在像列表行时才拉",写了个 `needsDetail()` 判据
* (看 `address === addrCity`)。实测**判错了** ——
* 爸爸那条列表行里 `address` 是**空串**、只有 `addrCity='菏泽市'`,
* 判据走不到分支 ⇒ 不拉详情 ⇒ 地址框永远停在"菏泽市",
* 用户保存就把真实住址("工业路与曙光路交叉口…")写没了。
* 见日志:`needsDetail id=11 addr= city=菏泽市 prov= => NO`。
*
* ⇒ 干脆**不猜**:编辑表单本来就应该以服务端最新值为准(别人可能刚改过),
* 拉一次 `GET /people/<id>` 只有几十毫秒,而且用户这时已经在看表单了。
* 拉回来的值与手上这份完全一样时,下面的合并逻辑是幂等空操作,没副作用。
*
* ⚠️ 列表行缺的不只是地址:`contacts` / `jobs` 全是空数组,而服务端
* `replaceContacts` / `PersonJob::replace` 是**整组替换**
* ⇒ 不补拉就会"随手改个姓名、把补充电话和工作经历全清空"。
* 这是比地址更严重的一条,同样靠这次无条件补拉兜住。
*/
/* 这里**不加日志**:打包后 hilog 一行行刷的是噪音,
真要排查时临时加一行、查完删掉(本轮就是这么定位到
`needsDetail` 判据错、`@Builder` 值参数不刷新的)。 */
this.detailTask = this.loadDetail(p.id);
}
/**
* 异步补拉详情并**合并**进表单(不打断用户已经在打的字)。
*
* 🔴 为什么是"合并"而不是"整表重填":
* 详情在飞行的那几百毫秒里,用户可能已经开始改姓名/备注/网址。
* 整表重填会把他刚打的字冲掉 —— 那是比"地址不全"严重得多的体验问题。
* ⇒ 只补**用户还没碰过**的那几块:
* · 地址:`fAddress === origAddress`(没改)才用详情的;
* · 补充联系方式:四组都空、主网址也空(没动)才用详情的。
* ⚠️ 用户先动手改了 ⇒ 以用户为准(他知道自己在写什么)。
*
* 🔴 为什么补充联系方式也必须补:
* 从列表行进来时 `contacts` 是 `{phone:[],email:[],address:[],url:[]}`
* (`rowToPerson` 写死的空组)。服务端 `replaceContacts` 是**整组替换**语义
* ⇒ 用户只要在主网址框里填一个字,保存时就会把这个人原有的
* 补充电话/邮箱/地址**全部删掉**。这是本轮验证时一并发现的隐患。
*
* ⚠️ 拉失败时 `detailLoaded` **保持 false**(不是置 true)——
* 它代表"手上有服务端权威值",置 true 就会让残行数据被当真。
* 详见 `loadDetail()` 里那段说明。
*/
private async loadDetail(id: number): Promise<void> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
return; // 同上:没有令牌也保持 detailLoaded=false
}
const got: FbRosterPerson | null = await FbPersonRepo.inst().fetchDetail(id, token);
if (got === null) {
/* 🔴 拉不到(断网 / 令牌过期)⇒ `detailLoaded` **保持 false**。
为什么不能置 true:这个标记的语义是"手上有服务端权威值",
置 true 就等于告诉 `save()`「残行数据可以信」——
那样一次保存就会把地址写成城市名(正是本轮要修的损坏)。
⚠️ 那用户改的新地址会不会存不进去?不会:
`save()` 判的是 `!addrTouched && !detailLoaded` 才跳过,
用户**改过**地址(addrTouched=true)照样会提交。
⚠️ 子表(contacts/jobs)同理:没动过就不发,动过才发。
⇒ 拉失败时既能保存、又绝不写坏,代价只是"这次没把详情补全"。 */
return;
}
/* ⚠️ 赋给一个**非空类型**的局部变量再用:
ArkTS 的类型窄化不认"null 提前 return"之后的 `got`(会一路报
`'got' is possibly 'null'`,实测 20 条报错),必须显式断言一次。 */
const full: FbRosterPerson = got as FbRosterPerson;
this.applyDetail(full);
this.detailLoaded = true;
}
/** 把详情合并进表单(**只覆盖用户没动过的部分**) */
private applyDetail(full: FbRosterPerson): void {
// ---- 地址:只在用户没动过时覆盖 ----
if (this.fAddress === this.origAddress) {
this.fAddress = this.joinAddress(full);
this.origAddress = this.fAddress;
this.origAddressCol = full.address;
this.fAddrCountry = full.addrCountry;
this.fAddrProvince = full.addrProvince;
this.fAddrCity = full.addrCity;
this.fAddrDistrict = full.addrDistrict;
} else {
/* 用户已经改过地址 ⇒ 保留他的输入。
⚠️ 但 `origAddressCol` **要换成详情的真值**:这是"没改"分支回传
`address` 列用的基线,若还是列表行那份(= 城市),
用户改完又改回原样时会把 `address` 写成城市名。 */
this.origAddressCol = full.address;
}
// ---- 标量字段:逐项只在用户没动过时用详情的真值补 ----
/* 🔴 这一块是**修数据损坏**,不只是"补全显示":
列表行里 `birth_rank` / `birthday_lunar` / `birthday_lunar_leap` 等
根本没有(`rowToPerson` 写死成空/false),`fillFrom()` 把它们填成空
⇒ 用户随手保存一次就把服务端的真值**写空了**。
实测:爸爸的 `birth_rank` 从 2 变成 NULL。
⚠️ 比对用 `snap`(初值快照):当前值 === 初值 ⇒ 用户没碰 ⇒ 可以用详情的。 */
if (this.fGender === this.snap.gender && full.gender !== '') {
this.fGender = full.gender;
}
if (this.fBirthRank === this.snap.birthRank && full.birthRank !== '') {
this.fBirthRank = full.birthRank;
}
if (this.fBirthdaySolar === this.snap.bdSolar && full.birthdayFull !== '') {
this.fBirthdaySolar = full.birthdayFull;
}
if (this.fBirthdayLunar === this.snap.bdLunar && full.birthdayLunarRaw !== '') {
this.fBirthdayLunar = full.birthdayLunarRaw;
}
if (this.fLunarLeap === this.snap.lunarLeap) {
this.fLunarLeap = full.birthdayLunarLeap;
}
if (this.fPhone === this.snap.phone && full.phone !== '') {
this.fPhone = full.phone;
}
if (this.fOrg === this.snap.org && full.org !== '') {
this.fOrg = full.org;
}
if (this.fTitle === this.snap.title && full.title !== '') {
this.fTitle = full.title;
}
if (this.fSlogan === this.snap.slogan && full.slogan !== '') {
this.fSlogan = full.slogan;
}
if (this.fEmail === this.snap.email && full.email !== '') {
this.fEmail = full.email;
}
if (this.fNickname === this.snap.nickname && full.myNickname !== '') {
this.fNickname = full.myNickname;
}
if (this.fNote === this.snap.note && full.note !== '') {
this.fNote = full.note;
}
if (this.fMyNote === this.snap.myNote && full.myNote !== '') {
this.fMyNote = full.myNote;
}
/* `is_placeholder`(虚拟人物标记)**没有**反映到 `FbRosterPerson` 上
—— 那个 interface 里根本没有这个字段(见 FbPersonRepo 的定义)。
⇒ 这里不能从详情里取,只能保持回填时的值(`fillFrom` 里也是 false)。
这是既有缺陷(编辑界面看不出谁是虚拟人物),不在本轮范围内,
但**不能**在这里瞎猜一个字段名 —— 编译会直接报不存在。 */
if (this.fTags.join('\u0002') === this.snap.tags && full.tags.length > 0) {
this.fTags = [];
for (const tg of full.tags) {
this.fTags.push(tg);
}
}
// ---- 补充联系方式 / 工作经历:**逐组**只在用户没动过时回填 ----
if (this.rowsSig(this.cPhones) === this.origPhonesSig) {
this.cPhones = this.toEditRows(full.contacts.phone, 'ph');
}
if (this.rowsSig(this.cEmails) === this.origEmailsSig) {
this.cEmails = this.toEditRows(full.contacts.email, 'em');
}
if (this.rowsSig(this.cAddresses) === this.origAddrsSig) {
this.cAddresses = this.toEditRows(full.contacts.address, 'ad');
}
if (this.rowsSig(this.cUrls) + '|' + this.fUrl === this.origUrlsSig) {
this.fUrl = full.contacts.url.length > 0 ? full.contacts.url[0].value : '';
const urlRest: FbContactRow[] = [];
for (let i = 1; i < full.contacts.url.length; i++) {
urlRest.push(full.contacts.url[i]);
}
this.cUrls = this.toEditRows(urlRest, 'ur');
}
if (this.jobsSig() === this.origJobsSig) {
this.jobs = [];
for (const j of full.jobs) {
this.jobs.push(this.toEditJob(j));
}
}
/* `hadContacts` 取**并集**(服务端原本有没有):
原本有 ⇒ 提交时必须整组发(否则删不掉、也改不了);
原本没有 + 用户也没填 ⇒ 不必发(避免误清空,见 save() 的说明)。 */
this.hadContacts = this.hadContacts || (full.contacts.phone.length > 0
|| full.contacts.email.length > 0 || full.contacts.address.length > 0
|| full.contacts.url.length > 0);
// 回填后重算基线 —— 之后用户再动就是"真的动了"
this.snapGroups();
this.snapScalars();
}
/** 把一个人的字段灌进表单(新建时不会走到这里,见 aboutToAppear) */
private fillFrom(p: FbRosterPerson): void {
this.fName = p.name; this.fName = p.name;
this.fGender = p.gender; this.fGender = p.gender;
this.fBirthRank = p.birthRank; this.fBirthRank = p.birthRank;
@@ -262,7 +600,16 @@ export struct FbPersonEditSheet {
this.fTitle = p.title; this.fTitle = p.title;
this.fSlogan = p.slogan; this.fSlogan = p.slogan;
this.fEmail = p.email; this.fEmail = p.email;
this.fAddress = p.address; /* 地址:一个输入框装全部(见 fAddress 的说明)。
⚠️ 旧数据常常是"五级结构化 + `address` 只有街道"或反过来,
⇒ 两边都拼上(结构化在前、详细在后),用户一眼看到完整住址;
真正提交时**原样不动**就保留原来的五级(见 save())。 */
this.fAddress = this.joinAddress(p);
/* 🔴 记下"回填时的原值" —— 保存时拿它比对,判断用户到底改没改地址。
没改 ⇒ 原样回传旧的五级(不动网页端住址下拉的数据);
改了 ⇒ 整串写进 `address`、五级清空(用户写的就是一条自由文本)。 */
this.origAddress = this.fAddress;
this.origAddressCol = p.address;
this.fAddrCountry = p.addrCountry; this.fAddrCountry = p.addrCountry;
this.fAddrProvince = p.addrProvince; this.fAddrProvince = p.addrProvince;
this.fAddrCity = p.addrCity; this.fAddrCity = p.addrCity;
@@ -283,12 +630,71 @@ export struct FbPersonEditSheet {
this.cPhones = this.toEditRows(p.contacts.phone, 'ph'); this.cPhones = this.toEditRows(p.contacts.phone, 'ph');
this.cEmails = this.toEditRows(p.contacts.email, 'em'); this.cEmails = this.toEditRows(p.contacts.email, 'em');
this.cAddresses = this.toEditRows(p.contacts.address, 'ad'); this.cAddresses = this.toEditRows(p.contacts.address, 'ad');
this.cUrls = this.toEditRows(p.contacts.url, 'ur'); /* 网址:**第一条**抬成主输入框(见 fUrl 的说明),剩下的才是补充行。
服务端存的是一组 `kind='url'`,没有"主"的概念 ⇒ 这里取第一条当主,
提交时由 `urlsOut()` 把主网址拼回组里第一位,顺序不乱。
⚠️ 用循环取"从第二条起",不用 `slice(1)` —— 后者在 ArkTS 里
配合 `: []` 三元表达式容易触发类型推断报错,循环最稳。 */
this.fUrl = p.contacts.url.length > 0 ? p.contacts.url[0].value : '';
const urlRest: FbContactRow[] = [];
for (let i = 1; i < p.contacts.url.length; i++) {
urlRest.push(p.contacts.url[i]);
}
this.cUrls = this.toEditRows(urlRest, 'ur');
/* 🔴 记下"这个人原来有没有补充联系方式"。
为什么需要:`save()` 里"全空就不发 contacts"是为了防止**误清空**,
但这样一来"用户主动删掉仅有的那条网址/邮箱"就删不掉了
(全空 ⇒ 不发 ⇒ 服务端保留旧值)。有这个标记就能区分
"本来就是空的(不该发)"和"刚被我删空的(必须发,才删得掉)"。 */
this.hadContacts = p.contacts.phone.length > 0 || p.contacts.email.length > 0
|| p.contacts.address.length > 0 || p.contacts.url.length > 0;
// 工作经历 // 工作经历
this.jobs = []; this.jobs = [];
for (const j of p.jobs) { for (const j of p.jobs) {
this.jobs.push(this.toEditJob(j)); this.jobs.push(this.toEditJob(j));
} }
// ---- 记下各组的签名(详情回填时逐组比对,见 origPhonesSig 的说明)----
this.snapGroups();
this.snapScalars();
}
/** 记下全部标量字段的初值(见 FbEditSnap 的注释) */
private snapScalars(): void {
const s: FbEditSnap = {
name: this.fName, gender: this.fGender, birthRank: this.fBirthRank,
bdSolar: this.fBirthdaySolar, bdLunar: this.fBirthdayLunar, lunarLeap: this.fLunarLeap,
phone: this.fPhone, org: this.fOrg, title: this.fTitle, slogan: this.fSlogan,
email: this.fEmail, nickname: this.fNickname, note: this.fNote, myNote: this.fMyNote,
placeholder: this.fPlaceholder, tags: this.fTags.join('\u0002')
};
this.snap = s;
}
/** 把当前四组联系方式 + 工作经历算成签名存起来 */
private snapGroups(): void {
this.origPhonesSig = this.rowsSig(this.cPhones);
this.origEmailsSig = this.rowsSig(this.cEmails);
this.origAddrsSig = this.rowsSig(this.cAddresses);
this.origUrlsSig = this.rowsSig(this.cUrls) + '|' + this.fUrl;
this.origJobsSig = this.jobsSig();
}
/** 一组联系方式的签名(`label\u0001value` 按顺序拼,一眼比对) */
private rowsSig(rows: FbEditContactRow[]): string {
const out: string[] = [];
for (const r of rows) {
out.push(r.label + '\u0001' + r.value);
}
return out.join('\u0002');
}
/** 工作经历的签名 */
private jobsSig(): string {
const out: string[] = [];
for (const j of this.jobs) {
out.push([j.org, j.title, j.rankName, j.startYm, j.endYm, j.note].join('\u0001'));
}
return out.join('\u0002');
} }
private nextKey(prefix: string): string { private nextKey(prefix: string): string {
@@ -296,6 +702,36 @@ export struct FbPersonEditSheet {
return prefix + '_' + this.seq.toString(); return prefix + '_' + this.seq.toString();
} }
/**
* 把"结构化五级 + 详细地址"拼成**一行可读地址**(回填进唯一的地址输入框)。
*
* ⚠️ 去重很重要:旧数据里 `addr_city` 与 `address` 常常是重复的
* (网页端保存时两处都写了"青岛市黄岛区…"),不去重会读出
* "中国 山东省 青岛市 黄岛区 青岛市黄岛区xx路" 这种叠字。
* ⇒ 用**包含判断**:已经出现过的片段就跳过。
*/
private joinAddress(p: FbRosterPerson): string {
const parts: string[] = [p.addrCountry, p.addrProvince, p.addrCity, p.addrDistrict, p.address];
const out: string[] = [];
for (const raw of parts) {
const s: string = raw.trim();
if (s === '') {
continue;
}
let dup: boolean = false;
for (const kept of out) {
if (kept === s || kept.indexOf(s) >= 0 || s.indexOf(kept) >= 0) {
dup = true;
break;
}
}
if (!dup) {
out.push(s);
}
}
return out.join(' ');
}
/** 接口行 → 编辑行(带稳定 key) */ /** 接口行 → 编辑行(带稳定 key) */
private toEditRows(rows: FbContactRow[], prefix: string): FbEditContactRow[] { private toEditRows(rows: FbContactRow[], prefix: string): FbEditContactRow[] {
const out: FbEditContactRow[] = []; const out: FbEditContactRow[] = [];
@@ -548,46 +984,85 @@ export struct FbPersonEditSheet {
} }
/** /**
* 分组小标题(把一大堆字段分开,免得糊成一团)。 * **地址**字段(单行,直接读 `this.fAddress`)。
* *
* 🔴 2026-11-06 用户:「性别、颜色样式不对;联系方式不对;地址不对; * 🔴 为什么不复用 `field('地址', this.fAddress, …)`:
* 工作不对;其他不对;标签不对;备注不对;我的备注也不对。」 * `@Builder` 的**值参数不参与状态观察** —— 这是本工程踩过 5 次的老坑。
* 地址是**唯一**会被"异步补拉详情"改写的字段(见 `loadDetail`):
* 从名册列表点编辑时,先显示的是列表行拼出来的残地址("菏泽市"),
* 几百毫秒后详情回来、`fAddress` 变成完整住址 ——
* 而值参数那一份**永远是首次渲染的字面量** ⇒ 框里一直停在"菏泽市"。
* (实测就是这么表现:`loadDetail` 明明跑了,界面一个字没变。)
* ⇒ 这里写成**内部直读字段**的专用构建器,依赖才建得上。
* *
* 逐条核对下来,"不对"的是**所有分组的视觉层级**,不是内容: * ⚠️ 网址同理,见 `urlField()`。
* · 分组标题原来是 `fontTiny + FbColors.faint`(10-11vp 淡灰);
* · 而字段标签(`field()`)是 `fontSmall + Bold + 主题色`。
* ⇒ 结果:**淡灰的"联系方式"比主题色加粗的"手机号(主号)"还弱**,
* 分组层级被压到字段之下,整张表看起来是一堆同级小字糊在一起,
* 完全看不出"哪几个字段属于哪一组"。
*
* ⇒ 按 GLOBAL-FORM-SPEC 把分组标题抬成与字段标签**同级但更强**:
* 主题色 + Bold + fontBody(比字段标签大一号)+ 顶部留白拉开距离。
* 「级别标题 > 字段标签 > 输入值」三层,一眼能读。
*
* ⚠️ 顺带:「基本」这个标题**删掉了**(见 build 里的调用点)。
* 它下面紧跟着"姓名 / 称呼 / 性别 / 排行 / 生日",本来就属于最基础的一组,
* 而它是**表格的第一个分组**——上面没有任何内容需要与它区分,
* 挂一个"基本"只会让用户多读一个词。用户原话:「"基本"这两个字也不要。」
*/ */
@Builder @Builder
section(title: string) { addressField() {
Row({ space: 6 }) { Column({ space: 6 }) {
/* 左侧一小段主题色竖条:比纯文字更像"分组",且不抢眼 */ Text('地址')
Column() .fontSize(FbMetric.fontSmall)
.width(3)
.height(13)
.borderRadius(2)
.backgroundColor(this.accentColor())
Text(title)
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Bold) .fontWeight(FontWeight.Bold)
.fontColor(this.accentColor()) .fontColor(this.accentColor())
.width('100%')
TextInput({ text: this.fAddress, placeholder: '国家 省 市 区县 详细地址' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.width('100%')
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.fAddress = v;
})
} }
.width('100%') .width('100%')
.alignItems(VerticalAlign.Center) .alignItems(HorizontalAlign.Start)
.margin({ top: 14, bottom: 2 })
} }
/**
* **网址**字段(直接读 `this.fUrl`)。
*
* 🔴 与 `addressField()` 同一个理由:`fUrl` 也会被 `loadDetail` 异步回填
* (列表行不带 `contacts`,网址只能等详情)。值参数版刷不出来。
* ⚠️ 版式与 `field()` 完全一致(主题色/Bold/fontSmall 标签 + 整宽输入框),
* 用户看到的仍然是"跟邮箱一模一样的网址栏"。
*/
@Builder
urlField() {
Column({ space: 6 }) {
Text('网址')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
TextInput({ text: this.fUrl, placeholder: '如 https://…' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.width('100%')
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.fUrl = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 分组小标题 `section()` 在 v93 里**整个删掉了** ——
用户 2026-11-06:「不需要分多级,就是一层。」
上一轮它是 `fontBody + Bold + 主题色 + 左侧竖条`(为了修"分组标题比字段
标签还弱"),这一轮用户直接不要分组了 ⇒ 留着就是没人调用的死代码。
⚠️ 若以后某个表单又要分组,抄 GLOBAL-FORM-SPEC 的两排版式重写一份即可,
别再退回 `fontTiny + faint`(那正是上一轮被判"不对"的写法)。 */
/** 性别选择(两段) */ /** 性别选择(两段) */
@Builder @Builder
genderPick() { genderPick() {
@@ -904,7 +1379,13 @@ export struct FbPersonEditSheet {
} }
.width('100%') .width('100%')
this.section('联系方式') /* ════════════════════════════════════════════════════════
🔴 2026-11-06 用户:「联系方式,这一句话去掉吧。……工作这个也去掉,
直接写单位和职务;其他也去掉,直接写座右铭就可以了。
**不需要分多级,就是一层。**」
⇒ 从这里往下**没有任何分组标题**,字段按"人读资料"的顺序平铺。
(原来的 `this.section('联系方式'/'地址'/'工作'/'其它')` 全删)
════════════════════════════════════════════════════════ */
this.field('手机号(主号)', this.fPhone, '登录用;留空表示没录', (v: string): void => { this.field('手机号(主号)', this.fPhone, '登录用;留空表示没录', (v: string): void => {
this.fPhone = v; this.fPhone = v;
}) })
@@ -919,6 +1400,7 @@ export struct FbPersonEditSheet {
this.addRowBtn('补充电话', (): void => { this.addRowBtn('补充电话', (): void => {
this.cPhones.push({ key: this.nextKey('ph'), label: '', value: '' }); this.cPhones.push({ key: this.nextKey('ph'), label: '', value: '' });
}) })
this.field('邮箱', this.fEmail, '如 a@b.com', (v: string): void => { this.field('邮箱', this.fEmail, '如 a@b.com', (v: string): void => {
this.fEmail = v; this.fEmail = v;
}) })
@@ -932,6 +1414,15 @@ export struct FbPersonEditSheet {
this.addRowBtn('补充邮箱', (): void => { this.addRowBtn('补充邮箱', (): void => {
this.cEmails.push({ key: this.nextKey('em'), label: '', value: '' }); this.cEmails.push({ key: this.nextKey('em'), label: '', value: '' });
}) })
/* 🔴 网址(用户 2026-11-06):「网址这个地方应该跟邮箱一样,是有一个标签的。
如果现在没有,也跟邮箱一样,得有一个空的输入框。」
⇒ 这里**常驻**一个"网址"标签 + 空输入框,与邮箱完全同一个版式;
没有网址时它就是空的、也看得见(原来只在有数据时才冒出一行)。
⚠️ 用 `urlField()` 而不是 `field('网址', this.fUrl, …)`:
它会被异步详情回填,值参数那版刷不出来(见 urlField 的说明)。
⚠️ 下面的补充网址只在**多于一条**时才出现(第一条已在上面)。 */
this.urlField()
if (this.cUrls.length > 0) { if (this.cUrls.length > 0) {
ForEach(this.cUrls, (row: FbEditContactRow) => { ForEach(this.cUrls, (row: FbEditContactRow) => {
this.contactRow(row, '网址', (): void => { this.contactRow(row, '网址', (): void => {
@@ -939,67 +1430,21 @@ export struct FbPersonEditSheet {
}) })
}, (row: FbEditContactRow): string => row.key) }, (row: FbEditContactRow): string => row.key)
} }
this.addRowBtn('网址', (): void => { this.addRowBtn('补充网址', (): void => {
this.cUrls.push({ key: this.nextKey('ur'), label: '', value: '' }); this.cUrls.push({ key: this.nextKey('ur'), label: '', value: '' });
}) })
this.section('地址') /* 🔴 地址(用户 2026-11-06):「地址的格式应该跟上面的邮箱、手机号保持一致。
this.field('国家', this.fAddrCountry, '如 中国', (v: string): void => { 国家、省市、区县、详细地址这些东西,就放在那个输入框里
this.fAddrCountry = v; **作为 placeholder** 就可以了。」
}) ⇒ 五级结构化输入框(国家 / 省 / 市 / 区县 / 详细)**合并成一个**,
Row({ space: 8 }) { 层级顺序写在 placeholder 里当提示,用户照着一行写下来即可。
Column({ space: 4 }) { ⚠️ placeholder 刻意只列层级、不加"如中国山东省…"这种长示例:
/* 省/市是**字段标签**(并排两列,所以不能独占整宽)—— 单行输入框放不下,会被截成半句(本项目在别处踩过"占位被裁")。
样式与 field() 一致(主题色/Bold/fontSmall), ⚠️ 用 `addressField()` 而不是 `field(...)`:会被异步详情回填。
不整宽是因为它们左右并排、各自占一半。 */ ⚠️ 旧的结构化值不显示、但会原样回传(见 fAddrCountry 的说明),
Text('省') 所以"没动地址"的人不会被清掉网页端住址下拉。 */
.fontSize(FbMetric.fontSmall) this.addressField()
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
TextInput({ text: this.fAddrProvince, placeholder: '山东省' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.fAddrProvince = v;
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
/* 省/市是**字段标签**(并排两列,所以不能独占整宽)——
样式与 field() 一致(主题色/Bold/fontSmall),
不整宽是因为它们左右并排、各自占一半。 */
Text('市')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
TextInput({ text: this.fAddrCity, placeholder: '青岛市' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.fAddrCity = v;
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
this.field('区/县', this.fAddrDistrict, '如 黄岛区', (v: string): void => {
this.fAddrDistrict = v;
})
this.field('详细地址', this.fAddress, '街道门牌', (v: string): void => {
this.fAddress = v;
})
if (this.cAddresses.length > 0) { if (this.cAddresses.length > 0) {
ForEach(this.cAddresses, (row: FbEditContactRow) => { ForEach(this.cAddresses, (row: FbEditContactRow) => {
this.contactRow(row, '其它地址', (): void => { this.contactRow(row, '其它地址', (): void => {
@@ -1007,11 +1452,10 @@ export struct FbPersonEditSheet {
}) })
}, (row: FbEditContactRow): string => row.key) }, (row: FbEditContactRow): string => row.key)
} }
this.addRowBtn('其它地址', (): void => { this.addRowBtn('补充地址', (): void => {
this.cAddresses.push({ key: this.nextKey('ad'), label: '', value: '' }); this.cAddresses.push({ key: this.nextKey('ad'), label: '', value: '' });
}) })
this.section('工作')
this.field('单位', this.fOrg, '如 中国海洋大学', (v: string): void => { this.field('单位', this.fOrg, '如 中国海洋大学', (v: string): void => {
this.fOrg = v; this.fOrg = v;
}) })
@@ -1032,7 +1476,6 @@ export struct FbPersonEditSheet {
}); });
}) })
this.section('其它')
this.field('座右铭', this.fSlogan, '一句话(只有本人能改)', (v: string): void => { this.field('座右铭', this.fSlogan, '一句话(只有本人能改)', (v: string): void => {
this.fSlogan = v; this.fSlogan = v;
}) })
@@ -1282,6 +1725,28 @@ export struct FbPersonEditSheet {
} }
} }
/**
* 网址**整组**(主网址在前 + 补充网址)。
*
* 🔴 为什么不是 `rowsOut(this.cUrls)`(用户 2026-11-06):
* 这一轮把网址抬成了"跟邮箱一样的主字段"(`fUrl`),
* 而服务端存的仍然是一组 `kind='url'` ⇒ 主网址要**拼回组里第一位**,
* 否则用户填在主输入框里的那条**根本不会提交**。
* ⚠️ 主网址为空时也不硬塞:`rowsOut` 本来就跳过空值,这里先判一下更清楚。
*/
private urlsOut(): FbOutContactRow[] {
const out: FbOutContactRow[] = [];
const main: string = this.fUrl.trim();
if (main !== '') {
const head: FbOutContactRow = { label: '', value: main };
out.push(head);
}
for (const r of this.rowsOut(this.cUrls)) {
out.push(r);
}
return out;
}
/** 把编辑行转成接口要的形状(丢掉全空的行) */ /** 把编辑行转成接口要的形状(丢掉全空的行) */
private rowsOut(rows: FbEditContactRow[]): FbOutContactRow[] { private rowsOut(rows: FbEditContactRow[]): FbOutContactRow[] {
const out: FbOutContactRow[] = []; const out: FbOutContactRow[] = [];
@@ -1468,6 +1933,19 @@ export struct FbPersonEditSheet {
this.busy = true; this.busy = true;
this.err = ''; this.err = '';
/* 🔴 先等详情补拉完成(见 `detailTask` 的说明)。
列表行缺 `birth_rank` / 工作经历 / 补充联系方式,
不等就提交 = 一次保存把这些字段写空(实测踩过)。
⚠️ 只等一次:`loadDetail` 自己会兜异常(拉失败也置 `detailLoaded`),
所以这里 await 不会挂死;`busy` 期间按钮也点不动。
⚠️ 等待期间的合并逻辑是"用户动过的以用户为准",不会冲掉他已输入的内容。 */
if (this.detailTask !== null && !this.isCreate) {
try {
await this.detailTask;
} catch (err) {
hilog.warn(DOMAIN, TAG, '等待详情失败(不阻断保存):%{public}s', JSON.stringify(err));
}
}
const payload: FbPersonEditBody = {}; const payload: FbPersonEditBody = {};
payload['name'] = name; payload['name'] = name;
payload['gender'] = this.fGender; payload['gender'] = this.fGender;
@@ -1480,27 +1958,84 @@ export struct FbPersonEditSheet {
payload['title'] = this.fTitle.trim(); payload['title'] = this.fTitle.trim();
payload['slogan'] = this.fSlogan; payload['slogan'] = this.fSlogan;
payload['email'] = this.fEmail.trim(); payload['email'] = this.fEmail.trim();
payload['address'] = this.fAddress.trim(); /* ---- 地址(一个输入框 → 两种落库方式)----
payload['addr_country'] = this.fAddrCountry.trim(); 🔴 2026-11-06 起地址只剩一个输入框,但服务端仍是"五级结构化 + 详细"两处:
payload['addr_province'] = this.fAddrProvince.trim(); · 用户**没动**地址 ⇒ 原样回传旧的 `address` 列 + 旧五级(一个字都不改);
payload['addr_city'] = this.fAddrCity.trim(); · 用户**改了**地址 ⇒ 整串写进 `address`,五级置空
payload['addr_district'] = this.fAddrDistrict.trim(); (否则详情页会显示成"旧五级 + 新整串"叠两遍)。
⚠️ 判"改没改"必须拿**回填时的拼接串**比(`origAddress`),
不能拿 `fAddress` 比 `p.address` —— 后者本来就不等于拼接串。
🔴🔴 还有第三种情况:**手上这份不是详情、详情还没拉回来、用户也没改**
(见 `detailLoaded` 的长注释)。这时地址串是"菅泽市"这种残行拼出来的,
写回去就是把真实住址("工业路与曙光路交叉口…")覆盖掉。
⇒ 一个地址字段都**不提交** —— 服务端"没传就沿用旧值",
这次保存不碰地址,比写坏安全得多。 */
const addrNow: string = this.fAddress.trim();
const addrTouched: boolean = addrNow !== this.origAddress;
if (!addrTouched && !this.detailLoaded && !this.isCreate) {
/* 详情未到 + 用户没动 ⇒ 整段跳过地址 */
} else if (addrNow === '') {
// 用户把地址清空了:两处都清(这是明确的删除意图)
payload['address'] = '';
payload['addr_country'] = '';
payload['addr_province'] = '';
payload['addr_city'] = '';
payload['addr_district'] = '';
} else if (!addrTouched) {
// 没改:原样回传,五级一个字不动(保网页端住址下拉)
payload['address'] = this.origAddressCol;
payload['addr_country'] = this.fAddrCountry.trim();
payload['addr_province'] = this.fAddrProvince.trim();
payload['addr_city'] = this.fAddrCity.trim();
payload['addr_district'] = this.fAddrDistrict.trim();
} else {
// 改了:整串进 `address`(这列是 VARCHAR(200),服务端会截断),五级清空
payload['address'] = addrNow;
payload['addr_country'] = '';
payload['addr_province'] = '';
payload['addr_city'] = '';
payload['addr_district'] = '';
}
payload['note'] = this.fNote; payload['note'] = this.fNote;
payload['my_note'] = this.fMyNote; payload['my_note'] = this.fMyNote;
payload['my_nickname'] = this.fNickname.trim(); payload['my_nickname'] = this.fNickname.trim();
payload['is_placeholder'] = this.fPlaceholder; payload['is_placeholder'] = this.fPlaceholder;
payload['tags'] = this.tagsOut(); payload['tags'] = this.tagsOut();
payload['jobs'] = this.jobsOut(); /* ---- 工作经历:`PersonJob::replace()` 是**整组替换**(该接口无条件调用)
🔴 只有在"用户动过"或"新建"时才发这个键 ——
否则列表行进编辑(`jobs` 是空数组)随手改个姓名保存,
这个人的全部工作经历就被清空了。
⚠️ 详情回来后 `jobs` 是服务端真值、基线也同步重算 ⇒ 没动就不发,
服务端原样保留,等价且更安全(少一次无意义的 DELETE+INSERT)。
⚠️ 用户动过(加了/删了/改了任一行)⇒ 必须发,那是他的明确意图。 */
const jobsTouched: boolean = this.jobsSig() !== this.origJobsSig;
if (this.isCreate || jobsTouched) {
payload['jobs'] = this.jobsOut();
}
// 补充联系方式:按类型分组 // 补充联系方式:按类型分组
const contacts: FbOutContacts = { const contacts: FbOutContacts = {
phone: this.rowsOut(this.cPhones), phone: this.rowsOut(this.cPhones),
email: this.rowsOut(this.cEmails), email: this.rowsOut(this.cEmails),
address: this.rowsOut(this.cAddresses), address: this.rowsOut(this.cAddresses),
url: this.rowsOut(this.cUrls) // ⚠️ 网址走 `urlsOut()`(主网址 + 补充网址),不是直接 rowsOut(cUrls)
url: this.urlsOut()
}; };
// ⚠️ 逐类都为空时**不发** contacts —— 发一个"全空"会把人家的补充电话清掉; /* ⚠️ 逐类都为空时**通常不发** contacts —— 发一个"全空"会把人家的补充电话清掉。
// 只在用户确实动过(有内容)时才发(服务端是"整组替换"语义)。 🔴 但下面几种情况**必须发**,否则要么删不掉、要么清空别人的数据:
if (this.hasAnyContact()) { ① 用户确实有内容(正常提交);
② 进来时**原本有条目**、现在被删空了 ⇒ 这是"删除"意图,
不发就删不掉(服务端是"没传就沿用旧值",整组替换时也是"没传就跳过")。
③ **用户动过任意一组**(加了一行、改了一个字)⇒ 整组替换语义下
必须把完整四组发过去,否则他没动的那几组会被当成空。
⚠️ 都不是(详情没到 + 一个字没动)⇒ **不发** ——
列表行进编辑时四组都是空的,发过去就把真实的补充电话全删了。
⚠️ 原来只判 ①,于是"删掉仅有的那条网址"永远删不掉 —— 用户会反复试。 */
const contactsTouched: boolean =
this.rowsSig(this.cPhones) !== this.origPhonesSig
|| this.rowsSig(this.cEmails) !== this.origEmailsSig
|| this.rowsSig(this.cAddresses) !== this.origAddrsSig
|| (this.rowsSig(this.cUrls) + '|' + this.fUrl) !== this.origUrlsSig;
if (this.isCreate || this.hasAnyContact() || this.hadContacts || contactsTouched) {
payload['contacts'] = contacts; payload['contacts'] = contacts;
} }
// 身份证:只在用户重填了才发(打码值不能回传) // 身份证:只在用户重填了才发(打码值不能回传)
@@ -1564,11 +2099,17 @@ export struct FbPersonEditSheet {
return 0; return 0;
} }
/** 四类补充联系方式里有没有任何内容 */ /**
* 四类补充联系方式里有没有任何内容。
*
* ⚠️ 网址这一路要跟着 `urlsOut()`(含主网址)——
* 否则"只在主输入框里填了网址、没加补充行"时这里返回 false,
* `contacts` 整组**不会提交**,用户填的网址就白填了。
*/
private hasAnyContact(): boolean { private hasAnyContact(): boolean {
return this.rowsOut(this.cPhones).length > 0 return this.rowsOut(this.cPhones).length > 0
|| this.rowsOut(this.cEmails).length > 0 || this.rowsOut(this.cEmails).length > 0
|| this.rowsOut(this.cAddresses).length > 0 || this.rowsOut(this.cAddresses).length > 0
|| this.rowsOut(this.cUrls).length > 0; || this.urlsOut().length > 0;
} }
} }