Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbPersonEditSheet.ets
T
laoyang 7858de27e3 v0.8.4 简要卡观感 / 分享 / 名册搜索 / 全局返回手势(v59e)
八条需求的落地与真因:

1. 浮层与白底"糊在一起"、看不清 —— 根因不是配色,是**遮罩从来没画出来过**:
   ArkUI 的八位色值是 #AARRGGBB(alpha 在前),而全应用写的是 '#00000066'
   (本意"黑 40%")⇒ alpha = 0x00 ⇒ 全透明。同型还有 brand + '1A'(拼到了蓝通道)
   与 shadow('#00000033')。⇒ 17 个文件统一改走 fbAlpha() / FB_MASK / FB_SHADOW /
   FB_HAIRLINE;真机像素采样复验:遮罩 (148,146,140)、卡片白、浅底 (237,242,243)。
2. 简要卡外圈加**主题色描边环**(外层铺色 + padding,不是 border —— border 会被
   内层圆角切出白缝)。
3. 简要卡**去掉删除按钮**,删除挪到完整详情卡最下面(Divider + 红字块)。
   顺手修一处静默失败:confirmDelete() 原来只取 quickSource(),
   从家谱直接开详情时 quickPerson 为 null ⇒ 点删除毫无反应。
4. 信息行间距 10 → 16(每行再各加 3vp 上下内边距,实测行距 134/118px)。
5. 主电话整行可点**直接拨号**(call.makeCall,落到系统拨号盘)。
6. 按钮改为 详情 / 编辑 / 分享 / 关闭;分享走系统分享面板,
   内容为纯文本「姓名 / 电话 / 邮箱」三项(用户点名的就是这三项,
   不多带单位),面板拉不起来时兜底复制到剪贴板并提示。
7. 名册搜索"完全不能用"有两个独立真因:
   ① 搜索框是 List 的第一个 item,无结果时整个 List 被空态替换 ⇒ 输入框被卸载、
      焦点丢失 ⇒ 输不进第二个字。改:List 无条件渲染,空态自身做成 ListItem。
   ② rowToPerson 把 initials 写死成空串,服务端下发的拼音首字母从未落库 ⇒
      拼音搜索恒 0 结果。改:FB_DB_VERSION 6→7 + PRAGMA 探测补列 + 同步时落库;
      匹配同时遍历全部电话(原来只比主号)。
   另加搜索框「清空」(24×24 命中区)。
8. 全局返回手势:应用是单页 + 模块分发、没有路由栈 ⇒ 自建 FB_BACK 登记表
   (倒序询问)+ moduleHistory + applyModule()(不记历史,否则 A/B 两页来回弹)。
   9 个页面登记/注销;Index.onBackPress 先关浮层 → 回上一模块 → 才 return false
   交给系统退桌面(onBackPress 必须声明 boolean,返回 void 会被当成没接管)。

验证:
- 静态守卫 417 / 0(新增 ⑤阴影·描边·色环 ⑥透明度色 ⑦返回手势 ⑧搜索 ⑨分享)
- 反向验证 10 / 0(9 条注入全部能让守卫变红;其中"遮罩换纯黑"那条暴露了
  一条存在型假绿,已改成数个数 + 禁止 Color.Black)
- 建表/迁移列一致性守卫 12 / 0
- 真机全模块遍历 14 / 14;真机逐项复核 12 / 0 与 7 / 0

工具侧顺带修好两个过期的探针:
- 遍历探针仍用"应用内横滑"打开抽屉,而该手势早已按用户要求去掉、入口改为顶栏图标
  ⇒ 一次全量遍历里 12 个模块全报"抽屉里没找到"(像应用坏了,其实是探针过期)。
- 资源/笔记两页写死了早已不存在的条目(户口本/房产证/阳台改造清单)⇒ 改成
  结构性文案(副标题 + 常驻筛选胶囊),不随用户数据变化而恒红。
2026-10-18 23:12:57 +08:00

1421 lines
50 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 人员编辑浮层(鸿蒙端)
*
* 🔴 v52 重写(用户 2026-10-15):
* 「我们现在这个应用打造的目的就是为了让用户可以**完全脱离电脑端**……
* 编辑的时候,**所有的内容都应该可以编辑**,完成所有的功能才行。」
*
* 于是这里覆盖了**网页端表单的全部字段**(`templates/people/form.php`):
* 姓名 / 性别 / 家中排行 / 生日(阳历+农历+闰月) / 主手机号 / 补充电话
* 单位 / 职务 / 座右铭 / 邮箱 / 网址 / 身份证 / 结构化地址五级 / 详细地址
* 公共备注 / **我的私人备注** / 标签 / 工作经历(多段) / 虚拟人物标记
*
* ⚠️ 服务端同步扩过了(`PATCH /api/v1/people/<id>` 收全字段),
* 并且**没传的字段一律沿用旧值** ⇒ 本页"全量发一遍"也不会误清空。
* ⚠️ 头像走**单独接口**(`POST /people/<id>/avatar`,multipart),
* 不在这个 JSON 请求里 —— 见 `uploadAvatar()`。
*
* ⚠️ 与详情浮层的关系:
* 详情是"只读卡片"、编辑是"可写卡片",两者都是**页面内的覆盖层**。
* 编辑**从详情里点「编辑」进来**(详情 → 编辑),
* 这样用户不用在列表上找两次入口。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric, FB_HAIRLINE, FB_MASK, FB_SHADOW } from '../common/Theme';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from '../data/FbHttp';
import { FbAvatar } from './Widgets';
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';
import { picker } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbTagPickChip } from './FbTagPickChip';
import { fbImageSrc } from '../data/FbAttachQueue';
/**
* 贴底卡片的手势区缓冲(vp)。
*
* 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)——
* 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段,
* 否则最后一排按钮会被"从下往上滑"误触。
*/
const FB_SHEET_BOTTOM_GAP: number = 16;
const DOMAIN: number = 0x0000;
const TAG: string = 'FbPersonEdit';
/**
* 编辑请求体的类型别名。
*
* ⚠️ 必须用 **`Record<string, Object>`** 这个形状(v52 放宽后允许嵌套):
* ArkTS **不允许 interface 隐式转 Record**,也不允许无类型对象字面量
* (`arkts-no-untyped-obj-literals`)⇒ 用 Record 声明变量再赋值。
*/
type FbPersonEditBody = Record<string, Object>;
/** 一行标签输入用的可变行(服务端收的是 string[],这里带个稳定 key 供 ForEach) */
interface FbEditContactRow {
key: string;
label: string;
value: string;
}
/**
* 发出去的补充联系方式行。
*
* ⚠️ 为什么要有这个 interface(而不是直接写对象字面量):
* ArkTS 报 `arkts-no-untyped-obj-literals` —— **对象字面量必须对应一个
* 显式声明的 class/interface**。所以先声明形状,构造时用 `as` 转一次。
*/
interface FbOutContactRow {
label: string;
value: string;
}
/** 发出去的工作经历行(**注意是服务端要的下划线列名**,不是端上的驼峰) */
interface FbOutJobRow {
org: string;
title: string;
rank_name: string;
start_ym: string;
end_ym: string;
note: string;
}
/** 发出去的「补充联系方式」(按类型分组,服务端就是这个形状) */
interface FbOutContacts {
phone: FbOutContactRow[];
email: FbOutContactRow[];
address: FbOutContactRow[];
url: FbOutContactRow[];
}
/** 工作经历编辑行(与 `FbJobRow` 同构 + 一个稳定 key) */
interface FbEditJobRow {
key: string;
org: string;
title: string;
rankName: string;
startYm: string;
endYm: string;
note: string;
}
@ComponentV2
export struct FbPersonEditSheet {
/** 要编辑的人(null = 没打开 / 或"新建"模式,见 isCreate) */
@Param person: FbRosterPerson | null = null;
/**
* **新建模式**(用户 2026-10-15:全功能客户端的「添加」也必须能用)。
*
* ⚠️ 与"编辑"的差别只在**收尾**:
* 编辑走 `PATCH /people/<id>`(改现有的人),
* 新建走 `POST /people`(服务端会返回新 id,并自动进我的名册)。
* ⚠️ 表单字段完全一样 —— 所以不再另做一个"新建浮层",
* 少一套要维护的字段(本项目已经在"两份映射"上踩过坑)。
*/
@Param isCreate: boolean = false;
/**
* **已有标签**(名册的标签云)—— 编辑时**点选**用,不是让人手输。
*
* 🔴 用户 2026-10-16:「名册里面编辑的时候,或者添加的时候,
* 人的标签**应该是选择的**吧?应该让人**选择多个**,
* 而不是直接在这里输入文本。」
* ⚠️ 除了点选已有标签,**还要能新建**(服务端收的是任意字符串数组,
* 没有"必须先建标签"的限制)⇒ 下面给了"新标签"输入框 + 加号。
*/
@Param knownTags: string[] = [];
/** 取消 */
@Event onCancel: () => void = (): void => {
};
/** 保存成功(交给上层刷新) */
@Event onSaved: () => void = (): void => {
};
/**
* 删除成功(交给上层刷新)。
*
* ⚠️ 与 `onSaved` 分开:删除后列表要**重新拉一次**(那个人整条消失),
* 而保存只需刷新那一个人的显示 —— 语义不同,上层处理也不同。
*/
@Event onDeleted: () => void = (): void => {
};
// ============================================================
// 表单状态(初值由 aboutToAppear 从 person 填)
// ============================================================
@Local fName: string = '';
@Local fGender: string = '';
@Local fBirthRank: string = '';
/** 阳历生日 `YYYY-MM-DD` */
@Local fBirthdaySolar: string = '';
/** 农历生日 `YYYY-MM-DD`(服务端要完整日期;只填月日也收) */
@Local fBirthdayLunar: string = '';
@Local fLunarLeap: boolean = false;
@Local fPhone: string = '';
@Local fOrg: string = '';
@Local fTitle: string = '';
@Local fSlogan: string = '';
@Local fEmail: string = '';
@Local fAddress: string = '';
@Local fAddrCountry: string = '';
@Local fAddrProvince: string = '';
@Local fAddrCity: string = '';
@Local fAddrDistrict: string = '';
@Local fIdCard: string = '';
@Local fNote: string = '';
/**
* 「我对他的称呼」(用户 2026-10-15 要求:编辑里必须有这一项)。
*
* ⚠️ 它是**视角属性**(只影响我看到的名字),服务端存在 `nicknames` 表,
* 与 `persons` 分开 ⇒ 走 `my_nickname` 字段单独提交(见 `save()`)。
* 清空 = 恢复成用姓名显示。
*/
@Local fNickname: string = '';
@Local fMyNote: string = '';
/**
* 已选标签(**多选**;服务端收 `string[]`)。
*
* ⚠️ 不再是"逗号分隔的文本" —— 那样用户得记住分隔符、还会输错。
* ⇒ 改成点选/取消(见 `tagPicker()`),另给一个输入框新建标签。
*/
@Local fTags: string[] = [];
/** 新建标签的输入框内容 */
@Local newTag: string = '';
@Local fPlaceholder: boolean = false;
/** 补充联系方式(四类各一组可增删行) */
@Local cPhones: FbEditContactRow[] = [];
@Local cEmails: FbEditContactRow[] = [];
@Local cAddresses: FbEditContactRow[] = [];
@Local cUrls: FbEditContactRow[] = [];
/** 工作经历(可增删行) */
@Local jobs: FbEditJobRow[] = [];
/** 保存中(防连点) */
@Local busy: boolean = false;
/** 错误提示 */
@Local err: string = '';
/**
* 删除确认弹窗是否显示。
*
* 🔴 删除是**真实删服务端数据、不可逆**(用户 2026-10-15 选择"弹确认框")
* ⇒ 必须先问一次,不能点一下就删。
* ⚠️ 用页面内的**自定义遮罩**而不是 `AlertDialog`:
* 本项目所有浮层都是"页面内覆盖层"(见 FbPersonSheet 的文件头),
* 混用系统弹窗会让视觉与交互都不统一。
*/
@Local confirmDel: boolean = false;
/** 删除中(防连点) */
@Local deleting: boolean = false;
/** 自增 key(给增删行用,避免 ForEach key 冲突) */
private seq: number = 0;
/**
* **待上传的头像**(用户选了图、但还没传)。
*
* 🔴 为什么需要它(用户 2026-10-15 报的真 bug:「新增的时候点"换头像"却没有用」):
* 头像接口是 `POST /people/<id>/avatar` —— **需要 id**,
* 而新建模式下这个人**还不存在、没有 id** ⇒ 当场传不了。
* 旧代码更糟:`pickAvatar()` 开头就 `if (person === null) return;`
* ⇒ 新建时点"换头像"**什么都不发生**(连选图都不弹)。
* ⇒ 改法:新建时先把选中的图**存起来**,等 `POST /people` 建好、
* 拿到新 id **再传**(见 `save()` 里 `if (this.pendingAvatar !== '')`)。
*/
private pendingAvatar: string = '';
/** 新建后上传头像的失败说明(非致命:人不该因为图没上传成功就建不出来) */
private avatarWarn: string = '';
aboutToAppear(): void {
const p: FbRosterPerson | null = this.person;
// 新建模式:不需要回填,全部留空(用户从零填)
if (p === null) {
return;
}
this.fName = p.name;
this.fGender = p.gender;
this.fBirthRank = p.birthRank;
// ⚠️ 服务端生日是 'YYYY-MM-DD HH:mm:ss',输入框用 'YYYY-MM-DD'
this.fBirthdaySolar = p.birthdayFull;
this.fBirthdayLunar = p.birthdayLunarRaw;
this.fLunarLeap = p.birthdayLunarLeap;
this.fPhone = p.phone;
this.fOrg = p.org;
this.fTitle = p.title;
this.fSlogan = p.slogan;
this.fEmail = p.email;
this.fAddress = p.address;
this.fAddrCountry = p.addrCountry;
this.fAddrProvince = p.addrProvince;
this.fAddrCity = p.addrCity;
this.fAddrDistrict = p.addrDistrict;
this.fIdCard = '';
this.fNote = p.note;
this.fMyNote = p.myNote;
this.fNickname = p.myNickname;
// 标签:直接拿数组(不再拼成逗号串)
this.fTags = [];
for (const tg of p.tags) {
this.fTags.push(tg);
}
this.newTag = '';
this.fPlaceholder = false;
// 补充联系方式(深拷贝,别改到列表那份)
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');
// 工作经历
this.jobs = [];
for (const j of p.jobs) {
this.jobs.push(this.toEditJob(j));
}
}
private nextKey(prefix: string): string {
this.seq += 1;
return prefix + '_' + this.seq.toString();
}
/** 接口行 → 编辑行(带稳定 key) */
private toEditRows(rows: FbContactRow[], prefix: string): FbEditContactRow[] {
const out: FbEditContactRow[] = [];
for (const r of rows) {
const row: FbEditContactRow = { key: this.nextKey(prefix), label: r.label, value: r.value };
out.push(row);
}
return out;
}
/** 工作经历 → 编辑行 */
private toEditJob(j: FbJobRow): FbEditJobRow {
const row: FbEditJobRow = {
key: this.nextKey('jb'),
org: j.org, title: j.title, rankName: j.rankName,
startYm: j.startYm, endYm: j.endYm, note: j.note
};
return row;
}
// ============================================================
// 小件
// ============================================================
/** 一个输入行 */
@Builder
field(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
TextInput({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 标签**多选**(用户 2026-10-16:「标签应该是选择的……选择多个,
* 而不是直接在这里输入文本」)。
*
* 三部分:
* ① **已选**(当前这个人身上的标签,可以点掉);
* ② **可选**(名册里已有的其它标签,点一下加上);
* ③ **新建**(输入框 + 加号 —— 服务端收任意字符串,不要求先建标签)。
*
* ⚠️ 片用 `FbTagPickChip` **组件**(`@Builder` 的值参数不参与观察,
* 点了不会变样式 —— 本项目已踩 5 次)。
*/
@Builder
tagPicker() {
Column({ space: 6 }) {
Text('标签')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
// ① 已选(没有任何已选时给一句提示,免得看着是空白)
if (this.fTags.length === 0) {
Text('还没选标签')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 2, bottom: 2 })
} else {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.fTags, (tg: string) => {
FbTagPickChip({
label: tg,
active: true,
onToggle: (): void => {
this.toggleTag(tg);
}
})
}, (tg: string): string => 'sel_' + tg)
}
.width('100%')
}
// ② 可选(名册里已有、但这个人身上还没有的)
if (this.candidates().length > 0) {
Text('点一下加上')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 2 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.candidates(), (tg: string) => {
FbTagPickChip({
label: tg,
active: false,
onToggle: (): void => {
this.toggleTag(tg);
}
})
}, (tg: string): string => 'cand_' + tg)
}
.width('100%')
}
// ③ 新建标签
Row({ space: 8 }) {
TextInput({ text: this.newTag, placeholder: '新标签,如 大学同学' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(38)
.padding({ left: 12, right: 12 })
.layoutWeight(1)
.onChange((v: string): void => {
this.newTag = v;
})
.onSubmit((): void => {
this.addNewTag();
})
Text('+')
.fontSize(FbMetric.fontBody)
.fontColor(this.newTag.trim() === '' ? FbColors.faint : '#4A7C82')
.width(44)
.height(38)
.textAlign(TextAlign.Center)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.addNewTag();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 可选标签 = 已有标签 − 已选(排除自己在"已选"里重复出现) */
private candidates(): string[] {
const out: string[] = [];
for (const t of this.knownTags) {
if (t.trim() !== '' && this.fTags.indexOf(t) < 0) {
out.push(t);
}
}
return out;
}
/** 点一下:没选就加上、已选就取消 */
private toggleTag(tg: string): void {
const at: number = this.fTags.indexOf(tg);
if (at >= 0) {
this.fTags.splice(at, 1);
} else {
this.fTags.push(tg);
}
}
/** 把输入框里的新标签加上(空 / 重复则不响应) */
private addNewTag(): void {
const v: string = this.newTag.trim();
if (v === '' || this.fTags.indexOf(v) >= 0) {
this.newTag = '';
return;
}
this.fTags.push(v);
this.newTag = '';
}
/** 多行输入(备注类) */
@Builder
area(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
TextArea({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 分组小标题(把一大堆字段分开,免得糊成一团) */
@Builder
section(title: string) {
Text(title)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 6 })
}
/** 性别选择(两段) */
@Builder
genderPick() {
Column({ space: 4 }) {
Text('性别')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 8 }) {
this.genderChip('男', 'm')
this.genderChip('女', 'f')
this.genderChip('不填', '')
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
genderChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.fGender === key ? Color.White : FbColors.muted)
.backgroundColor(this.fGender === key ? '#4A7C82' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.onClick((): void => {
this.fGender = key;
})
}
/** 通用小开关(闰月 / 虚拟人物) */
@Builder
toggleChip(label: string, on: boolean, onTap: () => void) {
Row({ space: 6 }) {
Text(on ? '✓' : '○')
.fontSize(FbMetric.fontSmall)
.fontColor(on ? Color.White : FbColors.faint)
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(on ? Color.White : FbColors.muted)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(on ? '#4A7C82' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
onTap();
})
}
/** 「+ 加一行」 */
@Builder
addRowBtn(label: string, onTap: () => void) {
Text('+ ' + label)
.fontSize(FbMetric.fontTiny)
.fontColor('#4A7C82')
.padding({ top: 4, bottom: 4 })
.onClick((): void => {
onTap();
})
}
/** 一行「说明 + 值 + 删」 */
@Builder
contactRow(row: FbEditContactRow, placeholder: string, onDel: () => void) {
Row({ space: 6 }) {
TextInput({ text: row.label, placeholder: '说明' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(38)
.width(74)
.padding({ left: 8, right: 8 })
.onChange((v: string): void => {
row.label = v;
})
TextInput({ text: row.value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(38)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
row.value = v;
})
Text('✕')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.padding(6)
.onClick((): void => {
onDel();
})
}
.width('100%')
}
/** 一行工作经历(两行输入叠放) */
@Builder
jobRow(row: FbEditJobRow, onDel: () => void) {
Column({ space: 6 }) {
Row({ space: 6 }) {
TextInput({ text: row.org, placeholder: '单位' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(38)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
row.org = v;
})
Text('✕')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.padding(6)
.onClick((): void => {
onDel();
})
}
.width('100%')
Row({ space: 6 }) {
TextInput({ text: row.title, placeholder: '职务' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(36)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
row.title = v;
})
TextInput({ text: row.rankName, placeholder: '职称' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(36)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
row.rankName = v;
})
}
.width('100%')
Row({ space: 6 }) {
TextInput({ text: row.startYm, placeholder: '起始 2015-07' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(36)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
row.startYm = v;
})
TextInput({ text: row.endYm, placeholder: '结束(空=至今)' })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(36)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
row.endYm = v;
})
}
.width('100%')
}
.width('100%')
.padding(10)
.backgroundColor('#F7F5F2')
.borderRadius(FbMetric.radiusS)
}
build() {
// 🔴 外层 `Stack` 是**必需**的:本组件有**两个**要显示的层
// (编辑卡片 + 删除确认遮罩),而 build() 只能有一个根节点。
// ⚠️ `alignContent: Bottom` 让编辑卡片贴底(与详情卡片同一形态)。
Stack({ alignContent: Alignment.Bottom }) {
this.editCard()
// ---- 删除确认(页面内遮罩,盖在卡片之上)----
if (this.confirmDel) {
this.deleteConfirm()
}
}
.width('100%')
.height('100%')
}
/** 编辑卡片本体 */
@Builder
editCard() {
Column({ space: 0 }) {
// ---- 标题 ----
Row({ space: 12 }) {
FbAvatar({
name: this.person !== null ? this.person.shown : '',
color: this.person !== null ? this.person.avatarColor : '#A84A28',
dia: 40,
fontSizeVp: 16,
imageUrl: this.avatarUrl()
})
Column({ space: 2 }) {
Text(this.isCreate ? '添加成员' : '编辑资料')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.isCreate ? '填多少算多少,之后都能再改' : '全部字段都能改,改完点保存')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 换头像(单独走 multipart 接口)
Text('换头像')
.fontSize(FbMetric.fontTiny)
.fontColor('#4A7C82')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.border({ width: 1, color: '#4A7C82' })
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.pickAvatar();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
this.section('基本')
this.field('姓名', this.fName, '必填', (v: string): void => {
this.fName = v;
})
// 🔴 「我怎么称呼他」—— 只有我看得到(视角属性),与姓名分开
this.field('我对他的称呼', this.fNickname,
'只影响你看到的叫法,留空 = 用姓名', (v: string): void => {
this.fNickname = v;
})
this.genderPick()
this.field('家中排行', this.fBirthRank, '如 1(老大),留空表示没记', (v: string): void => {
this.fBirthRank = v;
})
this.field('生日(阳历)', this.fBirthdaySolar, '如 1985-11-16', (v: string): void => {
this.fBirthdaySolar = v;
})
this.field('生日(农历)', this.fBirthdayLunar, '如 1985-10-05', (v: string): void => {
this.fBirthdayLunar = v;
})
Row({ space: 8 }) {
this.toggleChip('农历闰月', this.fLunarLeap, (): void => {
this.fLunarLeap = !this.fLunarLeap;
})
this.toggleChip('虚拟人物(还没弄清真实身份)', this.fPlaceholder, (): void => {
this.fPlaceholder = !this.fPlaceholder;
})
}
.width('100%')
this.section('联系方式')
this.field('手机号(主号)', this.fPhone, '登录用;留空表示没录', (v: string): void => {
this.fPhone = v;
})
// ---- 补充电话 ----
if (this.cPhones.length > 0) {
ForEach(this.cPhones, (row: FbEditContactRow) => {
this.contactRow(row, '手机/座机', (): void => {
this.removeRow(this.cPhones, row.key);
})
}, (row: FbEditContactRow): string => row.key)
}
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;
})
if (this.cEmails.length > 0) {
ForEach(this.cEmails, (row: FbEditContactRow) => {
this.contactRow(row, '邮箱', (): void => {
this.removeRow(this.cEmails, row.key);
})
}, (row: FbEditContactRow): string => row.key)
}
this.addRowBtn('补充邮箱', (): void => {
this.cEmails.push({ key: this.nextKey('em'), label: '', value: '' });
})
if (this.cUrls.length > 0) {
ForEach(this.cUrls, (row: FbEditContactRow) => {
this.contactRow(row, '网址', (): void => {
this.removeRow(this.cUrls, row.key);
})
}, (row: FbEditContactRow): string => row.key)
}
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 }) {
Text('省')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
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 }) {
Text('市')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
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) {
ForEach(this.cAddresses, (row: FbEditContactRow) => {
this.contactRow(row, '其它地址', (): void => {
this.removeRow(this.cAddresses, row.key);
})
}, (row: FbEditContactRow): string => row.key)
}
this.addRowBtn('其它地址', (): void => {
this.cAddresses.push({ key: this.nextKey('ad'), label: '', value: '' });
})
this.section('工作')
this.field('单位', this.fOrg, '如 中国海洋大学', (v: string): void => {
this.fOrg = v;
})
this.field('职务', this.fTitle, '如 副院长', (v: string): void => {
this.fTitle = v;
})
if (this.jobs.length > 0) {
ForEach(this.jobs, (row: FbEditJobRow) => {
this.jobRow(row, (): void => {
this.removeJob(row.key);
})
}, (row: FbEditJobRow): string => row.key)
}
this.addRowBtn('工作经历', (): void => {
this.jobs.push({
key: this.nextKey('jb'), org: '', title: '', rankName: '',
startYm: '', endYm: '', note: ''
});
})
this.section('其它')
this.field('座右铭', this.fSlogan, '一句话(只有本人能改)', (v: string): void => {
this.fSlogan = v;
})
this.tagPicker()
this.field('身份证', this.fIdCard, this.idCardHint(), (v: string): void => {
this.fIdCard = v;
})
this.area('备注(大家都能看)', this.fNote, '爱吃辣、对芒果过敏……', (v: string): void => {
this.fNote = v;
})
this.area('我的备注(只有我看得到)', this.fMyNote, '我对这个人的印象/提醒', (v: string): void => {
this.fMyNote = v;
})
if (this.err !== '') {
Text(this.err)
.fontSize(FbMetric.fontTiny)
.fontColor('#A32D2D')
.width('100%')
}
}
.width('100%')
.padding({ top: 14 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
// ---- 按钮:删除 / 取消 / 保存 ----
// 🔴 用户 2026-10-15:「卡片上面有一个编辑按钮;点击编辑之后,
// 下面应该有编辑、删除之类的操作,或者关闭按钮。」
// ⇒ 编辑态给三颗:**删除**(左,红色描边)/ 取消 / 保存。
// ⚠️ 删除单独放最左、且用**描边**而不是实心红:
// 它是破坏性操作,不该和"保存"抢视觉重心(防误点)。
if (this.deleting) {
Text('删除中…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.margin({ top: 16 })
} else {
Row({ space: 10 }) {
// 新建模式没有"删除"(这人还不存在)
if (!this.isCreate) {
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor('#C0533F')
.textAlign(TextAlign.Center)
.height(42)
.padding({ left: 18, right: 18 })
.border({ width: 1, color: '#C0533F' })
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.confirmDel = true;
}
})
}
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onCancel();
})
Text(this.busy ? '保存中…' : (this.isCreate ? '创建' : '保存'))
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(this.busy ? '#4A7C8288' : '#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.save();
}
})
}
.width('100%')
.margin({ top: 16 })
}
}
.width('100%')
.constraintSize({ maxHeight: '82%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.alignItems(HorizontalAlign.Start)
}
/**
* 删除确认浮层。
*
* ⚠️ 文案要**说清后果**:"会从名册里移除,不可撤销"——
* 只写"确定删除吗"用户不知道删的是"名册里这一条"还是"整个人的账号"。
* ⚠️ 默认焦点/位置:**取消在左、删除在右**,且删除用红色实心
* (进入确认这一层后,用户已经明确意图,可以让它显眼)。
*/
@Builder
deleteConfirm() {
Column() {
Column({ space: 8 }) {
Text('删除「' + (this.person !== null ? this.person.shown : '') + '」?')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('会把这个人的资料从名册里移除,不能撤销。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(2)
Row({ space: 10 }) {
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
})
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor('#C0533F')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
this.doDelete();
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(18)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
/* 🔴 v59c:与 `FbPersonSheet` / `FbPersonQuickSheet` 同一套"三层分离"
(用户 2026-10-18:「白色和下面列表的白色融合在一起,看不出是弹框」)。
⚠️ 三个浮层必须**同手段**,否则从简要卡 → 编辑切过去会突然"变平"。 */
.shadow({ radius: 24, color: FB_SHADOW, offsetX: 0, offsetY: 6 })
.border({ width: 1, color: FB_HAIRLINE })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
// 遮罩盖满整屏(点空白处关掉 = 取消)
.justifyContent(FlexAlign.Center)
.padding({ left: 32, right: 32 })
.backgroundColor(FB_MASK)
.onClick((): void => {
this.confirmDel = false;
})
}
// ============================================================
// 辅助
// ============================================================
private avatarUrl(): string {
if (this.person === null) {
return '';
}
// ⚠️ 与其他地方同一套:必须用 fbImageUrl 拼带令牌的绝对地址
// 🔴 v55:**不再自己拼**「基址 + /media.php?...」——
// 服务器地址已经是运行期可配置的(见 FbServerStore),
// 自己拼的人漏改一处就会对着旧服务器要图片(表现为"换了服务器头像全裂")。
return fbImageSrc(this.person.avatar, FB_TOKEN_STORE.get());
}
private idCardHint(): string {
const masked: string = this.person !== null ? this.person.idCardMasked : '';
return masked !== '' ? ('当前 ' + masked + ',重填才改') : '留空表示没录';
}
/** 删掉一行补充联系方式 */
private removeRow(list: FbEditContactRow[], key: string): void {
for (let i = 0; i < list.length; i++) {
if (list[i].key === key) {
list.splice(i, 1);
return;
}
}
}
/** 删掉一段工作经历 */
private removeJob(key: string): void {
for (let i = 0; i < this.jobs.length; i++) {
if (this.jobs[i].key === key) {
this.jobs.splice(i, 1);
return;
}
}
}
/** 把编辑行转成接口要的形状(丢掉全空的行) */
private rowsOut(rows: FbEditContactRow[]): FbOutContactRow[] {
const out: FbOutContactRow[] = [];
for (const r of rows) {
const v: string = r.value.trim();
if (v === '') {
continue;
}
const row: FbOutContactRow = { label: r.label.trim(), value: v };
out.push(row);
}
return out;
}
/** 工作经历 → 接口形状(丢掉整段都空的行) */
private jobsOut(): FbOutJobRow[] {
const out: FbOutJobRow[] = [];
for (const j of this.jobs) {
const org: string = j.org.trim();
const title: string = j.title.trim();
const rank: string = j.rankName.trim();
if (org === '' && title === '' && rank === '') {
continue;
}
const row: FbOutJobRow = {
org: org, title: title, rank_name: rank,
start_ym: j.startYm.trim(), end_ym: j.endYm.trim(), note: j.note.trim()
};
out.push(row);
}
return out;
}
/**
* 已选标签 → 数组。
*
* ⚠️ 现在 `fTags` **本身就是数组**(点选得来的)——
* 这里只需去重、去空白、顺带把输入框里没点"加号"的那条也带上
* (用户输了字直接点保存是很自然的行为,不该丢)。
*/
private tagsOut(): string[] {
const out: string[] = [];
const seen: string[] = [];
const push = (raw: string): void => {
const v: string = raw.trim();
if (v !== '' && seen.indexOf(v) < 0) {
seen.push(v);
out.push(v);
}
};
for (const t of this.fTags) {
push(t);
}
push(this.newTag); // 输入框里没提交的那条也算上
return out;
}
// ============================================================
// 网络
// ============================================================
/**
* 换头像(`POST /api/v1/people/<id>/avatar`,multipart)。
*
* 🔴 为什么单独一个接口、而不是塞进 JSON 保存里:
* 二进制文件只能走 multipart,与 JSON 不是一路;
* 而且网页端也是"上传即生效"(换图就换主色),与保存解耦更简单。
* ⚠️ 用系统**文件选择器**(`photoViewPicker`)而不是相机权限:
* 选图不需要额外权限,也不会因为没授相机权限而卡住。
*/
private async pickAvatar(): Promise<void> {
// ⚠️ **不**在这里判 `person === null` —— 新建模式也要能选图
// (见 pendingAvatar 的说明)。
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.err = '未登录,无法换头像';
return;
}
let uri: string = '';
try {
const pickerOpts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions();
pickerOpts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
pickerOpts.maxSelectNumber = 1;
const photoPicker: picker.PhotoViewPicker = new picker.PhotoViewPicker();
const sel: picker.PhotoSelectResult = await photoPicker.select(pickerOpts);
if (sel.photoUris.length === 0) {
return; // 用户取消了
}
uri = sel.photoUris[0];
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '选图失败:%{public}s', JSON.stringify(e));
this.err = '选图失败';
return;
}
// ---- 新建模式:先存着,等建好了再传 ----
if (this.person === null) {
this.pendingAvatar = uri;
this.err = '';
this.avatarWarn = '';
return;
}
// ---- 编辑模式:当场传 ----
this.busy = true;
this.err = '';
const ok: string = await this.uploadAvatar(this.person.id, uri, token);
this.busy = false;
if (ok !== '') {
this.err = '头像上传失败:' + ok;
return;
}
this.onSaved(); // 让上层重新拉一次(拿到新头像 URL)
}
/**
* 真正上传(手工拼 multipart)。
*
* ⚠️ HarmonyOS 的 `http` 没有现成的 multipart 封装 ⇒ 手写 boundary 与分片。
* 结构就是标准 multipart/form-data:`--boundary\r\n头\r\n\r\n体\r\n--boundary--`。
* @returns 空串 = 成功;否则是错误说明
*/
private async uploadAvatar(id: number, uri: string, token: string): Promise<string> {
const url: string = fbApiBase() + '/api/v1/people/' + id.toString() + '/avatar';
// 读文件(picker 给的是 file:// URI,用 fileIo 读成 ArrayBuffer)
const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const stat: fileIo.Stat = fileIo.statSync(file.fd);
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
fileIo.readSync(file.fd, buf);
fileIo.closeSync(file);
const boundary: string = '----FbBoundary' + Date.now().toString();
const head: string = '--' + boundary + '\r\n'
+ 'Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"\r\n'
+ 'Content-Type: image/jpeg\r\n\r\n';
const tail: string = '\r\n--' + boundary + '--\r\n';
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.POST,
header: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Authorization': 'Bearer ' + token,
'Accept': 'application/json'
},
// ⚠️ 顺序必须是 head(字符串) → 文件(ArrayBuffer) → tail(字符串),
// 服务端按这个顺序解析出一个 avatar 分片。
extraData: [head, buf, tail],
connectTimeout: 20000,
readTimeout: 30000,
expectDataType: http.HttpDataType.STRING
});
if (resp.responseCode >= 200 && resp.responseCode < 300) {
return '';
}
const body: string = typeof resp.result === 'string' ? resp.result : '';
return 'HTTP ' + resp.responseCode.toString() + ' ' + body.substring(0, 120);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '头像上传异常:%{public}s', JSON.stringify(e));
return '网络异常';
} finally {
req.destroy();
}
}
/**
* 真删(`DELETE /api/v1/people/<id>`)。
*
* ⚠️ 服务端是**软删**(`Person::delete` 走 deleted_at),所以"删错了"在
* 纸面上还能捞回来 —— 但**应用里不提供恢复入口** ⇒ 对用户而言就是不可逆,
* 确认框仍然必要。
*/
private async doDelete(): Promise<void> {
const p: FbRosterPerson | null = this.person;
if (p === null) {
return;
}
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.err = '未登录,无法删除';
return;
}
this.deleting = true;
this.err = '';
const opts: FbApiOptions = { method: 'DELETE', token: token };
const res: FbApiResult = await fbApi('/api/v1/people/' + p.id.toString(), opts);
this.deleting = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '删除失败:%{public}s', res.error);
this.err = '删除失败:' + res.error;
return;
}
this.onDeleted();
}
/**
* 保存(`PATCH /api/v1/people/<id>`),**全字段**。
*
* ⚠️ 全发而不是"只发改动项":服务端对没传的字段是"取不到就用原值",
* 所以两者结果一样,而全发**代码简单得多**(不用维护"哪些脏了")。
* ⚠️ 身份证**只有填了才发** —— 服务端给的是打码值,把打码值发回去会把真号覆盖成乱码。
*/
private async save(): Promise<void> {
const p: FbRosterPerson | null = this.person;
// ⚠️ 新建模式 `person` 是 null(这不是错误,见 isCreate)
if (p === null && !this.isCreate) {
return;
}
const name: string = this.fName.trim();
if (name === '') {
this.err = '姓名不能为空';
return;
}
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.err = this.isCreate ? '未登录,无法创建' : '未登录,无法保存';
return;
}
this.busy = true;
this.err = '';
const payload: FbPersonEditBody = {};
payload['name'] = name;
payload['gender'] = this.fGender;
payload['birth_rank'] = this.fBirthRank.trim();
payload['birthday_solar'] = this.fBirthdaySolar.trim();
payload['birthday_lunar'] = this.fBirthdayLunar.trim();
payload['birthday_lunar_leap'] = this.fLunarLeap;
payload['phone'] = this.fPhone.trim();
payload['org'] = this.fOrg.trim();
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();
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();
// 补充联系方式:按类型分组
const contacts: FbOutContacts = {
phone: this.rowsOut(this.cPhones),
email: this.rowsOut(this.cEmails),
address: this.rowsOut(this.cAddresses),
url: this.rowsOut(this.cUrls)
};
// ⚠️ 逐类都为空时**不发** contacts —— 发一个"全空"会把人家的补充电话清掉;
// 只在用户确实动过(有内容)时才发(服务端是"整组替换"语义)。
if (this.hasAnyContact()) {
payload['contacts'] = contacts;
}
// 身份证:只在用户重填了才发(打码值不能回传)
const idCard: string = this.fIdCard.trim();
if (idCard !== '') {
payload['id_card'] = idCard;
}
// 🔴 新建走 POST(服务端会建人 + 自动进我的名册 + 返回完整详情);
// 编辑走 PATCH。**表单字段完全一样**,只有路径与方法不同。
const isNew: boolean = this.isCreate || p === null;
const opts: FbApiOptions = {
method: isNew ? 'POST' : 'PATCH',
token: token,
body: payload,
};
const path: string = isNew ? '/api/v1/people' : ('/api/v1/people/' + (p as FbRosterPerson).id.toString());
const res: FbApiResult = await fbApi(path, opts);
this.busy = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '保存失败:%{public}s', res.error);
this.err = (isNew ? '创建失败:' : '保存失败:') + res.error;
return;
}
// 🔴 新建时"换头像"选的图**现在才传**(创建前没有 id,见 pendingAvatar 的说明)。
// ⚠️ 头像失败**不挡创建** —— 人已经建好了,图下次再换即可;
// 提示写在 `avatarWarn` 里由上层展示。
if (isNew && this.pendingAvatar !== '') {
const newId: number = this.newIdOf(res.data);
if (newId > 0) {
const upErr: string = await this.uploadAvatar(newId, this.pendingAvatar, token);
if (upErr !== '') {
hilog.warn(DOMAIN, TAG, '新建后传头像失败:%{public}s', upErr);
this.avatarWarn = '(头像没传上:' + upErr + ')';
}
}
this.pendingAvatar = '';
}
this.onSaved();
}
/** 从 `POST /people` 的返回里取新人的 id(取不到返回 0) */
private newIdOf(json: string): number {
if (json === '') {
return 0;
}
try {
const o: Record<string, Object> = JSON.parse(json) as Record<string, Object>;
const v: Object | undefined = o['id'];
if (typeof v === 'number') {
return v;
}
if (typeof v === 'string') {
return parseInt(v, 10);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '解析新 id 失败:%{public}s', JSON.stringify(err));
}
return 0;
}
/** 四类补充联系方式里有没有任何内容 */
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;
}
}