From 7d7973a319865a1122b6378a64903d3d91bf4338 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 6 Nov 2026 12:40:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8D=E5=86=8C=E7=BC=96=E8=BE=91=EF=BC=9A?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=8F=96=E6=B6=88=E5=88=86=E7=BB=84=E3=80=81?= =?UTF-8?q?=E7=BD=91=E5=9D=80=E8=A1=A5=E5=B8=B8=E9=A9=BB=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E3=80=81=E5=9C=B0=E5=9D=80=E5=90=88=E4=B8=80=EF=BC=88?= =?UTF-8?q?#92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 增加「原本有条目、现在删空」与「动过任意一组」两个 提交条件,否则删掉仅有的那条网址永远删不掉。 --- .../main/ets/components/FbPersonEditSheet.ets | 757 +++++++++++++++--- 1 file changed, 649 insertions(+), 108 deletions(-) diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index b16b352..796fec6 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -7,9 +7,16 @@ * * 于是这里覆盖了**网页端表单的全部字段**(`templates/people/form.php`): * 姓名 / 性别 / 家中排行 / 生日(阳历+农历+闰月) / 主手机号 / 补充电话 - * 单位 / 职务 / 座右铭 / 邮箱 / 网址 / 身份证 / 结构化地址五级 / 详细地址 + * 单位 / 职务 / 座右铭 / 邮箱 / 网址 / 身份证 / 地址 / 详细地址 * 公共备注 / **我的私人备注** / 标签 / 工作经历(多段) / 虚拟人物标记 * + * 🔴 v93(用户 2026-11-06):**整张表单取消分组标题,字段平铺一层**。 + * 「联系方式,这一句话去掉吧。……工作这个也去掉,直接写单位和职务; + * 其他也去掉,直接写座右铭就可以了。不需要分多级,就是一层。」 + * ⇒ 原来的「联系方式 / 地址 / 工作 / 其它」四个 `section()` 全部删掉, + * 字段按人读资料的顺序依次排下来。分组标题消失后表单反而更短, + * 因为每个标题本身就要占一行、还要带 14vp 上边距。 + * * ⚠️ 服务端同步扩过了(`PATCH /api/v1/people/` 收全字段), * 并且**没传的字段一律沿用旧值** ⇒ 本页"全量发一遍"也不会误清空。 * ⚠️ 头像走**单独接口**(`POST /people//avatar`,multipart), @@ -25,7 +32,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbColors, FbMetric, FB_MASK } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; 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 { FbApiOptions, FbApiResult, fbApi } from '../data/FbHttp'; import { fbUploadOne } from '../data/FbUpload'; @@ -105,6 +112,42 @@ interface FbEditJobRow { 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 export struct FbPersonEditSheet { /** 要编辑的人(null = 没打开 / 或"新建"模式,见 isCreate) */ @@ -177,11 +220,122 @@ export struct FbPersonEditSheet { @Local fTitle: string = ''; @Local fSlogan: 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 = ''; + /** + * 结构化地址的**原始值**(国家/省/市/区县)。 + * + * ⚠️ 它们**不再显示**,只在"用户没动过地址"时原样回传 —— + * 保有旧数据的结构化信息(网页端住址下拉还认它),不清库。 + * 一旦用户真的改了地址输入框,就整串写进 `address` 并把五级清空 + * (见 `save()` 里的比对)。 + */ @Local fAddrCountry: string = ''; @Local fAddrProvince: string = ''; @Local fAddrCity: 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 | 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 fNote: string = ''; /** @@ -250,6 +404,190 @@ export struct FbPersonEditSheet { if (p === null) { return; } + this.fillFrom(p); + /* 🔴 **无条件补拉一次详情**(编辑模式)。 + * + * 踩过的坑:一开始想"只在像列表行时才拉",写了个 `needsDetail()` 判据 + * (看 `address === addrCity`)。实测**判错了** —— + * 爸爸那条列表行里 `address` 是**空串**、只有 `addrCity='菏泽市'`, + * 判据走不到分支 ⇒ 不拉详情 ⇒ 地址框永远停在"菏泽市", + * 用户保存就把真实住址("工业路与曙光路交叉口…")写没了。 + * 见日志:`needsDetail id=11 addr= city=菏泽市 prov= => NO`。 + * + * ⇒ 干脆**不猜**:编辑表单本来就应该以服务端最新值为准(别人可能刚改过), + * 拉一次 `GET /people/` 只有几十毫秒,而且用户这时已经在看表单了。 + * 拉回来的值与手上这份完全一样时,下面的合并逻辑是幂等空操作,没副作用。 + * + * ⚠️ 列表行缺的不只是地址:`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 { + 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.fGender = p.gender; this.fBirthRank = p.birthRank; @@ -262,7 +600,16 @@ export struct FbPersonEditSheet { this.fTitle = p.title; this.fSlogan = p.slogan; 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.fAddrProvince = p.addrProvince; this.fAddrCity = p.addrCity; @@ -283,12 +630,71 @@ export struct FbPersonEditSheet { this.cPhones = this.toEditRows(p.contacts.phone, 'ph'); this.cEmails = this.toEditRows(p.contacts.email, 'em'); 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 = []; for (const j of p.jobs) { 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 { @@ -296,6 +702,36 @@ export struct FbPersonEditSheet { 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) */ private toEditRows(rows: FbContactRow[], prefix: string): FbEditContactRow[] { const out: FbEditContactRow[] = []; @@ -548,46 +984,85 @@ export struct FbPersonEditSheet { } /** - * 分组小标题(把一大堆字段分开,免得糊成一团)。 + * **地址**字段(单行,直接读 `this.fAddress`)。 * - * 🔴 2026-11-06 用户:「性别、颜色样式不对;联系方式不对;地址不对; - * 工作不对;其他不对;标签不对;备注不对;我的备注也不对。」 + * 🔴 为什么不复用 `field('地址', this.fAddress, …)`: + * `@Builder` 的**值参数不参与状态观察** —— 这是本工程踩过 5 次的老坑。 + * 地址是**唯一**会被"异步补拉详情"改写的字段(见 `loadDetail`): + * 从名册列表点编辑时,先显示的是列表行拼出来的残地址("菏泽市"), + * 几百毫秒后详情回来、`fAddress` 变成完整住址 —— + * 而值参数那一份**永远是首次渲染的字面量** ⇒ 框里一直停在"菏泽市"。 + * (实测就是这么表现:`loadDetail` 明明跑了,界面一个字没变。) + * ⇒ 这里写成**内部直读字段**的专用构建器,依赖才建得上。 * - * 逐条核对下来,"不对"的是**所有分组的视觉层级**,不是内容: - * · 分组标题原来是 `fontTiny + FbColors.faint`(10-11vp 淡灰); - * · 而字段标签(`field()`)是 `fontSmall + Bold + 主题色`。 - * ⇒ 结果:**淡灰的"联系方式"比主题色加粗的"手机号(主号)"还弱**, - * 分组层级被压到字段之下,整张表看起来是一堆同级小字糊在一起, - * 完全看不出"哪几个字段属于哪一组"。 - * - * ⇒ 按 GLOBAL-FORM-SPEC 把分组标题抬成与字段标签**同级但更强**: - * 主题色 + Bold + fontBody(比字段标签大一号)+ 顶部留白拉开距离。 - * 「级别标题 > 字段标签 > 输入值」三层,一眼能读。 - * - * ⚠️ 顺带:「基本」这个标题**删掉了**(见 build 里的调用点)。 - * 它下面紧跟着"姓名 / 称呼 / 性别 / 排行 / 生日",本来就属于最基础的一组, - * 而它是**表格的第一个分组**——上面没有任何内容需要与它区分, - * 挂一个"基本"只会让用户多读一个词。用户原话:「"基本"这两个字也不要。」 + * ⚠️ 网址同理,见 `urlField()`。 */ @Builder - section(title: string) { - Row({ space: 6 }) { - /* 左侧一小段主题色竖条:比纯文字更像"分组",且不抢眼 */ - Column() - .width(3) - .height(13) - .borderRadius(2) - .backgroundColor(this.accentColor()) - Text(title) - .fontSize(FbMetric.fontBody) + addressField() { + Column({ space: 6 }) { + Text('地址') + .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Bold) .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%') - .alignItems(VerticalAlign.Center) - .margin({ top: 14, bottom: 2 }) + .alignItems(HorizontalAlign.Start) } + /** + * **网址**字段(直接读 `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 genderPick() { @@ -904,7 +1379,13 @@ export struct FbPersonEditSheet { } .width('100%') - this.section('联系方式') + /* ════════════════════════════════════════════════════════ + 🔴 2026-11-06 用户:「联系方式,这一句话去掉吧。……工作这个也去掉, + 直接写单位和职务;其他也去掉,直接写座右铭就可以了。 + **不需要分多级,就是一层。**」 + ⇒ 从这里往下**没有任何分组标题**,字段按"人读资料"的顺序平铺。 + (原来的 `this.section('联系方式'/'地址'/'工作'/'其它')` 全删) + ════════════════════════════════════════════════════════ */ this.field('手机号(主号)', this.fPhone, '登录用;留空表示没录', (v: string): void => { this.fPhone = v; }) @@ -919,6 +1400,7 @@ export struct FbPersonEditSheet { this.addRowBtn('补充电话', (): void => { this.cPhones.push({ key: this.nextKey('ph'), label: '', value: '' }); }) + this.field('邮箱', this.fEmail, '如 a@b.com', (v: string): void => { this.fEmail = v; }) @@ -932,6 +1414,15 @@ export struct FbPersonEditSheet { this.addRowBtn('补充邮箱', (): void => { this.cEmails.push({ key: this.nextKey('em'), label: '', value: '' }); }) + + /* 🔴 网址(用户 2026-11-06):「网址这个地方应该跟邮箱一样,是有一个标签的。 + 如果现在没有,也跟邮箱一样,得有一个空的输入框。」 + ⇒ 这里**常驻**一个"网址"标签 + 空输入框,与邮箱完全同一个版式; + 没有网址时它就是空的、也看得见(原来只在有数据时才冒出一行)。 + ⚠️ 用 `urlField()` 而不是 `field('网址', this.fUrl, …)`: + 它会被异步详情回填,值参数那版刷不出来(见 urlField 的说明)。 + ⚠️ 下面的补充网址只在**多于一条**时才出现(第一条已在上面)。 */ + this.urlField() if (this.cUrls.length > 0) { ForEach(this.cUrls, (row: FbEditContactRow) => { this.contactRow(row, '网址', (): void => { @@ -939,67 +1430,21 @@ export struct FbPersonEditSheet { }) }, (row: FbEditContactRow): string => row.key) } - this.addRowBtn('网址', (): void => { + this.addRowBtn('补充网址', (): void => { this.cUrls.push({ key: this.nextKey('ur'), label: '', value: '' }); }) - this.section('地址') - this.field('国家', this.fAddrCountry, '如 中国', (v: string): void => { - this.fAddrCountry = v; - }) - Row({ space: 8 }) { - Column({ space: 4 }) { - /* 省/市是**字段标签**(并排两列,所以不能独占整宽)—— - 样式与 field() 一致(主题色/Bold/fontSmall), - 不整宽是因为它们左右并排、各自占一半。 */ - Text('省') - .fontSize(FbMetric.fontSmall) - .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; - }) + /* 🔴 地址(用户 2026-11-06):「地址的格式应该跟上面的邮箱、手机号保持一致。 + 国家、省市、区县、详细地址这些东西,就放在那个输入框里 + **作为 placeholder** 就可以了。」 + ⇒ 五级结构化输入框(国家 / 省 / 市 / 区县 / 详细)**合并成一个**, + 层级顺序写在 placeholder 里当提示,用户照着一行写下来即可。 + ⚠️ placeholder 刻意只列层级、不加"如中国山东省…"这种长示例: + 单行输入框放不下,会被截成半句(本项目在别处踩过"占位被裁")。 + ⚠️ 用 `addressField()` 而不是 `field(...)`:会被异步详情回填。 + ⚠️ 旧的结构化值不显示、但会原样回传(见 fAddrCountry 的说明), + 所以"没动地址"的人不会被清掉网页端住址下拉。 */ + this.addressField() if (this.cAddresses.length > 0) { ForEach(this.cAddresses, (row: FbEditContactRow) => { this.contactRow(row, '其它地址', (): void => { @@ -1007,11 +1452,10 @@ export struct FbPersonEditSheet { }) }, (row: FbEditContactRow): string => row.key) } - this.addRowBtn('其它地址', (): void => { + this.addRowBtn('补充地址', (): void => { this.cAddresses.push({ key: this.nextKey('ad'), label: '', value: '' }); }) - this.section('工作') this.field('单位', this.fOrg, '如 中国海洋大学', (v: string): void => { this.fOrg = v; }) @@ -1032,7 +1476,6 @@ export struct FbPersonEditSheet { }); }) - this.section('其它') this.field('座右铭', this.fSlogan, '一句话(只有本人能改)', (v: string): void => { 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[] { const out: FbOutContactRow[] = []; @@ -1468,6 +1933,19 @@ export struct FbPersonEditSheet { this.busy = true; 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 = {}; payload['name'] = name; payload['gender'] = this.fGender; @@ -1480,27 +1958,84 @@ export struct FbPersonEditSheet { payload['title'] = this.fTitle.trim(); payload['slogan'] = this.fSlogan; payload['email'] = this.fEmail.trim(); - payload['address'] = this.fAddress.trim(); - payload['addr_country'] = this.fAddrCountry.trim(); - payload['addr_province'] = this.fAddrProvince.trim(); - payload['addr_city'] = this.fAddrCity.trim(); - payload['addr_district'] = this.fAddrDistrict.trim(); + /* ---- 地址(一个输入框 → 两种落库方式)---- + 🔴 2026-11-06 起地址只剩一个输入框,但服务端仍是"五级结构化 + 详细"两处: + · 用户**没动**地址 ⇒ 原样回传旧的 `address` 列 + 旧五级(一个字都不改); + · 用户**改了**地址 ⇒ 整串写进 `address`,五级置空 + (否则详情页会显示成"旧五级 + 新整串"叠两遍)。 + ⚠️ 判"改没改"必须拿**回填时的拼接串**比(`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['my_note'] = this.fMyNote; payload['my_nickname'] = this.fNickname.trim(); payload['is_placeholder'] = this.fPlaceholder; 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 = { phone: this.rowsOut(this.cPhones), email: this.rowsOut(this.cEmails), address: this.rowsOut(this.cAddresses), - url: this.rowsOut(this.cUrls) + // ⚠️ 网址走 `urlsOut()`(主网址 + 补充网址),不是直接 rowsOut(cUrls) + url: this.urlsOut() }; - // ⚠️ 逐类都为空时**不发** contacts —— 发一个"全空"会把人家的补充电话清掉; - // 只在用户确实动过(有内容)时才发(服务端是"整组替换"语义)。 - if (this.hasAnyContact()) { + /* ⚠️ 逐类都为空时**通常不发** contacts —— 发一个"全空"会把人家的补充电话清掉。 + 🔴 但下面几种情况**必须发**,否则要么删不掉、要么清空别人的数据: + ① 用户确实有内容(正常提交); + ② 进来时**原本有条目**、现在被删空了 ⇒ 这是"删除"意图, + 不发就删不掉(服务端是"没传就沿用旧值",整组替换时也是"没传就跳过")。 + ③ **用户动过任意一组**(加了一行、改了一个字)⇒ 整组替换语义下 + 必须把完整四组发过去,否则他没动的那几组会被当成空。 + ⚠️ 都不是(详情没到 + 一个字没动)⇒ **不发** —— + 列表行进编辑时四组都是空的,发过去就把真实的补充电话全删了。 + ⚠️ 原来只判 ①,于是"删掉仅有的那条网址"永远删不掉 —— 用户会反复试。 */ + 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; } // 身份证:只在用户重填了才发(打码值不能回传) @@ -1564,11 +2099,17 @@ export struct FbPersonEditSheet { return 0; } - /** 四类补充联系方式里有没有任何内容 */ + /** + * 四类补充联系方式里有没有任何内容。 + * + * ⚠️ 网址这一路要跟着 `urlsOut()`(含主网址)—— + * 否则"只在主输入框里填了网址、没加补充行"时这里返回 false, + * `contacts` 整组**不会提交**,用户填的网址就白填了。 + */ private hasAnyContact(): boolean { return this.rowsOut(this.cPhones).length > 0 || this.rowsOut(this.cEmails).length > 0 || this.rowsOut(this.cAddresses).length > 0 - || this.rowsOut(this.cUrls).length > 0; + || this.urlsOut().length > 0; } } \ No newline at end of file