/** * 家书 FamilyBook · 人员编辑浮层(鸿蒙端) * * 🔴 v52 重写(用户 2026-10-15): * 「我们现在这个应用打造的目的就是为了让用户可以**完全脱离电脑端**…… * 编辑的时候,**所有的内容都应该可以编辑**,完成所有的功能才行。」 * * 于是这里覆盖了**网页端表单的全部字段**(`templates/people/form.php`): * 姓名 / 性别 / 家中排行 / 生日(阳历+农历+闰月) / 主手机号 / 补充电话 * 单位 / 职务 / 座右铭 / 邮箱 / 网址 / 身份证 / 结构化地址五级 / 详细地址 * 公共备注 / **我的私人备注** / 标签 / 工作经历(多段) / 虚拟人物标记 * * ⚠️ 服务端同步扩过了(`PATCH /api/v1/people/` 收全字段), * 并且**没传的字段一律沿用旧值** ⇒ 本页"全量发一遍"也不会误清空。 * ⚠️ 头像走**单独接口**(`POST /people//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`** 这个形状(v52 放宽后允许嵌套): * ArkTS **不允许 interface 隐式转 Record**,也不允许无类型对象字面量 * (`arkts-no-untyped-obj-literals`)⇒ 用 Record 声明变量再赋值。 */ type FbPersonEditBody = Record; /** 一行标签输入用的可变行(服务端收的是 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/`(改现有的人), * 新建走 `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//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//avatar`,multipart)。 * * 🔴 为什么单独一个接口、而不是塞进 JSON 保存里: * 二进制文件只能走 multipart,与 JSON 不是一路; * 而且网页端也是"上传即生效"(换图就换主色),与保存解耦更简单。 * ⚠️ 用系统**文件选择器**(`photoViewPicker`)而不是相机权限: * 选图不需要额外权限,也不会因为没授相机权限而卡住。 */ private async pickAvatar(): Promise { // ⚠️ **不**在这里判 `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 { 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/`)。 * * ⚠️ 服务端是**软删**(`Person::delete` 走 deleted_at),所以"删错了"在 * 纸面上还能捞回来 —— 但**应用里不提供恢复入口** ⇒ 对用户而言就是不可逆, * 确认框仍然必要。 */ private async doDelete(): Promise { 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/`),**全字段**。 * * ⚠️ 全发而不是"只发改动项":服务端对没传的字段是"取不到就用原值", * 所以两者结果一样,而全发**代码简单得多**(不用维护"哪些脏了")。 * ⚠️ 身份证**只有填了才发** —— 服务端给的是打码值,把打码值发回去会把真号覆盖成乱码。 */ private async save(): Promise { 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 = JSON.parse(json) as Record; 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; } }