Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbPersonEditSheet.ets
T
laoyang 86de59616c 端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)
#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
2026-11-05 12:41:04 +08:00

1464 lines
53 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_MASK } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbSheetRing } from './FbSheetRing';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbApiOptions, FbApiResult, fbApi } from '../data/FbHttp';
import { fbUploadOne } from '../data/FbUpload';
import { FbAvatar } from './Widgets';
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[] = [];
/**
* 卡片外圈描边环的颜色(hex,'' = 用名册模块色)。
*
* ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。
*/
@Param accent: string = '';
/** 环色:显式给了就用给的,否则取名册模块色(与简要卡/详情卡同一个色系) */
private accentColor(): string {
if (this.accent !== '') {
return this.accent;
}
const m: FbModuleDef | undefined = fbModuleOf('people');
return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand;
}
/** 取消 */
@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;
}
// ============================================================
// 小件
// ============================================================
/**
* 一个输入行(**两排版式**,全局规范 —— 用户 2026-11-05)。
*
* 🔴 「点击名册里面的"添加",姓名之类的内容全部都按这种形式显示。
* 它会按照主题色,加粗大一点显示。**所有地方都需要这样改**。」
* 🔴 「为了解决字数原因导致输入框左右来回对不齐的问题,我们全部按两排来显示:
* 第一排显示片名…下一行开始是片名的输入框…这样一列下来就行了。」
*
* ## 规范
*
* | 项 | 规则 |
* |---|---|
* | 标签颜色 | **主题色** `this.accent` |
* | 标签字重 | **Bold** |
* | 标签字号 | `fontSmall`(比正文大一号) |
* | 标签位置 | **独占第一排**(不是和输入框同行) |
* | 输入框 | 第二排,整宽 |
* | 必填 | **红色**,**无星号** |
*
* ## 为什么两排能解决对齐
*
* 原来同行时标签宽度由字数决定(「姓名」2 字 vs「我对他的称呼」6 字),
* 输入框起点跟着变 ⇒ 整列左右错开。标签独占一行后,
* 所有输入框都从同一左边距开始 ⇒ **天然对齐**,与标签字数无关。
*
* ⚠️ `hint` 参数保留(它是 placeholder),另加 `req` 表示必填。
* 原来用 `hint='必填'` 冒充必填标记 —— 那只会在输入框里显示灰字,
* 既不是红色也不在标签上,用户看不出哪个字段是必填。
*/
@Builder
field(label: string, value: string, hint: string, onChange: (v: string) => void,
req: boolean = false) {
Column({ space: 6 }) {
/* 第一排:标签(主题色 / 必填红,加粗,独占一行) */
Text(label)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(req ? '#C0533F' : this.accent)
.width('100%')
/* 第二排:输入框(整宽,与标签左边缘对齐) */
TextInput({ text: value, placeholder: hint })
.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 => {
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;
})
}
/** 通用小开关(闰月 / 虚拟人物)
* ⚠️ 外层间距用 `margin` 而不是父容器的 `space`:这两个开关在 `Flex(wrap)` 里,
* `Flex` 没有 `space` 参数;margin 在换行后也能保持间距。
*/
@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 })
.margin({ right: 8, 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() 只能有一个根节点。
//
// 🔴 2026-10-19 用户:「编辑,也弄成弹框,加线,保持一致。」
// ⇒ 原来是"贴底满宽 + 只有上面两个角圆",看起来是**抽屉**而不是弹框,
// 和简要卡/详情卡并排切换时观感断层。
// 现在与它们完全同一种形态:**92% 宽 + 四角圆角 + 屏幕居中 + 主题色环**。
// ⚠️ 居中对键盘不友好?——不会:输入框获得焦点时系统会整体上推页面,
// 居中卡片反而不会被键盘压住(贴底卡才会)。真机验证过。
Stack({ alignContent: Alignment.Center }) {
this.editCard()
// ---- 删除确认(页面内遮罩,盖在卡片之上)----
if (this.confirmDel) {
this.deleteConfirm()
}
}
.width('100%')
.height('100%')
}
/** 编辑卡片本体 */
@Builder
editCard() {
// ⚠️ 外面这一层只给**宽度**(92%):卡片自己必须是 100%,
// 否则 0.92×0.92 ≈ 84.6%,会比简要卡/详情卡窄一圈。
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
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;
})
/* 🔴 2026-10-19:原来是一个 `Row({space:8}).width('100%')` ——
两个小开关并排时,"虚拟人物(还没弄清真实身份)"这一条**放不下**,
Row 只横向排、放不下就**直接溢出**,被卡片 `clip` 裁掉半句话
(真机截图里就是"…还没弄清真实"戛然而止)。
⇒ 换成 `Flex(wrap)`:放不下就**换行**,两个都不丢。
⚠️ 别改成给文字加 `maxLines(1)` + 省略号 —— 那句话是**解释功能**的,
截成"虚拟人物(还没弄清…"等于没解释。 */
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
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) {
// 🔴 用户 2026-10-19:「删除按钮全部用红色背景」
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.height(42)
.padding({ left: 18, right: 18 })
.backgroundColor(FbColors.red)
.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%')
/* ⚠️ 高度上限写在**卡片自己**身上(不是环层):卡片里有
`Scroll().layoutWeight(1)`,它要靠父容器的确定高度算"还能滚多少"。
挪到外面 ⇒ 内层高度变成内容驱动 ⇒ 滚动区塌成一条线。 */
.constraintSize({ maxHeight: '82%' })
/* 居中形态**不再需要**底部手势区缓冲(FB_SHEET_BOTTOM_GAP):
卡片不贴屏幕底,手势区碰不到它。 */
.padding({ left: 18, right: 18, top: 18, bottom: 20 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
/**
* 删除确认浮层。
*
* ⚠️ 文案要**说清后果**:"会从名册里移除,不可撤销"——
* 只写"确定删除吗"用户不知道删的是"名册里这一条"还是"整个人的账号"。
* ⚠️ 默认焦点/位置:**取消在左、删除在右**,且删除用红色实心
* (进入确认这一层后,用户已经明确意图,可以让它显眼)。
*/
@Builder
deleteConfirm() {
Column() {
/* 🔴 2026-10-19:确认框同样加**主题色环** ——
用户要的是"所有弹框都加线",这一层虽然小,但它也是弹框;
漏掉它就又出现"有的有线、有的没线"的不一致。 */
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
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(FbColors.red)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
this.doDelete();
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(18)
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
}
.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)
}
/**
* 真正上传头像。
*
* 🔴 v77(2026-10-22):multipart 的实现**搬去了 `data/FbUpload.ets`**。
* 在这之前这段代码在三个地方各有一份(头像 / 海报 / 观影照片),
* 而旅程题图又要用第四份 —— 再抄一遍必然漂移。
* ⇒ 现在只有一份 `fbUploadOne()`,这里只是带上门路与字段名。
* ⚠️ 字段名 `avatar` 必须与服务端读的那个键一致(`ApiHandler::people`)。
* @returns 空串 = 成功;否则是错误说明
*/
private async uploadAvatar(id: number, uri: string, token: string): Promise<string> {
return await fbUploadOne('/api/v1/people/' + id.toString() + '/avatar', 'avatar', uri, token);
}
/**
* 真删(`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;
}
}