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