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
+80
View File
@@ -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();
+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 '分享失败(本机没有可用的分享服务)';
}
}
+58
View File
@@ -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;
+63
View File
@@ -355,3 +355,66 @@ function fbIconOf(name: string): Resource {
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';
+2 -2
View File
@@ -24,7 +24,7 @@
* 自己用 Stack + 位移动画做,行为完全可控。
*/
import { FbColors, FbIcon, FbMetric, fbTint, FbTint } from '../common/Theme';
import { FbColors, FbIcon, FbMetric, fbTint, FbTint, fbAlpha } from '../common/Theme';
import { FbAppState, fbAppState } from '../common/Store';
import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules';
import { FB_DEFAULT_SLOGAN } from '../model/DemoData';
@@ -229,7 +229,7 @@ export struct FbDrawer {
private washOf(hex: string): string {
// 与 Theme.fbPalette 的 wash 同口径:压饱和、抬明度到 0.94
const t = fbThemeVars(hex);
return t.brand + '1A';
return fbAlpha(t.brand, 0.10);
}
build() {
@@ -36,7 +36,7 @@
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbColors, FbMetric, FB_MASK } from '../common/Theme';
import { FbCalEventFull, FbCalEventInput, FbCalRepo } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
@@ -780,7 +780,7 @@ export struct FbEventEditSheet {
.height('100%')
.justifyContent(FlexAlign.Center)
.padding({ left: 32, right: 32 })
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.confirmDel = false;
})
@@ -242,7 +242,7 @@ export struct FbMovieDetailSheet {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000E6')
.backgroundColor('#E6000000')
.onClick((): void => {
this.viewerOpen = false;
})
@@ -22,7 +22,7 @@
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric } from '../common/Theme';
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';
@@ -1036,8 +1036,8 @@ export struct FbPersonEditSheet {
/* 🔴 v59c:与 `FbPersonSheet` / `FbPersonQuickSheet` 同一套"三层分离"
(用户 2026-10-18:「白色和下面列表的白色融合在一起,看不出是弹框」)。
⚠️ 三个浮层必须**同手段**,否则从简要卡 → 编辑切过去会突然"变平"。 */
.shadow({ radius: 24, color: '#00000033', offsetX: 0, offsetY: 6 })
.border({ width: 1, color: '#00000014' })
.shadow({ radius: 24, color: FB_SHADOW, offsetX: 0, offsetY: 6 })
.border({ width: 1, color: FB_HAIRLINE })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
@@ -1045,7 +1045,7 @@ export struct FbPersonEditSheet {
// 遮罩盖满整屏(点空白处关掉 = 取消)
.justifyContent(FlexAlign.Center)
.padding({ left: 32, right: 32 })
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.confirmDel = false;
})
@@ -1,30 +1,42 @@
/**
* 家书 FamilyBook · 名册「简要卡片」(鸿蒙端)
*
* 🔴🔴 用户 2026-10-18 原话:
* 🔴 用户 2026-10-18 原话(第一版):
* 「点击列表,不打电话了,容易误触。而是弹出一个卡片,卡片中是头像、
* 名称、电话号码、邮箱等简要信息,下面三个按钮,详情、编辑和删除。」
* 🔴 用户 2026-10-18 原话(第二版,本文件当前形态):
* 「点击按钮后弹出这个简单框,不要加删除按钮了,删除按钮太容易误触。
* 应该在详情页面的最下面加一个删除按钮。」
* 「电话和单位什么的距离太近了,再拉远一点。」
* 「上面的电话点击之后就可以直接拨打。同时……下面有一个按钮,
* 详情、编辑、关闭,还有分享。」
* 「弹出的这个框,现在颜色和下面的白色都混到一起了……能不能在这个边框外面
* 加一圈这个颜色,也就是这个主题色。」
*
* 为什么要单独一个组件(而不是在名册页里再写一个 `@Builder`):
* · 它是**一张自己的卡**(与"完整详情"是两级),有自己的按钮组与布局;
* · 名册页那个文件已经 1300+ 行,再往里塞会失控;
* · 将来"从日程里点参会人"也可能要弹同一张卡 ⇒ 组件化才复用得上。
* ⇒ 于是本卡片相比第一版有四处变化,逐条对应在下面的代码注释里:
* ① 「删除」**移走**(挪到 `FbPersonSheet` 的最下面,见那边的 `onDelete`);
* ② 信息行**拉开间距**(`INFO_GAP`,从 10 → 16);
* ③ 主电话那一行**可点即拨**(发 `onCall` 事件,真正拨号在页面侧);
* ④ 底下四个按钮:详情 / 编辑 / 分享 / 关闭。
*
* 🔴 关于「弹框与下面列表都是白的、看不出是弹框」(用户同一条反馈里提的):
* 本卡片的解法是**三层分离**(缺一层就会"糊在一起"):
* ① 宿主给**深色遮罩**(见调用点,`#00000066`)—— 把下面的列表压暗;
* ② 卡片自己**加阴影**(`shadow`)—— 与遮罩拉开"浮起来"的层次;
* ③ 卡片底色用**纸白**、并给一圈**极浅描边** —— 即使在同色背景上,
* 边缘也有一条可见的界线(只靠阴影在浅底上是不够的)。
* 🔴 「卡片和下面的白色糊在一起」的三层解法(缺一层就会"看不清"):
* ⚠️ 只做①(很多地方只做遮罩)在"背景也是大面积白"时效果最弱:
* 白卡压在"被压暗的白"上,边界仍然发飘 —— 用户说的就是这种观感。
* ① 宿主给**深色遮罩**(见调用点,`#00000066`)—— 把下面的列表压暗;
* ② 卡片外面**一圈主题色描边环**(用户这次点名要的)—— 它是**实色**,
* 不依赖光照假设,白底白遮罩上也一眼看得出边界在哪;
* ③ 卡片自己**加阴影** —— 与遮罩拉开"浮起来"的层次。
* ⚠️ ② 的实现是"外层 `Column` 铺主题色 + 内边距 2 + 内层白卡",
* **不是**给白卡加 `border`:`border` 是画在盒子边界上的,
* 会把卡片内部撑窄 2vp,且圆角处颜色会顺着内圆角走(看着像"白卡描了彩边")。
* 铺一层再垫一张,才是真正的"外面加一圈"。
*
* ⚠️ 「删除」是**破坏性操作**,必须二次确认(见调用点的 `AlertDialog`)。
* 卡片这里**只负责发事件**,不自己做确认也不自己调接口 ——
* 与 `FbPersonSheet` 的做法一致(浮层之间不互相引用,免得成环)。
* ⚠️ 本组件**只发事件、不自己做动作**(不拨号、不删除、不弹确认):
* 拨号要 AbilityContext、删除要二次确认 + 删完刷新列表、分享要拼文本,
* 这些都是**页面**的职责。浮层之间也不互相引用(免得成环)。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../common/Theme';
import { FbColors, FbMetric, FB_SHADOW, fbTint, FbTint, fbAlpha } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbRosterPerson } from '../data/FbPersonRepo';
import { FbAvatar, FbTag } from './Widgets';
@@ -35,14 +47,32 @@ import { FB_TOKEN_STORE } from '../data/FbTokenStore';
const CARD_SHADOW: number = 24;
/**
* 「删除」用的红 —— 与 `resources/base/element/color.json` 的 `red` **同值**。
* 主题色描边环的宽度(vp)。
*
* ⚠️ 不能直接用 `FbColors.red`(那是 `Resource`):本文件要拿它拼
* `RED + '1A'`(透明度后缀)做底色,`Resource` 拼不了字符串。
* ⇒ 凡是要参与**颜色运算**的地方都用 hex(本项目已有 `FbTint` 这组先例)。
* ⚠️ 2vp 是"看得见但不喧哗"的那一档:1vp 在深色遮罩上仍显得细弱,
* 3vp 以上就变成"彩色相框",抢了内容和按钮的注意力。
*/
const RING_W: number = 2;
/**
* 信息行之间的间距(vp)。
*
* 🔴 用户 2026-10-18:「电话和单位什么的距离太近了,再拉远一点。」
* ⇒ 原来是 10(`Column({space: 10})`),现在 16,并且每行自己还有
* 上下 3vp 的内边距(见 `infoRow` 的 `padding`)⇒ 实际行距约 22vp。
*/
const INFO_GAP: number = 16;
/**
* 「关闭」按钮的底色 —— 与 `resources/base/element/color.json` 的
* `input_bg`(`FbColors.inputBg`)**同值**。
*
* ⚠️ 为什么不用 `FbColors.inputBg`:那是 `Resource`,而 `actionBtn` 的
* `bg/fg` 参数必须是 hex 字符串(要为「编辑/分享」做 `fbAlpha(bg, 0.10)` 的颜色运算)。
* ⇒ 与 `FbTint` 同一套处理方式:需要"参与运算"的颜色,一律在端上抄一份 hex。
* ⚠️ color.json 改色时这里要同步改(只有这一个值,风险可控)。
*/
const RED: string = '#C0533F';
const INPUT_BG: string = '#F2EDE2';
@ComponentV2
export struct FbPersonQuickSheet {
@@ -67,10 +97,24 @@ export struct FbPersonQuickSheet {
};
/**
* 「删除」:只发事件。
* ⚠️ 二次确认与调接口都在页面侧 —— 那里才知道"删完怎么刷新列表"。
* 「分享」:把这个人以**纯文本**(姓名 / 电话 / 邮箱)分享出去。
*
* ⚠️ 文本的拼接与系统分享面板都在页面侧(`common/FbShare.ets`)——
* 本组件只管"用户点了这一下"。
*/
@Event onDelete: () => void = (): void => {
@Event onShare: () => void = (): void => {
};
/**
* 「拨号」:点主电话那一行触发。
*
* 🔴 用户 2026-10-18:「上面的电话点击之后就可以直接拨打。」
* ⚠️ 传号码出去(而不是让页面自己去 person 里取):
* 将来"其他电话"也想点的时候,这里不用改签名。
* ⚠️ 实际动作是 `call.makeCall()` = **拉起拨号盘并填好号码**,
* 最后一下由用户按(合规要求,也是"不误触"的保险)。
*/
@Event onCall: (phone: string) => void = (phone: string): void => {
};
/** 名册模块的主题色(按钮配色与整个名册页同一个色系) */
@@ -147,7 +191,13 @@ export struct FbPersonQuickSheet {
return org !== '' ? org : title;
}
/** 一行「图标 + 标签 + 值」 */
/**
* 一行「图标 + 标签 + 值」。
*
* ⚠️ 上下各留 3vp 的内边距:`Column({space})` 的间距是**盒子之间**的距离,
* 行内的文字本身贴着盒子边,只调 space 会觉得"怎么拉还是挤"。
* 两者一起给才是真正的"拉开"。
*/
@Builder
infoRow(icon: Resource, label: string, value: string) {
Row({ space: 10 }) {
@@ -168,22 +218,68 @@ export struct FbPersonQuickSheet {
.layoutWeight(1)
}
.width('100%')
.padding({ top: 3, bottom: 3 })
.alignItems(VerticalAlign.Center)
}
/**
* 一个动作按钮。
* 可点即拨的电话行(主号专用)。
*
* ⚠️ 三个按钮等宽平分(`layoutWeight(1)`)—— 用户要"下面三个按钮",
* 等宽是最不容易出错、也最像"选择"的排法。
* 🔴 用户 2026-10-18:「上面的电话点击之后就可以直接拨打。」
*
* ⚠️ 视觉上必须**明确"这一行能点"**(否则用户不会去点):
* 号码用主题色、右侧挂一个电话小图标、整行有浅色底与圆角 ——
* 与上面那些"只是文字"的行形成对比。
* ⚠️ 点击区域是**整行**(不是只有号码文字):手指在手机上点单行文字
* 命中率很低,"点歪了没反应"会被当成 bug。
*/
@Builder
callRow(phone: string) {
Row({ space: 10 }) {
Image($r('app.media.fb_ic_user'))
.width(16)
.height(16)
.colorFilter(fbTint(this.brand()))
Text('电话')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width(52)
Text(phone)
.fontSize(FbMetric.fontSmall)
.fontColor(this.brand())
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
/* ⚠️ 右侧不挂图标:本工程的 `media/` 里**没有电话图标**
(只有 fb_ic_user / notes / people / days / share 这一批)。
与其为了一个小提示去加一张新图(还要跟着主题染两份色),
不如用一枚文字胶囊 —— 它更直白:"这里可以拨打"。 */
Text('拨打')
.fontSize(10)
.fontColor(FbTint.white)
.backgroundColor(this.brand())
.borderRadius(FbMetric.radiusPill)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
.width('100%')
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.backgroundColor(fbAlpha(this.brand(), 0.08))
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onCall(phone);
})
}
/**
* 一个动作按钮。
*
* ⚠️ `fg`/`bg` 必须是 **hex 字符串**(不是 `Resource`):
* 这里要拿它做**颜色运算**(`bg + '1A'` 加透明度后缀),
* `Resource` 参与不了字符串拼接。红色那个因此用与
* `color.json` 的 `red` **同值**的字面量(见 `RED`)。
* 这里要拿它做**颜色运算**(`fbAlpha(bg, 0.10)` 加透明度后缀),
* `Resource` 参与不了字符串拼接。
* ⚠️ 四个按钮等宽平分(`layoutWeight(1)`)—— 等宽是最不容易出错、
* 也最像"选择"的排法。
*/
@Builder
actionBtn(label: string, fg: string, bg: string, fire: () => void) {
@@ -203,108 +299,116 @@ export struct FbPersonQuickSheet {
build() {
Column() {
Column({ space: 14 }) {
// ---- 头像 + 姓名 + 称谓 ----
Row({ space: 14 }) {
FbAvatar({
name: this.person !== null ? this.person.name : '',
color: this.person !== null ? this.person.avatarColor : '#A84A28',
dia: 54,
fontSizeVp: 22,
imageUrl: this.avatarSrc()
})
Column({ space: 5 }) {
Row({ space: 8 }) {
Text(this.title())
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.flexShrink(1)
if (this.person !== null && this.person.label !== '') {
FbTag({ text: this.person.label, tone: 'muted' })
/* 🔴 ② 主题色描边环(用户 2026-10-18 点名要的那一圈)
⚠️ 它是"外层铺色 + 内边距",不是 `border` —— 见文件头说明。 */
Column() {
Column({ space: 14 }) {
// ---- 头像 + 姓名 + 称谓 ----
Row({ space: 14 }) {
FbAvatar({
name: this.person !== null ? this.person.name : '',
color: this.person !== null ? this.person.avatarColor : '#A84A28',
dia: 54,
fontSizeVp: 22,
imageUrl: this.avatarSrc()
})
Column({ space: 5 }) {
Row({ space: 8 }) {
Text(this.title())
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.flexShrink(1)
if (this.person !== null && this.person.label !== '') {
FbTag({ text: this.person.label, tone: 'muted' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
if (this.person !== null && this.person.name !== this.title()) {
Text('本名 ' + this.person.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.person !== null && this.person.name !== this.title()) {
Text('本名 ' + this.person.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
// ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)----
//
// 🔴 用户 2026-10-18:「电话和单位什么的距离太近了,再拉远一点。」
// ⇒ `INFO_GAP`(16)+ 每行自己的上下 3vp 内边距。
Column({ space: INFO_GAP }) {
if (this.mainPhone() !== '') {
// ③ 主电话:**可点即拨**(其余电话仍只是文字,避免误触)
this.callRow(this.mainPhone())
}
if (this.otherPhones() !== '') {
this.infoRow($r('app.media.fb_ic_user'), '其他', this.otherPhones())
}
if (this.person !== null && this.person.email !== '') {
this.infoRow($r('app.media.fb_ic_notes'), '邮箱',
this.person !== null ? this.person.email : '')
}
if (this.jobText() !== '') {
this.infoRow($r('app.media.fb_ic_people'), '单位', this.jobText())
}
if (this.birthdayText() !== '') {
this.infoRow($r('app.media.fb_ic_days'), '生日', this.birthdayText())
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)----
Column({ space: 10 }) {
if (this.mainPhone() !== '') {
this.infoRow($r('app.media.fb_ic_user'), '电话', this.mainPhone())
}
if (this.otherPhones() !== '') {
this.infoRow($r('app.media.fb_ic_user'), '其他', this.otherPhones())
}
if (this.person !== null && this.person.email !== '') {
this.infoRow($r('app.media.fb_ic_notes'), '邮箱',
this.person !== null ? this.person.email : '')
}
if (this.jobText() !== '') {
this.infoRow($r('app.media.fb_ic_people'), '单位', this.jobText())
}
if (this.birthdayText() !== '') {
this.infoRow($r('app.media.fb_ic_days'), '生日', this.birthdayText())
}
}
.width('100%')
// ---- 三个动作:详情 / 编辑 / 删除 ----
//
// ⚠️ 「删除」用红色(语义色),其余两个是主题色的深浅两档:
// 同一组按钮里,"哪个是破坏性的"必须一眼看出来。
Row({ space: 10 }) {
this.actionBtn('详情', FbTint.white, this.brand(), (): void => {
this.onDetail();
})
this.actionBtn('编辑', this.brand(), this.brand() + '1A', (): void => {
this.onEdit();
})
this.actionBtn('删除', RED, RED + '1A', (): void => {
this.onDelete();
})
}
.width('100%')
// ---- 关闭 ----
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.width('100%')
.height(38)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onClose();
})
// ---- 四个动作:详情 / 编辑 / 分享 / 关闭 ----
//
// 🔴 「删除」**不在这里**(用户 2026-10-18:太容易误触)——
// 它挪到了完整详情卡片的最下面(`FbPersonSheet` 的 `onDelete`)。
// ⚠️ 「关闭」也做成**同等大小的按钮**(而不是一行文字链接):
// 四个动作同一个视觉权重,用户不用在"哪个是按钮"上犹豫。
// 它是灰色(`inputBg` + 次要字色)—— 语义上"这一下什么都不改"。
Row({ space: 8 }) {
this.actionBtn('详情', FbTint.white, this.brand(), (): void => {
this.onDetail();
})
this.actionBtn('编辑', this.brand(), fbAlpha(this.brand(), 0.10), (): void => {
this.onEdit();
})
this.actionBtn('分享', this.brand(), fbAlpha(this.brand(), 0.10), (): void => {
this.onShare();
})
this.actionBtn('关闭', FbTint.muted, INPUT_BG, (): void => {
this.onClose();
})
}
.width('100%')
}
/* 🔴 卡片本体:见文件头「三层分离」的说明。
⚠️ 宽度 **100%**(不是 92%):92% 由**外面那圈环**负责,
这里再写 92% 就成了 0.92×0.92 ≈ 84.6%,卡片会比详情卡窄一圈。
⚠️ 四角圆角 + 居中 —— 与 `FbPersonSheet` 同一套观感
(用户 2026-10-16 定:"圆角浮窗式、不占满宽、浮在屏幕中间")。 */
.width('100%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.backgroundColor(FbColors.surface)
/* ⚠️ 内层圆角要**比外层小一档**(14 vs 16):两圈同心圆角差值
= 环宽 2,否则环在内侧会显厚、外侧会显薄(看着像描边歪了)。 */
.borderRadius(FbMetric.radius)
/* ③ 阴影:把卡片从"被压暗的列表"上抬起来 */
.shadow({ radius: CARD_SHADOW, color: FB_SHADOW, offsetX: 0, offsetY: 6 })
.alignItems(HorizontalAlign.Start)
}
/* 🔴 卡片本体:见文件头「三层分离」的说明。
⚠️ `width('92%')` + 四角圆角 + 居中 —— 与 `FbPersonSheet` 同一套观感
(用户 2026-10-16 定:"圆角浮窗式、不占满宽、浮在屏幕中间")。 */
.width('92%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
/* ② 阴影:把卡片从"被压暗的列表"上抬起来 */
.shadow({ radius: CARD_SHADOW, color: '#00000033', offsetX: 0, offsetY: 6 })
/* ③ 极浅描边:同色背景上仍有一条可见界线(只靠阴影在浅底上不够) */
.border({ width: 1, color: '#00000014' })
.alignItems(HorizontalAlign.Start)
.padding(RING_W)
.backgroundColor(this.brand())
.borderRadius(FbMetric.radius + RING_W)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
@@ -24,7 +24,7 @@
* 只是缺那几项详情专属字段,界面不会空。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_HAIRLINE, FB_SHADOW, fbAlpha } from '../common/Theme';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
import { FbAvatar, FbTag } from './Widgets';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
@@ -39,6 +39,15 @@ import { fbImageSrc } from '../data/FbAttachQueue';
*/
const FB_SHEET_BOTTOM_GAP: number = 16;
/**
* 「删除」用的红 —— 与 `resources/base/element/color.json` 的 `red` **同值**。
*
* ⚠️ 不能直接用 `FbColors.red`(那是 `Resource`):本文件要拿它拼
* `fbAlpha(RED, 0.08)`(透明度后缀)做底色,`Resource` 拼不了字符串。
* ⚠️ color.json 改色时这里要同步改(只有这一个值,风险可控)。
*/
const RED: string = '#C0533F';
@ComponentV2
export struct FbPersonSheet {
@@ -56,6 +65,33 @@ export struct FbPersonSheet {
*/
@Event onEdit: () => void = (): void => {
};
/**
* 「删除」回调。
*
* 🔴🔴 用户 2026-10-18 原话:
* 「点击按钮后弹出这个简单框,不要加删除按钮了,删除按钮太容易误触。
* 应该在**详情页面的最下面**加一个删除按钮。」
*
* ⇒ 「删除」从"简要卡"(`FbPersonQuickSheet`)挪到了这里,并且放在
* **整张卡的最下面**、与编辑/关闭之间隔一条分隔线 ——
* 这是"危险区"的通用做法:多一层遮挡、手指不会顺手扫到。
*
* ⚠️ 与别的事件一样:**只发事件**。二次确认与调接口都在页面侧
* (那里才知道"删完怎么刷新列表")。
*/
@Event onDelete: () => void = (): void => {
};
/**
* 是否显示「删除」按钮。
*
* ⚠️ 顶栏那个「我的详情」(`TopBar.myDetailPanel`)要传 `false`:
* 删除自己既没意义、又很危险(服务端是物理删除,删完连账号都进不去)。
* 默认 `true` = 名册里的详情卡(那里的删除是正常的)。
*/
@Param canDelete: boolean = true;
/**
* 嵌入模式:宿主**自己已经是浮层**(如 `bindSheet`)时为 true。
*
@@ -481,6 +517,41 @@ export struct FbPersonSheet {
}
.width('100%')
.margin({ top: 18 })
/* ---- 危险区:删除联系人 ----
*
* 🔴 用户 2026-10-18:「应该在详情页面的最下面加一个删除按钮。」
* ⇒ 位置在**编辑/关闭之下**、并且与它们之间隔一条分隔线:
* 手指从上往下扫时不会顺手点到;想删必须**再往下够**一次。
* ⚠️ 视觉上只给文字 + 图标(不做成大红块):
* 它是"偶尔用一次"的操作,大红按钮会抢走"编辑"的注意力。
* ⚠️ 确认框在页面侧(`FbPeoplePage.confirmDelete`)—— 见 `onDelete` 的说明。 */
if (this.canDelete) {
Divider()
.color(FbColors.line)
.margin({ top: 14 })
Row({ space: 6 }) {
Image($r('app.media.fb_ic_trash'))
.width(14)
.height(14)
/* ⚠️ PNG 图标必须 colorFilter(fillColor 只对 SVG 生效) */
.colorFilter(fbTint(RED))
Text('删除联系人')
.fontSize(FbMetric.fontSmall)
.fontColor(RED)
.fontWeight(FontWeight.Medium)
}
.width('100%')
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(fbAlpha(RED, 0.08))
.borderRadius(FbMetric.radiusS)
.margin({ top: 10 })
.onClick((): void => {
this.onDelete();
})
}
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
@@ -512,8 +583,8 @@ export struct FbPersonSheet {
* ⚠️ 只加阴影是不够的:浅色底上的投影很淡,真机上常常看不出来;
* 描边才是"稳"的那一手(它不依赖光照假设)。
*/
.shadow({ radius: 24, color: '#00000033', offsetX: 0, offsetY: 6 })
.border({ width: 1, color: '#00000014' })
.shadow({ radius: 24, color: FB_SHADOW, offsetX: 0, offsetY: 6 })
.border({ width: 1, color: FB_HAIRLINE })
.alignItems(HorizontalAlign.Start)
}
/* 🔴 embedded(宿主自己就是 sheet)时**不能再铺满 + 居中**:
+4
View File
@@ -413,6 +413,10 @@ export struct FbTopBar {
`AceDrag: Drag gesture has been canceled` —— 起来立刻被关**,
界面上看就是"点了没反应"(极易误判成"点击没接上")。 */
embedded: true,
/* 🔴 不给「删除」:这是"我自己的详情"。
删除自己既没意义又危险(服务端是物理删除,删完账号都进不去)。
名册里的详情卡才显示删除(那里 `canDelete` 取默认值 true)。 */
canDelete: false,
onClose: (): void => {
this.myDetailSheet = false;
},
+20
View File
@@ -342,6 +342,26 @@ export class FbDb {
}
}
// ---- v6 → v7:`persons.initials`(拼音首字母)----
//
// 🔴🔴 症状(用户 2026-10-18):「名册的搜索是完全不能用的」。
// 过滤逻辑本身是对的,坏在**数据**:`rowToPerson` 把 `initials` 写死成空串
// ⇒ 「输入 yy 搜杨永全」永远 0 结果(中文名包含匹配还能用,拼音首字母全废)。
// 服务端 `/roster` 一直都在下发 `initials`,只是端上没这一列可存。
//
// ⚠️ 与 v6 同一套写法:先 `PRAGMA` 探测再 ALTER。
// 升上来之后第一次同步(进名册页就会跑)才把值填进去 ——
// 迁移**只加列不给值**,不要试图在这里写死任何东西。
if (from < 7) {
const has: boolean = await this.columnExists(store, 'persons', 'initials');
if (has) {
hilog.info(DOMAIN, TAG, 'v7 迁移:persons.initials 已在(跳过 ALTER)');
} else {
await store.executeSql('ALTER TABLE persons ADD COLUMN initials TEXT');
hilog.info(DOMAIN, TAG, 'v7 迁移完成:补上 persons.initials(拼音搜索)');
}
}
store.version = FB_DB_VERSION;
}
+15 -3
View File
@@ -328,6 +328,16 @@ export class FbPersonRepo {
b['org'] = p.org;
b['title'] = p.title;
b['slogan'] = p.slogan;
/* 🔴🔴 v59e(2026-10-18 真机定位):拼音首字母**必须落库**。
*
* `rowToPerson` 原来把它写死成空串,于是「输入 yy 搜杨永全」这条
* 明确的需求在名册页**从来没生效过** —— 用户的原话是
* 「名册的搜索是完全不能用的」。
* ⚠️ 服务端 `/roster` 本来就下发 `initials`
* (见 ApiHandler 的 `Pinyin::initials(name + nickname)`),端上只是**没存**。
* ⚠️ 只补这一处不够,还要:建表列 + 迁移列 + `list/byId` 的 SELECT +
* `rowToPerson` 的读取(四处缺一,现象都是"搜不到人")。 */
b['initials'] = p.initials;
b['address'] = p.addrCity; // 名册只给到城市
b['addr_city'] = p.addrCity;
b['updated_at'] = '';
@@ -490,7 +500,7 @@ export class FbPersonRepo {
async list(keyword: string = ''): Promise<FbRosterPerson[]> {
const sql: string =
'SELECT p.id, p.name, p.nickname, p.gender, p.birthday, p.avatar, p.avatar_color,' +
' p.note, p.email, p.org, p.title, p.slogan, p.addr_city,' +
' p.note, p.email, p.org, p.title, p.slogan, p.addr_city, p.initials,' +
' r.sort_order, r.phones,' +
' (SELECT GROUP_CONCAT(t.tag, "\\u0001") FROM person_tags t' +
' WHERE t.person_id = p.id AND t.owner_id = r.owner_id) AS tags' +
@@ -528,7 +538,7 @@ export class FbPersonRepo {
}
const sql: string =
'SELECT p.id, p.name, p.nickname, p.gender, p.birthday, p.avatar, p.avatar_color,' +
' p.note, p.email, p.org, p.title, p.slogan, p.addr_city,' +
' p.note, p.email, p.org, p.title, p.slogan, p.addr_city, p.initials,' +
' 0 AS sort_order, NULL AS phones,' +
' (SELECT GROUP_CONCAT(t.tag, "\\u0001") FROM person_tags t' +
' WHERE t.person_id = p.id) AS tags' +
@@ -665,7 +675,9 @@ export class FbPersonRepo {
// (只有走接口的 `listWithLabel()` 才带 phone)。这是个真 bug。
phone: phonesArr.length > 0 ? phonesArr[0] : '',
phones: phonesArr,
initials: '',
/* 🔴 v59e:**从库里读**(原来写死成空串 ⇒ 拼音首字母搜索全废)。
⚠️ 见 `sync()` 里 `b['initials']` 那段说明:这一项是四处一起改才通的。 */
initials: r.strOr('initials', ''),
org: r.strOr('org', ''),
title: r.strOr('title', ''),
slogan: r.strOr('slogan', ''),
+9 -1
View File
@@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db';
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
*/
export const FB_DB_VERSION: number = 6;
export const FB_DB_VERSION: number = 7;
/**
* 建表语句。
@@ -141,6 +141,14 @@ export const FB_SCHEMA_SQL: string[] = [
slogan TEXT,
address TEXT,
addr_city TEXT,
-- 🔴 v7(v59e)新增:拼音首字母(服务端 Pinyin::initials 算好下发)。
-- 名册搜索「输入 yy 搜杨永全」全靠它;原来端上把它写死成空串 ⇒ 拼音搜索全废。
-- ⚠️ 加列时**两处都要改**:这里(全新安装)+ FbDb.migrate() 的 v6→v7
-- (老库升级)。只改一处就是"新装能搜、老库搜不到"或者反过来。
-- ⚠️ 本段是 SQL 注释、而整条建表语句是 JS 模板字符串 —— 注释里**绝不能
-- 出现反引号**(一个反引号就会把模板串提前闭合,报一堆
-- "Cannot find name Pinyin/FbDb" 这种看着毫不相干的错)。已踩第 5 次。
initials TEXT,
created_at TEXT,
updated_at TEXT NOT NULL DEFAULT '',
deleted_at TEXT,
+43
View File
@@ -43,6 +43,7 @@ import { FbDb } from '../data/FbDb';
import { FB_ATTACH } from '../data/FbAttachQueue';
import { FB_KEY_STORE } from '../data/FbKeyStore';
import { fbSyncState } from '../common/FbSyncStore';
import { FB_BACK } from '../common/FbBack';
import { common } from '@kit.AbilityKit';
/**
@@ -97,6 +98,48 @@ struct Index {
FB_GRIP.stop();
}
/**
* 🔴🔴 v59e(用户 2026-10-18):接管**系统返回手势**。
*
* 用户原话:
* 「加入全局返回按键手势,现在在界面中使用系统的返回手势,是返回到桌面了,
* 这样不好,如果有弹框,返回应该是关闭弹窗,或者返回上个页面,
* 如果要退出 app,用户可以选择返回桌面的手势。在应用中给,
* 应该是返回上一层才对。」
*
* 为什么以前会"直接回桌面":本应用**没有实现 `onBackPress`** ⇒
* 系统认为这一页没有消费返回 ⇒ 按默认行为**把应用退到后台**。
* 而本应用是"单页 + 模块分发",系统并不知道"用户其实还在应用里"。
*
* 处理顺序(从**最上面一层**往下):
* ① 抽屉开着 ⇒ 收抽屉(它是最外层的浮层);
* ② 页面内浮层(详情 / 编辑 / 简要卡 / 设置 …)⇒ 由页面自己关,
* 见 `common/FbBack.ets` 的登记表;
* ③ 不在首页 ⇒ 回上一个模块(没有历史就回首页);
* ④ 首页且没有任何浮层 ⇒ **返回 false**,交还给系统
* (用户想退到桌面时,退回桌面仍是他的手势说了算)。
*
* ⚠️ 返回值语义:`true` = **已消费**(不再往下传);`false` = 交给系统处理。
* 这里必须显式写 `: boolean`,返回 `void` 会被当成 false ⇒ 等于没接管。
*/
onBackPress(): boolean {
// ① 抽屉
if (this.app.drawerOpen) {
this.app.drawerOpen = false;
return true;
}
// ② 页面内浮层(登记表里最后登记的先问 —— 见 common/FbBack.ets)
if (FB_BACK.back()) {
return true;
}
// ③ 回上一个模块 / 回首页
if (this.entered && !this.app.needLogin && this.app.backToPrevModule()) {
return true;
}
// ④ 真的在最外层了:让系统退到桌面
return false;
}
/**
* 读状态栏高度(由 `EntryAbility` 在全屏生效后量好、放进 AppStorage)。
*
+22 -2
View File
@@ -8,7 +8,7 @@
* 「在线读」「上传 EPUB 解析」「笔记导入」这些属于交互+文件能力,留给第二轮。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbBookItem, FbGroup } from '../../model/Types';
import { fbGroupBook } from '../../model/DemoData';
@@ -18,6 +18,7 @@ import { FbEmptyBox, FbIconButton, FbProgressLine, FbRating, FbTag } from '../..
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_BOOKS } from '../../model/FbSettingsMenus';
@ComponentV2
@@ -48,7 +49,26 @@ export struct FbBooksPage {
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 先关本页的浮层,再交给 `Index` 换模块。
*
* 本页只有「模块设置」一个浮层,所以规则很短:开着就关掉(返回 true = 已消费)。
* ⚠️ 登记/注销成对出现,见 `common/FbBack.ets` 与 `Index.onBackPress`。
*/
aboutToDisappear(): void {
FB_BACK.unregister('books');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('books', (): boolean => this.backOnce());
this.load();
}
@@ -163,7 +183,7 @@ export struct FbBooksPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})
@@ -23,7 +23,7 @@
* 旧代码从条目里臆造 id,导致新建日程根本落不到正确的本上(见 `FbCalRepo.fetchBooks`)。
*/
import { FbColors, FbMetric, fbPalette, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbPalette, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbCalBook, FbCalEvent, isEditableEvent } from '../../model/Types';
import { parseCalendar } from '../../data/FbParse';
@@ -38,6 +38,7 @@ import { FbCalSettingsSheet } from '../../components/FbCalSettingsSheet';
import { FbDayEventPanel } from '../../components/FbDayEventPanel';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FB_BACK } from '../../common/FbBack';
/** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */
/**
@@ -139,7 +140,41 @@ export struct FbCalendarPage {
/** 详情请求序号(防"连点两条,前一条的响应覆盖后一条") */
private detailToken: number = 0;
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 只关**最上面一层**浮层。
*
* 四个浮层:编辑 / 新建 / 设置 / 详情。
* ⚠️ 「详情」放最后问:编辑是从详情里点出去的(此时详情已经关了),
* 所以编辑一定比详情更"上"。
*/
aboutToDisappear(): void {
FB_BACK.unregister('calendar');
}
private backOnce(): boolean {
if (this.editing) {
// 与 `FbEventEditSheet` 的 onCancel 一致
this.editing = false;
this.editEvent = null;
return true;
}
if (this.creating) {
this.creating = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
if (this.detailOpen()) {
this.closeDetail();
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('calendar', (): boolean => this.backOnce());
// 先按设置里的默认视图展示(用户上次选的)
this.mode = FB_CAL_SETTINGS.getView();
this.style = FB_CAL_SETTINGS.getStyle();
@@ -596,7 +631,7 @@ export struct FbCalendarPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.closeDetail();
})
@@ -621,7 +656,7 @@ export struct FbCalendarPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.editing = false;
this.editEvent = null;
@@ -648,7 +683,7 @@ export struct FbCalendarPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.creating = false;
})
@@ -674,7 +709,7 @@ export struct FbCalendarPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})
+22 -2
View File
@@ -10,7 +10,7 @@
* 因为「还有几天」是这个模块唯一真正要一眼看到的信息。
*/
import { FbColors, FbMetric, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbAnniversaryItem, FbKinChip } from '../../model/Types';
import { parseDays } from '../../data/FbParse';
@@ -19,6 +19,7 @@ import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../.
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_DAYS } from '../../model/FbSettingsMenus';
@ComponentV2
@@ -51,7 +52,26 @@ export struct FbDaysPage {
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 先关本页的浮层,再交给 `Index` 换模块。
*
* 本页只有「模块设置」一个浮层,所以规则很短:开着就关掉(返回 true = 已消费)。
* ⚠️ 登记/注销成对出现,见 `common/FbBack.ets` 与 `Index.onBackPress`。
*/
aboutToDisappear(): void {
FB_BACK.unregister('days');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('days', (): boolean => this.backOnce());
this.load();
}
@@ -215,7 +235,7 @@ export struct FbDaysPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})
@@ -14,7 +14,7 @@
* 任何地方都不要拿「元」去加总 —— 浮点误差会越滚越大。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbLedgerBook, FbLedgerEntry, FbLedgerDetail} from '../../model/Types';
import { fbLedgerKindName, fbMoney } from '../../model/DemoData';
@@ -24,6 +24,7 @@ import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../componen
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_LEDGER } from '../../model/FbSettingsMenus';
@ComponentV2
@@ -65,7 +66,26 @@ export struct FbLedgerPage {
* ⚠️ 账本必须走两级:`/ledger`(有哪些本)→ `/ledger/<id>/entries`(明细 + 汇总)。
* 净资产只在这第二级里 ⇒ 拿到第一本之后要立刻拉一次明细。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 先关本页的浮层,再交给 `Index` 换模块。
*
* 本页只有「模块设置」一个浮层,所以规则很短:开着就关掉(返回 true = 已消费)。
* ⚠️ 登记/注销成对出现,见 `common/FbBack.ets` 与 `Index.onBackPress`。
*/
aboutToDisappear(): void {
FB_BACK.unregister('ledger');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('ledger', (): boolean => this.backOnce());
this.load();
}
@@ -349,7 +369,7 @@ export struct FbLedgerPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})
@@ -406,7 +426,7 @@ export struct FbLedgerPage {
.fontSize(10)
.fontColor(this.accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(this.accent + '14')
.backgroundColor(fbAlpha(this.accent, 0.08))
.borderRadius(FbMetric.radiusPill)
}
.width('100%')
@@ -11,7 +11,7 @@
* 没有使用场景。所以本文件现在只承载「体量小、形态相近」的页面。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbColors, FbMetric, FbTint, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbNoteItem, FbNotificationItem } from '../../model/Types';
import { parseNotes, parseNotifications } from '../../data/FbParse';
@@ -242,7 +242,7 @@ export struct FbNotificationsPage {
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.brand)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(FbColors.brand + '14')
.backgroundColor(fbAlpha(FbTint.brand, 0.08))
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
// 本轮只做界面:把本地未读标记清掉即可
@@ -21,7 +21,7 @@
* 断网时更是完全空白。**先渲染本地、再后台同步**是这套架构的要点。
*/
import { FbColors, FbMetric, fbPalette, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbPalette, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbKinChip } from '../../model/Types';
import { FbCinemaItem } from '../../data/FbMovieRepo';
@@ -32,6 +32,7 @@ import { FbMovieDetail, FbMovieListItem, FbMovieRepo, FbSyncResult } from '../..
import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet';
import { FbCinemaSheet } from '../../components/FbCinemaSheet';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_MOVIES } from '../../model/FbSettingsMenus';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { hilog } from '@kit.PerformanceAnalysisKit';
@@ -151,7 +152,35 @@ export struct FbMoviesPage {
* ⚠️ 不能声明成 async 让框架 await —— 生命周期钩子返回 void 即可,
* 异步逻辑在内部自己跑(否则会影响页面首帧时机)。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 只关**最上面一层**浮层。
*
* 本页三个浮层:详情(`sheetOpen`)/ 影院(`cinemaSheetOpen`)/ 设置(`settingsOpen`)。
* ⚠️ 顺序按"后出现的在上层":设置里点「影院管理」会把设置关掉、开影院
* (两者不会同时开),但反过来"影院开着时又点设置"是可能的 ⇒ 先收设置。
*/
aboutToDisappear(): void {
FB_BACK.unregister('movies');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
if (this.cinemaSheetOpen) {
this.cinemaSheetOpen = false;
return true;
}
if (this.sheetOpen) {
this.sheetOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('movies', (): boolean => this.backOnce());
this.bootstrap();
}
@@ -329,7 +358,7 @@ export struct FbMoviesPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.sheetOpen = false;
})
@@ -354,7 +383,7 @@ export struct FbMoviesPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.cinemaSheetOpen = false;
})
@@ -372,7 +401,7 @@ export struct FbMoviesPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})
+260 -88
View File
@@ -12,7 +12,7 @@
* 每圈一张卡,卡里按人列。关系表达上等价,宽度可控。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbKinGraph, FbKinPerson, FbRosterItem } from '../../model/Types';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../../model/Modules';
@@ -35,6 +35,8 @@ import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbImageSrc } from '../../data/FbAttachQueue';
import { FB_BACK } from '../../common/FbBack';
import { FbSharePerson, fbPersonShareText, fbShareText } from '../../common/FbShare';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbPeoplePage';
@@ -101,6 +103,19 @@ function matchesKeyword(it: FbRosterItem, p: FbRosterPerson | undefined, q: stri
return true;
}
}
// 🔴 v59e:**所有**号码都要能搜到(原来只比 `p.phone` = 主号,
// 第 2、3 个号码根本搜不出来,用户会以为"这人不在名册里")。
// 并且按**纯数字**再比一次:号码里常带空格/横杠(`138 0000 0000`),
// 用户手打的通常不带分隔符。
const digits: string = k.replace(/[^0-9]/g, '');
for (const ph of it.phones) {
if (ph.indexOf(k) >= 0) {
return true;
}
if (digits !== '' && ph.replace(/[^0-9]/g, '').indexOf(digits) >= 0) {
return true;
}
}
// ---- ② 拼音首字母**子序列**匹配 ----
// 🔴 用户 2026-10-13:"增加按照名字的拼音首字母进行搜索,
@@ -266,12 +281,15 @@ export struct FbPeoplePage {
* ⇒ 用 `@Monitor` 一处声明、自动跟随派生,赋值的写法一概不用改。
*
* ⚠️ 用 `AppStorageV2.connect` 拿到的**同一个单例**(`fbAppState()` 返回的就是它)。
* ⚠️ v59e 补上 `quickPerson`:简要卡也是"页面内浮层",它开着时
* 悬浮栏同样会压住卡片底部的按钮(本卡片底下就是四个动作按钮)。
*/
@Monitor('detailPerson', 'editPerson', 'creating')
@Monitor('detailPerson', 'editPerson', 'creating', 'quickPerson')
onSheetChanged(): void {
const app: FbAppState = fbAppState();
// 只要有一个浮层开着,就让悬浮栏让位(计数 0/1 即可,本页不会同时开两个)
const open: boolean = this.detailPerson !== null || this.editPerson !== null || this.creating;
const open: boolean = this.detailPerson !== null || this.editPerson !== null
|| this.creating || this.quickPerson !== null;
if (open) {
app.sheetCount = 1;
} else {
@@ -315,9 +333,67 @@ export struct FbPeoplePage {
* 理由见 FbMoviesPage 的说明:等网络回来才渲染 = 转圈好几秒、断网全白。
*/
aboutToAppear(): void {
/* 🔴 v59e(用户 2026-10-18):把「关掉本页最上层浮层」的能力登记给
系统返回手势(见 `common/FbBack.ets`)。
⚠️ 在 `aboutToAppear` 里登记、`aboutToDisappear` 里注销 ——
切模块时本页会被销毁,不注销的话登记表里会留下一个"已经死掉的 this",
下一次返回手势会调到旧组件上(改了状态也没人渲染,看起来就是"返回没反应")。 */
FB_BACK.register('people', (): boolean => this.backOnce());
this.bootstrap();
}
aboutToDisappear(): void {
FB_BACK.unregister('people');
}
/**
* 返回手势:**只关一层**,关了就说"我消费了"。
*
* 🔴 用户 2026-10-18 原话:「如果有弹框,返回应该是关闭弹窗,或者返回上个页面,
* 如果要退出 app,用户可以选择返回桌面的手势。在应用中给,应该是返回上一层才对。」
*
* 层级顺序 = 屏幕上"最上面"的那个:
* 编辑 / 新建 > 详情 > 简要卡 > 家谱子视图 > (交还给 `Index` 换模块)
*
* ⚠️ 与各浮层的 `onClose` / `onCancel` **走同一条路**(不是另写一套收尾):
* 否则"点关闭"和"按返回"会出现两种不同的落点,用户会觉得其中一种坏了。
*/
private backOnce(): boolean {
// ① 编辑浮层(含"新建"复用同一个组件,但两者状态是分开的)
if (this.editPerson !== null) {
// 与 `onCancel` 一致:取消编辑 ⇒ 回到详情(刚才就是从详情进来的)
this.detailPerson = this.editPerson;
this.editPerson = null;
return true;
}
if (this.creating) {
this.creating = false;
return true;
}
// ② 详情 ⇒ 回到简要卡(它还在的话)
if (this.detailPerson !== null) {
const back: FbRosterPerson | null = this.detailPerson;
this.detailPerson = null;
if (back !== null && this.quickPerson !== null) {
this.quickFromDetail = back;
}
return true;
}
// ③ 简要卡 ⇒ 回列表
if (this.quickPerson !== null || this.quickFromDetail !== null) {
this.quickPerson = null;
this.quickFromDetail = null;
return true;
}
// ④ 家谱 ⇒ 回名册(同一页的两个子视图,"返回上一层"就是回到默认那个)
if (this.subView === 'kin') {
this.subView = 'roster';
return true;
}
// ⑤ 本页没有可关的东西 ⇒ 交给 `Index`(换回上一个模块)
return false;
}
private async bootstrap(): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
@@ -463,7 +539,13 @@ export struct FbPeoplePage {
* 用户能立刻看出"是不是点错了人"(这正是误触最需要的信息)。
*/
private confirmDelete(): void {
const who: FbRosterPerson | null = this.quickSource();
/* 🔴 v59e:删除入口从"简要卡"挪到了"详情卡底部" ⇒ 目标人先取 `detailPerson`。
⚠️ 不能只取 `quickSource()`:详情有可能是**从家谱那行直接打开**的
(`kinRow` 的 onClick 直接写 `detailPerson`,`quickPerson` 一直是 null)
⇒ 那样点删除会**静默什么都不发生**(`who === null` 就 return 了)。
这正是"按钮点了没反应"最难查的一类:没有报错、没有日志。 */
const who: FbRosterPerson | null =
this.detailPerson !== null ? this.detailPerson : this.quickSource();
if (who === null) {
return;
}
@@ -546,7 +628,7 @@ export struct FbPeoplePage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.detailPerson = null;
})
@@ -567,6 +649,12 @@ export struct FbPeoplePage {
// 关详情、开编辑(把同一个人带过去)
this.editPerson = this.detailPerson;
this.detailPerson = null;
},
/* 🔴 v59e(用户 2026-10-18):「应该在详情页面的最下面加一个删除按钮。」
⇒ 删除的入口从简要卡挪到了这里(简要卡上那个太容易误触)。
⚠️ 走的还是同一个 `confirmDelete()`(二次确认 + 调接口 + 刷新名册)。 */
onDelete: (): void => {
this.confirmDelete();
}
})
}
@@ -580,7 +668,7 @@ export struct FbPeoplePage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.quickPerson = null;
this.quickFromDetail = null;
@@ -608,8 +696,21 @@ export struct FbPeoplePage {
this.editPerson = who;
}
},
onDelete: (): void => {
this.confirmDelete();
/* 🔴 v59e(用户 2026-10-18):「上面的电话点击之后就可以直接拨打。」
⚠️ 卡片**不自己拨号**(那要 AbilityContext),只把号码发出来,
真正的 `call.makeCall()` 在本页 —— 与本页别处的拨号同一个实现。 */
onCall: (phone: string): void => {
const who: FbRosterPerson | null = this.quickSource();
this.dial(phone, who !== null ? who.shown : '');
},
/* 🔴 v59e:「下面有一个按钮,详情、编辑、关闭,还有分享。
分享这个可以选择,比如说微信或什么的,以文本的形式,姓名、电话、邮箱。」
⇒ 拉起系统分享面板(见 `common/FbShare.ets` 为什么不用自己拉微信)。 */
onShare: (): void => {
const who: FbRosterPerson | null = this.quickSource();
if (who !== null) {
this.sharePerson(who);
}
}
})
}
@@ -619,7 +720,7 @@ export struct FbPeoplePage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.editPerson = null;
})
@@ -668,7 +769,7 @@ export struct FbPeoplePage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.creating = false;
})
@@ -798,6 +899,23 @@ export struct FbPeoplePage {
.onChange((v: string) => {
this.keyword = v;
})
/* 🔴 v59e:搜索框右侧补一个「清空」——
名册上千人,搜错了要逐字删掉很烦(而且手机上退格容易连删)。
⚠️ 命中区给 24×24(图标只有 12):手机上 12vp 的点击区域点不中。 */
if (this.keyword !== '') {
Row() {
Image($r('app.media.fb_ic_close'))
.width(12)
.height(12)
.colorFilter(fbTint(FbTint.faint))
}
.width(24)
.height(24)
.justifyContent(FlexAlign.Center)
.onClick((): void => {
this.keyword = '';
})
}
}
.layoutWeight(1)
.height(36)
@@ -898,34 +1016,58 @@ export struct FbPeoplePage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
}
if (this.loading) {
Text('正在读取本地数据…')
.width('100%')
.padding({ top: 40, bottom: 40 })
.textAlign(TextAlign.Center)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} else if (this.visiblePeople().length === 0) {
Text(this.emptyHint())
.width('100%')
.padding({ top: 40, bottom: 40, left: 24, right: 24 })
.textAlign(TextAlign.Center)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} else {
// 🔴 **平铺列表**(不再按分组分堆)。
// 用户 2026-10-12:"名册不需要分组,是按照标签来的",
// ⇒ 分组筛选已移到顶部的标签条(见 tagBar),列表只负责呈现"筛完的人"。
// ⚠️ 这也让 `List` 的 `ListItemGroup` 整个消失 —— 少一层嵌套,
// 而且不用再纠结"分组头的背景色"。
List({ space: 0 }) {
// 🔴 **顶栏 + 页头作为第一个 item**(用户 2026-10-15 要求本页也跟滚)。
// 放进 List 内容 ⇒ 上滑时跟着滚出屏幕,与首页观感一致;
// 同时 List 仍按需渲染 ⇒ 上千人也不卡(见 pageHeaderItem 的说明)。
ListItem() {
this.pageHeaderItem()
}
/* 🔴🔴 **`List` 必须无条件渲染**(用户 2026-10-18:「名册的搜索完全不能用」)。
*
* 真凶(不是"过滤逻辑没接上",而是**搜索框自己会被卸载**):
* 顶栏 + 页头(含搜索框)原本是 `List` 的**第一个 item**,
* 而"没有结果"时走的是 `else` 分支 —— 整块 `List` 被换成一行空状态文字
* ⇒ **搜索框跟着一起消失**:
* · 输第一个字(中文输入法下先出的是拼音字母)没有匹配 ⇒ 框没了;
* · 框没了 ⇒ 焦点丢了、键盘收起 ⇒ **再也输入不了第二个字**;
* · 用户看到的正是"搜索完全不能用",而代码里过滤逻辑其实是对的。
*
* ⇒ 正确形态:`List` 常驻,**空状态也做成列表里的一项**
* (顶栏/搜索框永远是 item[0],不会被任何分支替换掉)。
* ⚠️ 这条经验对所有"搜索框放在列表头"的页面都成立:
* 条件渲染包裹 `List` ⇒ 搜索中间态会把输入框一起卸载。
*/
List({ space: 0 }) {
// 🔴 **顶栏 + 页头作为第一个 item**(用户 2026-10-15 要求本页也跟滚)。
// 放进 List 内容 ⇒ 上滑时跟着滚出屏幕,与首页观感一致;
// 同时 List 仍按需渲染 ⇒ 上千人也不卡(见 pageHeaderItem 的说明)。
ListItem() {
this.pageHeaderItem()
}
if (this.loading) {
ListItem() {
/* ⚠️ 提示行**直接写在这里**,不抽 `@Builder(text)`:
`@Builder` 的简单类型参数是**按值传**的,参数变了不会触发重建
(本项目在日程/首页踩过两次)。`this.emptyHint()` 必须跟着
`keyword` 变化重算,所以只能让它留在**带状态的 UI 描述里**。 */
Row() {
Text('正在读取本地数据…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
}
.width('100%')
.padding({ top: 40, bottom: 40, left: 24, right: 24 })
}
} else if (this.visiblePeople().length === 0) {
ListItem() {
Row() {
Text(this.emptyHint())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
}
.width('100%')
.padding({ top: 40, bottom: 40, left: 24, right: 24 })
}
} else {
ForEach(this.visiblePeople(), (p: FbRosterItem, idx: number) => {
ListItem() {
// 🔴 每条**独立圆角卡**(用户 2026-10-16)⇒ 卡与卡之间留空隙,
@@ -934,59 +1076,59 @@ export struct FbPeoplePage {
this.personRow(p)
}
}, (p: FbRosterItem): string => 'pr_' + p.id.toString())
// 🔴 **末尾占位**:给底部悬浮栏留出高度。
// 为什么要做成**列表的最后一个 item**、而不是 List 的 `padding(bottom)`:
// · `padding(bottom)` 是**容器内边距** ⇒ 它把列表的**可视底边往上收**
// ⇒ 内容在那条线就停住/被裁,看着就是"没滚到屏幕最底、下面空一块"
// (用户 2026-10-15:「它的内容是从悬浮框上面滚出来的」);
// · 做成 item ⇒ 它是**内容的一部分**,跟着一起滚,
// 平时在屏幕外、只有滚到底时才出现在悬浮栏后面 ——
// 与观影页 `.padding` 挂在**内容 Column** 上的效果一致。
ListItem() {
this.dockSpacerItem()
}
}
// 🔴 **`layoutWeight(1)` 让列表吃掉页头之后的全部剩余高度**
// ⇒ 列表白底一直铺到屏幕底部,下面只留一条极窄的页面底色
// (与首页/观影页观感一致;用户明确要求这个形态)。
// ⚠️ 这里能用 `layoutWeight` 的**前提是外层不再是 Scroll** ——
// 在 Scroll 里子组件高度由内容决定,`layoutWeight` 会拿到 0。
// 见 `build()` 的说明。
.layoutWeight(1)
.width('100%')
/* 🔴🔴 v59b(用户 2026-10-18):「名册页面,不好看,太丑了,应该是
* 每个人都是圆角矩形,往中间收一点,距离左右都有点距离,
* 每个人中间都有一点空隙。这样是很多个圆角矩形平铺下来的。」
*
* 🔴 真凶:卡片**早就**是圆角矩形了(v51 就做了 `borderRadius` +
* `margin`),但**容器也是纯白** ⇒ **白卡压在白底上**,
* 圆角和左右收边**全都看不见** —— 看上去就是一整块通栏白。
* (这解释了为什么"改过一轮还是丑":改的是卡片的形状,
* 而问题在于它没有可对比的背景。)
*
* ⇒ 容器改成**页面底色(宣纸米色)**,白卡才"浮"得出来;
* 外层那个兜底白(见 build 末尾)也一并改掉,否则滚到底
* 会从米色突然切回白色。
*/
.backgroundColor(FbColors.paper)
// 🔴🔴 **这里绝对不能再加 `padding({bottom})`**
// (用户 2026-10-15:「它的内容是从悬浮框上面滚出来的」)。
//
// ⚠️ 容器上的 `padding(bottom)` 是**内边距**:它会把 List 的**可视底边**
// 往上收一截 ⇒ 列表内容在那条线就被裁掉/停住,
// 看上去就是"内容没能滚到屏幕最底、下面空出一块"。
// ⇒ 正确做法与观影页一致:预留改成**列表内容末尾的一个占位 item**
// (它是"内容",会跟着一起滚,只在滚到底时才占位)。
// 见下面的 `dockSpacerItem`。
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
// 🔴 **末尾占位**:给底部悬浮栏留出高度。
// 为什么要做成**列表的最后一个 item**、而不是 List 的 `padding(bottom)`:
// · `padding(bottom)` 是**容器内边距** ⇒ 它把列表的**可视底边往上收**
// ⇒ 内容在那条线就停住/被裁,看着就是"没滚到屏幕最底、下面空一块"
// (用户 2026-10-15:「它的内容是从悬浮框上面滚出来的」);
// · 做成 item ⇒ 它是**内容的一部分**,跟着一起滚,
// 平时在屏幕外、只有滚到底时才出现在悬浮栏后面 ——
// 与观影页 `.padding` 挂在**内容 Column** 上的效果一致。
ListItem() {
this.dockSpacerItem()
}
}
// 🔴 **`layoutWeight(1)` 让列表吃掉页头之后的全部剩余高度**
// ⇒ 列表白底一直铺到屏幕底部,下面只留一条极窄的页面底色
// (与首页/观影页观感一致;用户明确要求这个形态)。
// ⚠️ 这里能用 `layoutWeight` 的**前提是外层不再是 Scroll** ——
// 在 Scroll 里子组件高度由内容决定,`layoutWeight` 会拿到 0。
// 见 `build()` 的说明。
.layoutWeight(1)
.width('100%')
/* 🔴🔴 v59b(用户 2026-10-18):「名册页面,不好看,太丑了,应该是
* 每个人都是圆角矩形,往中间收一点,距离左右都有点距离,
* 每个人中间都有一点空隙。这样是很多个圆角矩形平铺下来的。」
*
* 🔴 真凶:卡片**早就**是圆角矩形了(v51 就做了 `borderRadius` +
* `margin`),但**容器也是纯白** ⇒ **白卡压在白底上**,
* 圆角和左右收边**全都看不见** —— 看上去就是一整块通栏白。
* (这解释了为什么"改过一轮还是丑":改的是卡片的形状,
* 而问题在于它没有可对比的背景。)
*
* ⇒ 容器改成**页面底色(宣纸米色)**,白卡才"浮"得出来;
* 外层那个兜底白(见 build 末尾)也一并改掉,否则滚到底
* 会从米色突然切回白色。
*/
.backgroundColor(FbColors.paper)
// 🔴🔴 **这里绝对不能再加 `padding({bottom})`**
// (用户 2026-10-15:「它的内容是从悬浮框上面滚出来的」)。
//
// ⚠️ 容器上的 `padding(bottom)` 是**内边距**:它会把 List 的**可视底边**
// 往上收一截 ⇒ 列表内容在那条线就被裁掉/停住,
// 看上去就是"内容没能滚到屏幕最底、下面空出一块"。
// ⇒ 正确做法与观影页一致:预留改成**列表内容末尾的一个占位 item**
// (它是"内容",会跟着一起滚,只在滚到底时才占位)。
// 见下面的 `dockSpacerItem`。
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
}
.width('100%')
.layoutWeight(1)
@@ -1202,6 +1344,36 @@ export struct FbPeoplePage {
});
}
/**
* 分享一个人(**纯文本**:姓名 / 电话 / 邮箱,见 `common/FbShare.ets`)。
*
* 🔴 用户 2026-10-18:「分享这个可以选择,比如说微信或什么的,以文本的形式,
* 姓名、电话、邮箱,这几个以文本的形式分享给别人。」
*
* ⚠️ 用**系统分享面板**:微信 / 短信 / 邮件 / 备忘录都在里面,用户自己选。
* ⚠️ 分享面板拉不起来时,`fbShareText` 会把同一段文本**复制到剪贴板**并回一句话
* ⇒ 这里用 Toast 告诉用户"内容去哪了",而不是静默失败。
*/
private sharePerson(p: FbRosterPerson): void {
const ctx: common.UIAbilityContext | undefined =
this.getUIContext().getHostContext() as common.UIAbilityContext | undefined;
if (ctx === undefined) {
return;
}
const data: FbSharePerson = {
name: p.shown !== '' ? p.shown : p.name,
phones: p.phones,
email: p.email,
};
fbShareText(ctx, fbPersonShareText(data), data.name).then((note: string): void => {
if (note !== '') {
this.getUIContext().getPromptAction().showToast({ message: note, duration: 2600 });
}
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '分享失败:%{public}s', JSON.stringify(err));
});
}
/**
* 拨号(跳到系统拨号盘,**不直接呼出**)。
*
@@ -8,7 +8,7 @@
* 那一排「类别筛选胶囊」对应 Web 端页头的分类入口。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbResourceItem } from '../../model/Types';
import { fbGroupResource } from '../../model/DemoData';
@@ -18,6 +18,7 @@ import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../componen
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus';
@ComponentV2
@@ -50,7 +51,26 @@ export struct FbResourcesPage {
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 先关本页的浮层,再交给 `Index` 换模块。
*
* 本页只有「模块设置」一个浮层,所以规则很短:开着就关掉(返回 true = 已消费)。
* ⚠️ 登记/注销成对出现,见 `common/FbBack.ets` 与 `Index.onBackPress`。
*/
aboutToDisappear(): void {
FB_BACK.unregister('resources');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('resources', (): boolean => this.backOnce());
this.load();
}
@@ -180,7 +200,7 @@ export struct FbResourcesPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})
@@ -217,7 +237,7 @@ export struct FbResourcesPage {
.width(42)
.height(42)
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.accent + '14')
.backgroundColor(fbAlpha(this.accent, 0.08))
Column({ space: 4 }) {
Row({ space: 6 }) {
@@ -10,7 +10,7 @@
* 抽屉里列什么,正是由这里决定的。
*/
import { FbColors, FbMetric, fbPalette, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbPalette, fbTint, FbTint, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbModuleDef, FB_MODULES, FB_PRESET_COLORS } from '../../model/Modules';
import { FbAvatar, FbDot, FbPrimaryButton, FbTag } from '../../components/Widgets';
@@ -534,7 +534,7 @@ export struct FbSettingsPage {
.width(32)
.height(32)
.borderRadius(9)
.backgroundColor(fbPalette(m.color).brand + '14')
.backgroundColor(fbAlpha(fbPalette(m.color).brand, 0.08))
Column({ space: 2 }) {
Text(m.name)
+22 -2
View File
@@ -9,7 +9,7 @@
* 看板名仍保留(卡片上那个小色块标签),点它可以切到别的看板。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbTaskItem } from '../../model/Types';
import { fbGroupTask } from '../../model/DemoData';
@@ -19,6 +19,7 @@ import { FbCheckCircle, FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TASKS } from '../../model/FbSettingsMenus';
@ComponentV2
@@ -52,7 +53,26 @@ export struct FbTasksPage {
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 先关本页的浮层,再交给 `Index` 换模块。
*
* 本页只有「模块设置」一个浮层,所以规则很短:开着就关掉(返回 true = 已消费)。
* ⚠️ 登记/注销成对出现,见 `common/FbBack.ets` 与 `Index.onBackPress`。
*/
aboutToDisappear(): void {
FB_BACK.unregister('tasks');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('tasks', (): boolean => this.backOnce());
this.load();
}
@@ -186,7 +206,7 @@ export struct FbTasksPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})
+22 -2
View File
@@ -12,7 +12,7 @@
* 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。
*/
import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem, FbTripNode } from '../../model/Types';
import { fbMoney } from '../../model/DemoData';
@@ -22,6 +22,7 @@ import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../c
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
@ComponentV2
@@ -71,7 +72,26 @@ export struct FbTripsPage {
* ⚠️ 本页**不自己打开本地库** —— 已在 `EntryAbility.onCreate` 统一打开了
* (见那里的说明)。页面只管用。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 先关本页的浮层,再交给 `Index` 换模块。
*
* 本页只有「模块设置」一个浮层,所以规则很短:开着就关掉(返回 true = 已消费)。
* ⚠️ 登记/注销成对出现,见 `common/FbBack.ets` 与 `Index.onBackPress`。
*/
aboutToDisappear(): void {
FB_BACK.unregister('trips');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('trips', (): boolean => this.backOnce());
this.load();
}
@@ -179,7 +199,7 @@ export struct FbTripsPage {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.settingsOpen = false;
})