diff --git a/entry/src/main/ets/common/FbBack.ets b/entry/src/main/ets/common/FbBack.ets new file mode 100644 index 0000000..bd055c2 --- /dev/null +++ b/entry/src/main/ets/common/FbBack.ets @@ -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(); diff --git a/entry/src/main/ets/common/FbShare.ets b/entry/src/main/ets/common/FbShare.ets new file mode 100644 index 0000000..4c71579 --- /dev/null +++ b/entry/src/main/ets/common/FbShare.ets @@ -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/`),让它们各自拼好再传进来最省事, + * 也避免 `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 { + 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 { + 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 '分享失败(本机没有可用的分享服务)'; + } +} diff --git a/entry/src/main/ets/common/Store.ets b/entry/src/main/ets/common/Store.ets index 40bbd38..394c8ea 100644 --- a/entry/src/main/ets/common/Store.ets +++ b/entry/src/main/ets/common/Store.ets @@ -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; diff --git a/entry/src/main/ets/common/Theme.ets b/entry/src/main/ets/common/Theme.ets index ff66c71..0a59fa4 100644 --- a/entry/src/main/ets/common/Theme.ets +++ b/entry/src/main/ets/common/Theme.ets @@ -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'); } -} \ No newline at end of file +} +// ============================================================ +// 透明度色的**唯一正确写法** +// ============================================================ + +/** + * 🔴🔴 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'; diff --git a/entry/src/main/ets/components/Drawer.ets b/entry/src/main/ets/components/Drawer.ets index a5c2389..083f87f 100644 --- a/entry/src/main/ets/components/Drawer.ets +++ b/entry/src/main/ets/components/Drawer.ets @@ -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() { diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index c7a115b..83a6940 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -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; }) diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index 55e0841..9d8a97c 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -242,7 +242,7 @@ export struct FbMovieDetailSheet { Column() .width('100%') .height('100%') - .backgroundColor('#000000E6') + .backgroundColor('#E6000000') .onClick((): void => { this.viewerOpen = false; }) diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index d8054c4..366bece 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -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; }) diff --git a/entry/src/main/ets/components/FbPersonQuickSheet.ets b/entry/src/main/ets/components/FbPersonQuickSheet.ets index 93bbb60..d8639fe 100644 --- a/entry/src/main/ets/components/FbPersonQuickSheet.ets +++ b/entry/src/main/ets/components/FbPersonQuickSheet.ets @@ -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%') @@ -313,4 +417,4 @@ export struct FbPersonQuickSheet { 不设的话这一层会把空白处也吃掉,点空白关不掉(很别扭)。 */ .hitTestBehavior(HitTestMode.Transparent) } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/components/FbPersonSheet.ets b/entry/src/main/ets/components/FbPersonSheet.ets index e1d3ea1..3d64c47 100644 --- a/entry/src/main/ets/components/FbPersonSheet.ets +++ b/entry/src/main/ets/components/FbPersonSheet.ets @@ -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)时**不能再铺满 + 居中**: diff --git a/entry/src/main/ets/components/TopBar.ets b/entry/src/main/ets/components/TopBar.ets index e84bc8c..ba763fc 100644 --- a/entry/src/main/ets/components/TopBar.ets +++ b/entry/src/main/ets/components/TopBar.ets @@ -413,6 +413,10 @@ export struct FbTopBar { `AceDrag: Drag gesture has been canceled` —— 起来立刻被关**, 界面上看就是"点了没反应"(极易误判成"点击没接上")。 */ embedded: true, + /* 🔴 不给「删除」:这是"我自己的详情"。 + 删除自己既没意义又危险(服务端是物理删除,删完账号都进不去)。 + 名册里的详情卡才显示删除(那里 `canDelete` 取默认值 true)。 */ + canDelete: false, onClose: (): void => { this.myDetailSheet = false; }, diff --git a/entry/src/main/ets/data/FbDb.ets b/entry/src/main/ets/data/FbDb.ets index e954640..40e03e4 100644 --- a/entry/src/main/ets/data/FbDb.ets +++ b/entry/src/main/ets/data/FbDb.ets @@ -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; } diff --git a/entry/src/main/ets/data/FbPersonRepo.ets b/entry/src/main/ets/data/FbPersonRepo.ets index a7f3b4d..50d7304 100644 --- a/entry/src/main/ets/data/FbPersonRepo.ets +++ b/entry/src/main/ets/data/FbPersonRepo.ets @@ -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 { 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', ''), diff --git a/entry/src/main/ets/data/FbSchema.ets b/entry/src/main/ets/data/FbSchema.ets index 3a2492c..b26a740 100644 --- a/entry/src/main/ets/data/FbSchema.ets +++ b/entry/src/main/ets/data/FbSchema.ets @@ -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, diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index b296843..5f0de3a 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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)。 * diff --git a/entry/src/main/ets/pages/books/FbBooksPage.ets b/entry/src/main/ets/pages/books/FbBooksPage.ets index ba8ca1a..7f524fd 100644 --- a/entry/src/main/ets/pages/books/FbBooksPage.ets +++ b/entry/src/main/ets/pages/books/FbBooksPage.ets @@ -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; }) diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index eb2d918..89bb97d 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -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; }) diff --git a/entry/src/main/ets/pages/days/FbDaysPage.ets b/entry/src/main/ets/pages/days/FbDaysPage.ets index 46d8601..654dff8 100644 --- a/entry/src/main/ets/pages/days/FbDaysPage.ets +++ b/entry/src/main/ets/pages/days/FbDaysPage.ets @@ -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; }) diff --git a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets index 795d1c5..3904dcf 100644 --- a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets +++ b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets @@ -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//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%') diff --git a/entry/src/main/ets/pages/misc/FbMiscPages.ets b/entry/src/main/ets/pages/misc/FbMiscPages.ets index d52763e..e8bfeb7 100644 --- a/entry/src/main/ets/pages/misc/FbMiscPages.ets +++ b/entry/src/main/ets/pages/misc/FbMiscPages.ets @@ -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(() => { // 本轮只做界面:把本地未读标记清掉即可 diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets index 663d4a2..d76954a 100644 --- a/entry/src/main/ets/pages/movies/FbMoviesPage.ets +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -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; }) diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets index b02f28f..751f89f 100644 --- a/entry/src/main/ets/pages/people/FbPeoplePage.ets +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -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 { 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)); + }); + } + /** * 拨号(跳到系统拨号盘,**不直接呼出**)。 * diff --git a/entry/src/main/ets/pages/resources/FbResourcesPage.ets b/entry/src/main/ets/pages/resources/FbResourcesPage.ets index a57bd3d..dbcb4e8 100644 --- a/entry/src/main/ets/pages/resources/FbResourcesPage.ets +++ b/entry/src/main/ets/pages/resources/FbResourcesPage.ets @@ -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 }) { diff --git a/entry/src/main/ets/pages/settings/FbSettingsPage.ets b/entry/src/main/ets/pages/settings/FbSettingsPage.ets index e9203ac..209b46e 100644 --- a/entry/src/main/ets/pages/settings/FbSettingsPage.ets +++ b/entry/src/main/ets/pages/settings/FbSettingsPage.ets @@ -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) diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets index 90db882..18dd1df 100644 --- a/entry/src/main/ets/pages/tasks/FbTasksPage.ets +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -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; }) diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets index b9bd2e8..efe73fd 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -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; })