v0.8.4 简要卡观感 / 分享 / 名册搜索 / 全局返回手势(v59e)

八条需求的落地与真因:

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 个模块全报"抽屉里没找到"(像应用坏了,其实是探针过期)。
- 资源/笔记两页写死了早已不存在的条目(户口本/房产证/阳台改造清单)⇒ 改成
  结构性文案(副标题 + 常驻筛选胶囊),不随用户数据变化而恒红。
This commit is contained in:
2026-10-18 23:12:57 +08:00
parent 1c1a859464
commit 7858de27e3
26 changed files with 1213 additions and 257 deletions
+137
View File
@@ -0,0 +1,137 @@
/**
* 家书 FamilyBook · 「把一个人分享出去」(鸿蒙端)
*
* 🔴 用户 2026-10-18 原话:
* 「再增加一个功能,就是下面有一个按钮,详情、编辑、关闭,还有分享。
* 分享这个可以选择,比如说微信或什么的,以文本的形式,姓名、电话、邮箱,
* 这几个以文本的形式分享给别人。」
*
* 为什么用**系统分享面板**(Share Kit)而不是自己做「复制 / 发微信」:
* · 用户要的是"可以选择,比如说微信或什么的" —— 那正是系统分享面板的职责,
* 它会把手机上**所有认领了纯文本**的应用都列出来(微信 / 短信 / 邮件 / 备忘录…)。
* · 自己去拉起微信 = 依赖微信的包名与应用跳转白名单,换台手机就失效;
* 而且要维护"哪些应用在装"的判断,纯属自找。
*
* ⚠️ 分享内容是**纯文本**(`general.plain-text`),不是 vCard:
* 用户说得很清楚"以文本的形式,姓名、电话、邮箱"。vCard(.vcf)虽然
* "更像通讯录",但很多聊天窗口里发出去只是一张卡片,反而看不见内容。
* ⚠️ Share Kit 在**没有 Huawei 分享服务**的设备上可能不可用(`systemShare`
* 的 syscap 是 `SystemCapability.Collaboration.SystemShare`)
* ⇒ 必须兜一层**剪贴板**:弹不出面板时把同一段文本复制走并告诉用户,
* "分享没反应"是最让人恼火的一种失败。
*/
import { systemShare } from '@kit.ShareKit';
import { uniformTypeDescriptor } from '@kit.ArkData';
import { pasteboard } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbShare';
/**
* 分享一个人需要的字段。
*
* ⚠️ 只收**纯字符串**(不收 `FbRosterPerson`):
* 这个工具被"简要卡"和"详情卡"两处用,两处的数据类型不同(一个来自列表、
* 一个来自 `/people/<id>`),让它们各自拼好再传进来最省事,
* 也避免 `common/` 反向依赖 `data/`(本项目踩过循环 import)。
*/
export interface FbSharePerson {
/** 显示名(昵称优先) */
name: string;
/** 电话(主号在前) */
phones: string[];
/** 邮箱('' = 不显示) */
email: string;
}
/**
* 拼分享文本:**姓名 / 电话 / 邮箱** —— 用户点名的就是这三项。
*
* 例:
* ```
* 杨永全
* 电话:13800000000 / 0532-88888888
* 邮箱:yyq@example.com
* ```
* ⚠️ 第一行**只放名字**(不带"姓名:"前缀):在微信里发出去,第一行就是
* "这个人是谁",比"姓名:杨永全"更像一条通讯录信息。
* ⚠️ 空项**整行不出现**:`邮箱:` 后面空着会让对方以为漏了。
* ⚠️ **不要"顺手多给两行"**(单位 / 职务 / 生日…):用户原话只列了
* 「姓名、电话、邮箱,这几个」。这是一段会被发到第三方应用里的文本,
* 多写的每一个字段都是用户没想到会外发的信息。
*/
export function fbPersonShareText(p: FbSharePerson): string {
const lines: string[] = [];
lines.push(p.name);
if (p.phones.length > 0) {
const out: string[] = [];
for (const s of p.phones) {
if (s !== '') {
out.push(s);
}
}
if (out.length > 0) {
lines.push('电话:' + out.join(' / '));
}
}
if (p.email !== '') {
lines.push('邮箱:' + p.email);
}
return lines.join('\n');
}
/**
* 拉起系统分享面板。
*
* @returns `''` = 已拉起(用户看到面板了);非空 = **没能拉起**,
* 返回的是给用户看的一句话(此时内容已复制到剪贴板)。
*
* ⚠️ 为什么要返回"一句话"而不是布尔:失败时用户必须知道"内容去哪了",
* 只说"失败"他会以为白点了。
*/
export async function fbShareText(ctx: common.UIAbilityContext,
text: string, title: string): Promise<string> {
try {
const record: systemShare.SharedRecord = {
utd: uniformTypeDescriptor.UniformDataType.PLAIN_TEXT,
content: text,
title: title,
label: 'text',
description: '',
};
const data: systemShare.SharedData = new systemShare.SharedData(record);
const controller: systemShare.ShareController = new systemShare.ShareController(data);
await controller.show(ctx, {
selectionMode: systemShare.SelectionMode.SINGLE,
// 纯文本推荐用缩略卡(DETAIL 那张大预览图是给图片/视频的)
previewMode: systemShare.SharePreviewMode.DEFAULT,
});
return '';
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '系统分享不可用,退回剪贴板:%{public}s',
e.message !== undefined ? e.message : JSON.stringify(err));
return await fbCopyText(text);
}
}
/**
* 兜底:复制到剪贴板。
*
* @returns `''` = 复制成功;非空 = 失败提示
*/
export async function fbCopyText(text: string): Promise<string> {
try {
const pd: pasteboard.PasteData =
pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
await pasteboard.getSystemPasteboard().setData(pd);
return '已复制到剪贴板,可以粘贴到任何聊天窗口';
} catch (err) {
hilog.error(DOMAIN, TAG, '复制到剪贴板失败:%{public}s', JSON.stringify(err));
return '分享失败(本机没有可用的分享服务)';
}
}