Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbPersonEditSheet.ets
T

1416 lines
50 KiB
Plaintext
Raw Normal View History

/**
* 家书 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 } 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)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
// 遮罩盖满整屏(点空白处关掉 = 取消)
.justifyContent(FlexAlign.Center)
.padding({ left: 32, right: 32 })
.backgroundColor('#00000066')
.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;
}
}