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:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 系统返回手势的「谁先关」登记表(鸿蒙端)
|
||||
*
|
||||
* 🔴🔴 用户 2026-10-18 原话:
|
||||
* 「加入全局返回按键手势,现在在界面中使用系统的返回手势,是返回到桌面了,这样不好,
|
||||
* 如果有弹框,返回应该是关闭弹窗,或者返回上个页面,如果要退出 app,
|
||||
* 用户可以选择返回桌面的手势。在应用中给,应该是返回上一层才对。」
|
||||
*
|
||||
* 为什么需要这样一个"登记表"而不是在 `Index` 里集中判断:
|
||||
* 本应用是**单页 + 模块分发**(`Index.moduleContent()` 用 if/else 切页),
|
||||
* 没有路由栈。各页的浮层(详情 / 编辑 / 简要卡 / 设置)都是**页面自己的
|
||||
* `@Local` 状态**,`Index` 根本看不到它们(`Index` 只认识 `activeModule`)。
|
||||
* ⇒ 唯一不改动架构的做法:**各页把自己"关掉最上层东西"的能力登记进来**,
|
||||
* `Index.onBackPress()` 先问一遍登记表,再决定要不要拦。
|
||||
*
|
||||
* 🔴 关键的一条纪律:**登记的回调必须是"幂等 + 只关一层"**。
|
||||
* 返回手势每按一次调用一次;如果一次把三层浮层全关了,
|
||||
* 用户会觉得"我按一下退了三步"。
|
||||
*
|
||||
* ⚠️ 这里**不是响应式状态**(没有 @Trace / @ObservedV2):
|
||||
* 它不参与渲染,只是"回调列表"。放进 `FbAppState` 反而会污染那个状态类
|
||||
* (那里只放跨页面共享的界面状态)。
|
||||
* ⚠️ 用**数组 + key**而不是 `Map`:Map 的遍历顺序在规范里是插入序,
|
||||
* 但本项目里出现过"同名工具在不同 SDK 上行为不一致"的坑,
|
||||
* 数组的 `push/splice` 语义更死、也更好读。
|
||||
*/
|
||||
|
||||
/** 返回手势的处理函数:返回 true = **已消费**(不要再往下传) */
|
||||
export type FbBackHandler = () => boolean;
|
||||
|
||||
class FbBackHub {
|
||||
/** 登记顺序(后登记的**先**被问 —— 后挂载的页面在上层) */
|
||||
private keys: string[] = [];
|
||||
private fns: FbBackHandler[] = [];
|
||||
|
||||
/**
|
||||
* 登记(同 key 重复登记 = **覆盖**)。
|
||||
*
|
||||
* ⚠️ 必须允许重复登记:页面被 `if` 分支复用时,`aboutToAppear` 可能跑多次,
|
||||
* 而 `aboutToDisappear` 不一定每次都配对(ArkUI 的卸载时机)。
|
||||
* 覆盖语义保证"登记表里永远最多一条",不会越堆越多。
|
||||
*/
|
||||
register(key: string, fn: FbBackHandler): void {
|
||||
const i: number = this.keys.indexOf(key);
|
||||
if (i >= 0) {
|
||||
this.fns[i] = fn;
|
||||
return;
|
||||
}
|
||||
this.keys.push(key);
|
||||
this.fns.push(fn);
|
||||
}
|
||||
|
||||
/** 注销(页面卸载时调) */
|
||||
unregister(key: string): void {
|
||||
const i: number = this.keys.indexOf(key);
|
||||
if (i < 0) {
|
||||
return;
|
||||
}
|
||||
this.keys.splice(i, 1);
|
||||
this.fns.splice(i, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从**最后登记的**往前问,第一个说"我消费了"的就停下。
|
||||
*
|
||||
* ⚠️ 顺序必须是**倒序**:同一屏里可能挂了两层(页面的详情浮层 +
|
||||
* 顶栏的 `bindSheet`),后挂载的 / 更上层的应该先响应。
|
||||
*/
|
||||
back(): boolean {
|
||||
for (let i = this.fns.length - 1; i >= 0; i--) {
|
||||
if (this.fns[i]()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例 */
|
||||
export const FB_BACK: FbBackHub = new FbBackHub();
|
||||
@@ -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 '分享失败(本机没有可用的分享服务)';
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,18 @@ export class FbAppState {
|
||||
*/
|
||||
scrollAcc: number = 0;
|
||||
|
||||
/**
|
||||
* 模块访问历史(**返回手势**用)。
|
||||
*
|
||||
* 🔴 v59e(用户 2026-10-18):系统返回手势原来直接退到桌面,用户不接受:
|
||||
* 「如果有弹框,返回应该是关闭弹窗,或者返回上个页面……
|
||||
* 在应用中给,应该是返回上一层才对。」
|
||||
*
|
||||
* ⚠️ 不加 `@Trace`:它不参与渲染,只在 `backToPrevModule()` 里被读。
|
||||
* 加了会让每次切页都触发一次无谓的界面刷新。
|
||||
*/
|
||||
moduleHistory: string[] = [];
|
||||
|
||||
/**
|
||||
* **横向滚动"占用"标记**:记录最近一次横向滚动发生的时刻(ms;0 = 从没发生)。
|
||||
*
|
||||
@@ -182,6 +194,30 @@ export class FbAppState {
|
||||
|
||||
/** 切模块:同时收起抽屉 */
|
||||
switchModule(key: string): void {
|
||||
/* 🔴 v59e(用户 2026-10-18):「加入全局返回按键手势……在应用中给,
|
||||
应该是返回上一层才对。」
|
||||
⇒ 换模块时把"从哪来"记下来,返回手势才有地方可回。
|
||||
⚠️ 只在**真的换了**的时候记(点同一个模块不该产生历史);
|
||||
最多留 12 步:本应用模块就十来个,无限堆只会让"返回"越退越远、
|
||||
退到一个用户早就不记得的页面。
|
||||
⚠️ 历史**不是渲染状态**,所以不加 `@Trace`(与 `scrollAcc` 同理)。 */
|
||||
if (key !== this.activeModule) {
|
||||
this.moduleHistory.push(this.activeModule);
|
||||
if (this.moduleHistory.length > 12) {
|
||||
this.moduleHistory.shift();
|
||||
}
|
||||
}
|
||||
this.applyModule(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正切换(**不记历史**)。
|
||||
*
|
||||
* ⚠️ 返回手势走 `backToPrevModule()` 时必须用它:用 `switchModule()`
|
||||
* 会把"当前页"再压回历史 ⇒ 在 A/B 两页之间按返回会**来回弹**,
|
||||
* 永远退不出去(典型的"返回键坏了")。
|
||||
*/
|
||||
private applyModule(key: string): void {
|
||||
this.activeModule = key;
|
||||
this.drawerOpen = false;
|
||||
// 🔴 换页要**复位滚动累积**:那是跨页面共享的,
|
||||
@@ -189,6 +225,28 @@ export class FbAppState {
|
||||
this.resetScroll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回上一个模块(返回手势用)。
|
||||
*
|
||||
* @returns true = 已经换到了别的模块;false = 没有可返回的地方(在首页)
|
||||
*/
|
||||
backToPrevModule(): boolean {
|
||||
while (this.moduleHistory.length > 0) {
|
||||
const prev: string = this.moduleHistory.pop() as string;
|
||||
// 历史里可能残留"当前页"(比如 A→B→A 之后栈里还有 A),跳过它
|
||||
if (prev !== this.activeModule) {
|
||||
this.applyModule(prev);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 没有历史(比如刚启动就切了一次模块)⇒ 兜底回首页
|
||||
if (this.activeModule !== 'home') {
|
||||
this.applyModule('home');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 有浮层开着吗(悬浮栏据此让位) */
|
||||
sheetOpen(): boolean {
|
||||
return this.sheetCount > 0;
|
||||
|
||||
@@ -354,4 +354,67 @@ function fbIconOf(name: string): Resource {
|
||||
case 'fb_ic_trash': return $r('app.media.fb_ic_trash');
|
||||
default: return $r('app.media.fb_ic_dot');
|
||||
}
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// 透明度色的**唯一正确写法**
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 🔴🔴 ArkUI 的 8 位十六进制颜色是 `#AARRGGBB`(**透明度在前**),
|
||||
* 不是 Web/CSS 的 `#RRGGBBAA`。
|
||||
*
|
||||
* 本项目为此踩了一个"看不见的 bug"(2026-10-18 真机像素采样定位):
|
||||
* · 遮罩写的是 `'#00000066'` —— 本意"黑色 40%",实际是 **alpha=0x00**
|
||||
* ⇒ **完全透明** ⇒ 全应用所有"页面内浮层"的遮罩**从来没显示过**;
|
||||
* 用户说的「弹框和下面白色的列表糊在一起,根本看不出是弹框」,
|
||||
* 根因就是这个 —— 不是卡片样式不够,而是**遮罩压根没画出来**。
|
||||
* · `brand + '1A'`(浅色底)实际变成"30% 不透明的橄榄色"
|
||||
* ⇒ 主题色浅底看着发黄发脏,而不是"很淡的同色"。
|
||||
* · `shadow({color:'#00000033'})` 同理 ⇒ 阴影也不存在。
|
||||
*
|
||||
* 证据(同一次真机截图里取像素):
|
||||
* 实底 `brand` 采样 (77,131,137);浅底按钮采样 (217,219,186)。
|
||||
* 按 AARRGGBB 算:`#4D83891A` = alpha 0x4D(30%) × (131,137,26) 叠白
|
||||
* = (218,219,186) ✓ 与采样一致;按 RRGGBBAA 算应是 (237,242,243) ✗。
|
||||
*
|
||||
* ⚠️ 所以:**任何带透明度的颜色都必须走下面这两个工具**,不要手写 8 位 hex,
|
||||
* 也不要再"在颜色串后面拼两位" —— 拼在后面就是拼到了蓝色通道上。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 给 `#RRGGBB` 加透明度,产出 ArkUI 认识的 `#AARRGGBB`。
|
||||
*
|
||||
* @param hex 六位十六进制(`#RRGGBB`;也容忍八位,会先截成前六位)
|
||||
* @param alpha 0~1(0.4 = 40% 不透明)
|
||||
*/
|
||||
export function fbAlpha(hex: string, alpha: number): string {
|
||||
let base: string = hex.trim();
|
||||
if (base.startsWith('#')) {
|
||||
base = base.substring(1);
|
||||
}
|
||||
if (base.length > 6) {
|
||||
// 已经是八位:取**后六位**当颜色(历史写法把透明度拼在了末尾)
|
||||
base = base.substring(base.length - 6);
|
||||
}
|
||||
let a: number = Math.round(alpha * 255);
|
||||
if (a < 0) {
|
||||
a = 0;
|
||||
}
|
||||
if (a > 255) {
|
||||
a = 255;
|
||||
}
|
||||
let aa: string = a.toString(16).toUpperCase();
|
||||
if (aa.length < 2) {
|
||||
aa = '0' + aa;
|
||||
}
|
||||
return '#' + aa + base.toUpperCase();
|
||||
}
|
||||
|
||||
/** 页面内浮层的遮罩(黑色 40%)—— 全应用**同一档**,两级浮层不能深浅不一 */
|
||||
export const FB_MASK: string = '#66000000';
|
||||
|
||||
/** 浮层卡片阴影(黑色 20%) */
|
||||
export const FB_SHADOW: string = '#33000000';
|
||||
|
||||
/** 卡片描边(黑色 8%,同色背景上仍有一条可见界线) */
|
||||
export const FB_HAIRLINE: string = '#14000000';
|
||||
|
||||
Reference in New Issue
Block a user