名册编辑:表单取消分组、网址补常驻输入框、地址合一(#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:
@@ -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();
|
/* ---- 地址(一个输入框 → 两种落库方式)----
|
||||||
|
🔴 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_country'] = this.fAddrCountry.trim();
|
||||||
payload['addr_province'] = this.fAddrProvince.trim();
|
payload['addr_province'] = this.fAddrProvince.trim();
|
||||||
payload['addr_city'] = this.fAddrCity.trim();
|
payload['addr_city'] = this.fAddrCity.trim();
|
||||||
payload['addr_district'] = this.fAddrDistrict.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();
|
||||||
|
/* ---- 工作经历:`PersonJob::replace()` 是**整组替换**(该接口无条件调用)
|
||||||
|
🔴 只有在"用户动过"或"新建"时才发这个键 ——
|
||||||
|
否则列表行进编辑(`jobs` 是空数组)随手改个姓名保存,
|
||||||
|
这个人的全部工作经历就被清空了。
|
||||||
|
⚠️ 详情回来后 `jobs` 是服务端真值、基线也同步重算 ⇒ 没动就不发,
|
||||||
|
服务端原样保留,等价且更安全(少一次无意义的 DELETE+INSERT)。
|
||||||
|
⚠️ 用户动过(加了/删了/改了任一行)⇒ 必须发,那是他的明确意图。 */
|
||||||
|
const jobsTouched: boolean = this.jobsSig() !== this.origJobsSig;
|
||||||
|
if (this.isCreate || jobsTouched) {
|
||||||
payload['jobs'] = this.jobsOut();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user