2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 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';
|
2026-10-19 08:29:44 +08:00
|
|
|
|
import { FbColors, FbMetric, FB_MASK } from '../common/Theme';
|
|
|
|
|
|
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
|
|
|
|
|
|
import { FbSheetRing } from './FbSheetRing';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
|
|
|
|
|
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from '../data/FbHttp';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { fbImageSrc } from '../data/FbAttachQueue';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 贴底卡片的手势区缓冲(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[] = [];
|
2026-10-19 08:29:44 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 卡片外圈描边环的颜色(hex,'' = 用名册模块色)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Param accent: string = '';
|
|
|
|
|
|
|
|
|
|
|
|
/** 环色:显式给了就用给的,否则取名册模块色(与简要卡/详情卡同一个色系) */
|
|
|
|
|
|
private accentColor(): string {
|
|
|
|
|
|
if (this.accent !== '') {
|
|
|
|
|
|
return this.accent;
|
|
|
|
|
|
}
|
|
|
|
|
|
const m: FbModuleDef | undefined = fbModuleOf('people');
|
|
|
|
|
|
return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
/** 取消 */
|
|
|
|
|
|
@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;
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 08:29:44 +08:00
|
|
|
|
/** 通用小开关(闰月 / 虚拟人物)
|
|
|
|
|
|
* ⚠️ 外层间距用 `margin` 而不是父容器的 `space`:这两个开关在 `Flex(wrap)` 里,
|
|
|
|
|
|
* `Flex` 没有 `space` 参数;margin 在换行后也能保持间距。
|
|
|
|
|
|
*/
|
2026-10-18 03:35:39 +08:00
|
|
|
|
@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 })
|
2026-10-19 08:29:44 +08:00
|
|
|
|
.margin({ right: 8, bottom: 6 })
|
2026-10-18 03:35:39 +08:00
|
|
|
|
.backgroundColor(on ? '#4A7C82' : FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
onTap();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 「+ 加一行」 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
addRowBtn(label: string, onTap: () => void) {
|
|
|
|
|
|
Text('+ ' + label)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor('#4A7C82')
|
|
|
|
|
|
.padding({ top: 4, bottom: 4 })
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
onTap();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 一行「说明 + 值 + 删」 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
contactRow(row: FbEditContactRow, placeholder: string, onDel: () => void) {
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
TextInput({ text: row.label, placeholder: '说明' })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(38)
|
|
|
|
|
|
.width(74)
|
|
|
|
|
|
.padding({ left: 8, right: 8 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
row.label = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
TextInput({ text: row.value, placeholder: placeholder })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(38)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.padding({ left: 10, right: 10 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
row.value = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
Text('✕')
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.faint)
|
|
|
|
|
|
.padding(6)
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
onDel();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 一行工作经历(两行输入叠放) */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
jobRow(row: FbEditJobRow, onDel: () => void) {
|
|
|
|
|
|
Column({ space: 6 }) {
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
TextInput({ text: row.org, placeholder: '单位' })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(38)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.padding({ left: 10, right: 10 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
row.org = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
Text('✕')
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.faint)
|
|
|
|
|
|
.padding(6)
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
onDel();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
TextInput({ text: row.title, placeholder: '职务' })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(36)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.padding({ left: 10, right: 10 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
row.title = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
TextInput({ text: row.rankName, placeholder: '职称' })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(36)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.padding({ left: 10, right: 10 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
row.rankName = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
TextInput({ text: row.startYm, placeholder: '起始 2015-07' })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(36)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.padding({ left: 10, right: 10 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
row.startYm = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
TextInput({ text: row.endYm, placeholder: '结束(空=至今)' })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(36)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.padding({ left: 10, right: 10 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
row.endYm = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding(10)
|
|
|
|
|
|
.backgroundColor('#F7F5F2')
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
build() {
|
|
|
|
|
|
// 🔴 外层 `Stack` 是**必需**的:本组件有**两个**要显示的层
|
|
|
|
|
|
// (编辑卡片 + 删除确认遮罩),而 build() 只能有一个根节点。
|
2026-10-19 08:29:44 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 🔴 2026-10-19 用户:「编辑,也弄成弹框,加线,保持一致。」
|
|
|
|
|
|
// ⇒ 原来是"贴底满宽 + 只有上面两个角圆",看起来是**抽屉**而不是弹框,
|
|
|
|
|
|
// 和简要卡/详情卡并排切换时观感断层。
|
|
|
|
|
|
// 现在与它们完全同一种形态:**92% 宽 + 四角圆角 + 屏幕居中 + 主题色环**。
|
|
|
|
|
|
// ⚠️ 居中对键盘不友好?——不会:输入框获得焦点时系统会整体上推页面,
|
|
|
|
|
|
// 居中卡片反而不会被键盘压住(贴底卡才会)。真机验证过。
|
|
|
|
|
|
Stack({ alignContent: Alignment.Center }) {
|
2026-10-18 03:35:39 +08:00
|
|
|
|
this.editCard()
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 删除确认(页面内遮罩,盖在卡片之上)----
|
|
|
|
|
|
if (this.confirmDel) {
|
|
|
|
|
|
this.deleteConfirm()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 编辑卡片本体 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
editCard() {
|
2026-10-19 08:29:44 +08:00
|
|
|
|
// ⚠️ 外面这一层只给**宽度**(92%):卡片自己必须是 100%,
|
|
|
|
|
|
// 否则 0.92×0.92 ≈ 84.6%,会比简要卡/详情卡窄一圈。
|
|
|
|
|
|
Column() {
|
|
|
|
|
|
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
|
|
|
|
|
|
Column({ space: 0 }) {
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// ---- 标题 ----
|
|
|
|
|
|
Row({ space: 12 }) {
|
|
|
|
|
|
FbAvatar({
|
|
|
|
|
|
name: this.person !== null ? this.person.shown : '',
|
|
|
|
|
|
color: this.person !== null ? this.person.avatarColor : '#A84A28',
|
|
|
|
|
|
dia: 40,
|
|
|
|
|
|
fontSizeVp: 16,
|
|
|
|
|
|
imageUrl: this.avatarUrl()
|
|
|
|
|
|
})
|
|
|
|
|
|
Column({ space: 2 }) {
|
|
|
|
|
|
Text(this.isCreate ? '添加成员' : '编辑资料')
|
|
|
|
|
|
.fontSize(FbMetric.fontTitle)
|
|
|
|
|
|
.fontWeight(FontWeight.Medium)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
Text(this.isCreate ? '填多少算多少,之后都能再改' : '全部字段都能改,改完点保存')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.faint)
|
|
|
|
|
|
}
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
// 换头像(单独走 multipart 接口)
|
|
|
|
|
|
Text('换头像')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor('#4A7C82')
|
|
|
|
|
|
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
|
|
|
|
|
.border({ width: 1, color: '#4A7C82' })
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
this.pickAvatar();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.alignItems(VerticalAlign.Center)
|
|
|
|
|
|
|
|
|
|
|
|
Scroll() {
|
|
|
|
|
|
Column({ space: 12 }) {
|
|
|
|
|
|
this.section('基本')
|
|
|
|
|
|
this.field('姓名', this.fName, '必填', (v: string): void => {
|
|
|
|
|
|
this.fName = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
// 🔴 「我怎么称呼他」—— 只有我看得到(视角属性),与姓名分开
|
|
|
|
|
|
this.field('我对他的称呼', this.fNickname,
|
|
|
|
|
|
'只影响你看到的叫法,留空 = 用姓名', (v: string): void => {
|
|
|
|
|
|
this.fNickname = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
this.genderPick()
|
|
|
|
|
|
this.field('家中排行', this.fBirthRank, '如 1(老大),留空表示没记', (v: string): void => {
|
|
|
|
|
|
this.fBirthRank = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
this.field('生日(阳历)', this.fBirthdaySolar, '如 1985-11-16', (v: string): void => {
|
|
|
|
|
|
this.fBirthdaySolar = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
this.field('生日(农历)', this.fBirthdayLunar, '如 1985-10-05', (v: string): void => {
|
|
|
|
|
|
this.fBirthdayLunar = v;
|
|
|
|
|
|
})
|
2026-10-19 08:29:44 +08:00
|
|
|
|
/* 🔴 2026-10-19:原来是一个 `Row({space:8}).width('100%')` ——
|
|
|
|
|
|
两个小开关并排时,"虚拟人物(还没弄清真实身份)"这一条**放不下**,
|
|
|
|
|
|
Row 只横向排、放不下就**直接溢出**,被卡片 `clip` 裁掉半句话
|
|
|
|
|
|
(真机截图里就是"…还没弄清真实"戛然而止)。
|
|
|
|
|
|
⇒ 换成 `Flex(wrap)`:放不下就**换行**,两个都不丢。
|
|
|
|
|
|
⚠️ 别改成给文字加 `maxLines(1)` + 省略号 —— 那句话是**解释功能**的,
|
|
|
|
|
|
截成"虚拟人物(还没弄清…"等于没解释。 */
|
|
|
|
|
|
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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%')
|
2026-10-19 08:29:44 +08:00
|
|
|
|
/* ⚠️ 高度上限写在**卡片自己**身上(不是环层):卡片里有
|
|
|
|
|
|
`Scroll().layoutWeight(1)`,它要靠父容器的确定高度算"还能滚多少"。
|
|
|
|
|
|
挪到外面 ⇒ 内层高度变成内容驱动 ⇒ 滚动区塌成一条线。 */
|
2026-10-18 03:35:39 +08:00
|
|
|
|
.constraintSize({ maxHeight: '82%' })
|
2026-10-19 08:29:44 +08:00
|
|
|
|
/* 居中形态**不再需要**底部手势区缓冲(FB_SHEET_BOTTOM_GAP):
|
|
|
|
|
|
卡片不贴屏幕底,手势区碰不到它。 */
|
|
|
|
|
|
.padding({ left: 18, right: 18, top: 18, bottom: 20 })
|
2026-10-18 03:35:39 +08:00
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
2026-10-19 08:29:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('92%')
|
2026-10-18 03:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 删除确认浮层。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 文案要**说清后果**:"会从名册里移除,不可撤销"——
|
|
|
|
|
|
* 只写"确定删除吗"用户不知道删的是"名册里这一条"还是"整个人的账号"。
|
|
|
|
|
|
* ⚠️ 默认焦点/位置:**取消在左、删除在右**,且删除用红色实心
|
|
|
|
|
|
* (进入确认这一层后,用户已经明确意图,可以让它显眼)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
deleteConfirm() {
|
|
|
|
|
|
Column() {
|
2026-10-19 08:29:44 +08:00
|
|
|
|
/* 🔴 2026-10-19:确认框同样加**主题色环** ——
|
|
|
|
|
|
用户要的是"所有弹框都加线",这一层虽然小,但它也是弹框;
|
|
|
|
|
|
漏掉它就又出现"有的有线、有的没线"的不一致。 */
|
|
|
|
|
|
Column() {
|
|
|
|
|
|
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
|
|
|
|
|
|
Column({ space: 8 }) {
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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 })
|
2026-10-19 08:29:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding(18)
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
}
|
2026-10-18 03:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height('100%')
|
|
|
|
|
|
// 遮罩盖满整屏(点空白处关掉 = 取消)
|
|
|
|
|
|
.justifyContent(FlexAlign.Center)
|
|
|
|
|
|
.padding({ left: 32, right: 32 })
|
2026-10-18 23:12:57 +08:00
|
|
|
|
.backgroundColor(FB_MASK)
|
2026-10-18 03:35:39 +08:00
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
this.confirmDel = false;
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 辅助
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
private avatarUrl(): string {
|
|
|
|
|
|
if (this.person === null) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⚠️ 与其他地方同一套:必须用 fbImageUrl 拼带令牌的绝对地址
|
|
|
|
|
|
// 🔴 v55:**不再自己拼**「基址 + /media.php?...」——
|
|
|
|
|
|
// 服务器地址已经是运行期可配置的(见 FbServerStore),
|
|
|
|
|
|
// 自己拼的人漏改一处就会对着旧服务器要图片(表现为"换了服务器头像全裂")。
|
2026-10-18 16:01:41 +08:00
|
|
|
|
return fbImageSrc(this.person.avatar, FB_TOKEN_STORE.get());
|
2026-10-18 03:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|