/** * 家书 FamilyBook · 抽屉(鸿蒙端) * * 形态对齐 Web 端顶栏 `.nav` 那一排模块入口,但**改成抽屉**(用户建议): * Web 端导航条最多放 12 个模块,手机宽度下必然溢出;抽屉竖向排列, * 还能顺带放下「模块说明 + 主题色」这层信息,一眼看清每个模块是干什么的。 * * 三条形态,同一个组件: * · **常驻侧栏**(宽屏:窗口短边 ≥ 600vp)—— 固定在左边,占 264vp; * · **左滑出层**(手机)—— 覆盖内容,点遮罩或选中项即收起; * · **右滑出层**(手机)—— 同上,只是贴右边(右手单手持机顺手)。 * * 🔴 抽屉**从哪边出**由「**握姿**」决定(用户 2026-10-16 第 4 条): * 左手握 → `app.drawerSide = 'left'`;右手握 → `'right'`。 * 逻辑在 `common/FbGrip.ets`(用官方 `multimodalAwareness.motion` 的 * `HoldingHandStatus`),不在本组件里。 * 🔴 **打开方式只有一种:点顶栏左侧的家书图标**(`pages/Index.ets`)。 * ⚠️ **横向滑动开抽屉的旧手势已删**(用户要求)—— 它与页面里的横滑、 * 与"点人名拨号"在 `parallelGesture` 下**并行触发**,冲突无法根治。 * * ⚠️ 这里**不用 SideBarContainer**:它的控制按钮与「滑出」是内建的一套, * 样式(图标、位置、宽度动画)很难与家书的视觉统一,且它只能有一层侧栏 * 内容 —— 而我们要在抽屉里放「品牌区 + 模块列表 + 底部账号区」三段。 * 自己用 Stack + 位移动画做,行为完全可控。 */ 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'; import { FbAvatar } from './Widgets'; /** * 底部手势条区域的留白(vp)。 * * 🔴 应用已开 `setWindowLayoutFullScreen(true)`(见 EntryAbility): * 内容会铺到屏幕最下沿,而**系统手势条已隐藏**(用户要求"下面没有任何的条") * ⇒ 手势区**没有任何视觉提示**,贴着底边的元素很容易被"从下往上滑"误触。 * 这里留一点缓冲,是给"手势区"而不是给"某个控件"。 */ const FB_BOTTOM_GAP: number = 16; @ComponentV2 export struct FbDrawer { /** 全局状态(父组件传入,保证与页面同一份实例) */ @Param app: FbAppState = fbAppState(); /** 抽屉宽度 */ @Param drawerW: number = FbMetric.drawerW; /** 是否以「覆盖层」形态出现(宽屏时为 false = 常驻) */ @Param overlayMode: boolean = true; /** 覆盖层形态下:点遮罩关闭 */ @Event onDismiss: () => void = () => { }; /** 抽屉里列出的模块(接口接上后由 user_settings('nav_modules') 下发) */ @Local items: FbModuleDef[] = fbDefaultNavModules(); /** * 品牌区:书名(家书)+ 标语。 * * 🔴🔴 v59(用户 2026-10-18):副标题从原来的**家庭名**(「我的家」/「杨家」) * 换成**标语**。用户原话: * 「进到系统里面之后,打开抽屉,左上角有家书的名字,上面写了"我的家"。 * 这个地方也换成我们的标语。」 * 「如果这个用户自己有标语,他可以写一句自己的标语;如果他有自己的标语, * 这个地方就是用户自己自定义的标语。如果他没有标语,就用我们给他的这个标语。」 * ⇒ 三层优先级:**个人 slogan → 家庭 motto → 应用默认口号**。 * 这个拼接**不在端上做** —— 服务端 `Family::slogan()` 已经算好并放在 * `session.slogan` 里(网页端首页是同一个函数,两边永远一致)。 * 端上只在"还没拿到"时回落 `FB_DEFAULT_SLOGAN`。 * ⚠️ 别再改回 `familyTitle` —— 那个字段现在只用于别处(如"某某的家"这类文案), * 而且用户明确说这一格要放标语。 */ @Builder brandHeader() { Row({ space: 10 }) { Image($r('app.media.fb_logo')) .width(32) .height(32) .borderRadius(9) Column({ space: 1 }) { Text('家书') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .letterSpacing(1) // 标语:用户自己的 → 家庭的 → 应用默认(服务端已算好,见上面说明) Text(this.sloganText()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) } .width('100%') // 顶部内边距 = 状态栏高度 + 一点呼吸,保证不压状态栏 .padding({ left: 18, right: 12, top: 16 + this.app.statusBarH, bottom: 10 }) } /** 该显示的标语(会话里没有就回落应用默认口号,**永远不空**) */ private sloganText(): string { const s: string = this.app.session.slogan; return s !== '' ? s : FB_DEFAULT_SLOGAN; } /** 一个模块行 */ @Builder moduleRow(m: FbModuleDef) { Row({ space: 12 }) { // 图标底:选中时用模块色实底(白图标),未选中时用极浅染色底(彩色图标) Stack() { Image(FbIcon.module(m.key)) .width(18) .height(18) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(this.isActive(m.key) ? FbTint.white : fbThemeVars(m.color).brand)) } .width(34) .height(34) .borderRadius(10) .backgroundColor(this.isActive(m.key) ? fbThemeVars(m.color).brand : this.washOf(m.color)) Column({ space: 1 }) { Text(m.name) .fontSize(FbMetric.fontBody) .fontWeight(this.isActive(m.key) ? FontWeight.Medium : FontWeight.Normal) .fontColor(FbColors.ink) Text(m.desc) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) } .width('100%') .padding({ left: 12, right: 12, top: 9, bottom: 9 }) .borderRadius(FbMetric.radiusS) .backgroundColor(this.isActive(m.key) ? this.washOf(m.color) : Color.Transparent) .onClick(() => { this.app.switchModule(m.key); this.onDismiss(); }) } /** 当前是否选中该模块 */ private isActive(key: string): boolean { return this.app.activeModule === key; } /** 模块色的极浅染色底(选中行 / 图标底) */ private washOf(hex: string): string { // 与 Theme.fbPalette 的 wash 同口径:压饱和、抬明度到 0.94 const t = fbThemeVars(hex); return fbAlpha(t.brand, 0.10); } build() { Column() { this.brandHeader() // 模块列表:可滚动,模块多时不会把底部账号区挤掉 List({ space: 1 }) { ForEach(this.items, (m: FbModuleDef) => { ListItem() { this.moduleRow(m) } }, (m: FbModuleDef): string => 'nav_' + m.key) } .layoutWeight(1) .width('100%') .padding({ left: 6, right: 6 }) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) /* 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、 修改密码、系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。 消息通知不用放进去,直接在头像旁边那里就可以了。」 ⇒ 原来这里的底部账号区(四行:通知消息 / 个人设置 / 修改密码 / 系统设置)**整块已删**: · 通知 → 顶栏铃铛(一直在头像左边,比藏抽屉里近得多); · 个人设置 / 修改密码 / 系统设置 → 点右上角头像进「个人设置」页。 ⚠️ 抽屉现在是**纯模块导航**:品牌区 + 模块列表。 ⚠️ 底部仍留手势条留白 —— 全屏布局下抽屉铺到屏幕最下沿, 最后一行的点击区贴着边缘容易被"从下往上滑"误触。 */ Column() .width('100%') .height(FB_BOTTOM_GAP) } .width(this.drawerW) .height('100%') .backgroundColor(FbColors.surface) // ⚠️ 侧栏右侧的分隔线只在常驻形态下需要;覆盖层形态下它贴着屏幕边,加了反而怪 .border(this.overlayMode ? { width: 0 } : { width: { right: 1 }, color: FbColors.line2 }) } } /** * 抽屉 + 内容 的整体骨架。 * * 两种形态在这里分派: * · 宽屏 → Row(抽屉占位 + 内容区); * · 窄屏 → Stack(内容铺满,抽屉带遮罩浮在上面)。 */ @ComponentV2 export struct FbDrawerShell { @Param app: FbAppState = fbAppState(); /** 内容区(由父组件通过尾随 lambda 传入) */ @BuilderParam content: () => void; build() { // 抽屉贴哪边由 drawerSide 决定 ⇒ Stack 的 alignment 也跟着换。 Stack({ alignContent: this.app.drawerSide === 'right' ? Alignment.TopEnd : Alignment.TopStart }) { // ---- 宽屏:并排 ---- if (this.app.wideLayout) { Row() { FbDrawer({ app: this.app, overlayMode: false }) Column() { this.content() } .layoutWeight(1) .height('100%') } .width('100%') .height('100%') } else { // ---- 窄屏:内容铺满 + 抽屉浮层(可从左或右滑出)---- Column() { this.content() } .width('100%') .height('100%') if (this.app.drawerOpen) { // 遮罩 Column() .width('100%') .height('100%') .backgroundColor('#66000000') .onClick(() => { this.app.drawerOpen = false; }) .transition(TransitionEffect.OPACITY.animation({ duration: 180 })) // 抽屉本体:由 drawerSide 决定贴左还是贴右 // ⚠️ 用 Stack 的 alignment 而不是 margin 来定位: // 动画是 translateX,用 alignment 铺位更稳(不参与测量)。 FbDrawer({ app: this.app, overlayMode: true, onDismiss: (): void => { this.app.drawerOpen = false; } }) .transition(TransitionEffect.translate({ x: this.app.drawerSide === 'right' ? FbMetric.drawerW : -FbMetric.drawerW }).animation({ duration: 220, curve: Curve.FastOutSlowIn })) } } } .width('100%') .height('100%') .backgroundColor(FbColors.paper) // 🔴🔴 **横向滑动开抽屉的手势已删除**(用户 2026-10-16 第 3、4 条)。 // // 用户原话:「这个列表上左滑、右滑和点击后打电话的事件冲突了…… // 如果实在不行,就把左滑、右滑弹出抽屉这个功能去掉吧, // 只保留点击标题或图标后弹出抽屉的功能。」 // 并再次强调:「**先这样做,把滑动出抽屉这个功能先去掉**, // 因为它现在在很多地方都跟别的功能有冲突。」 // // 历史:`parallelGesture(PanGesture)` + `fbHScrollBusy()` 的那套"让位" // 机制(见 git 历史)**治不了根本** —— 因为 `parallelGesture` 是**并行**识别, // 它和应用自己的横滑/点按/拨号**同时**成立,没有"谁先抢到"的机制。 // 名册里"滑动既弹抽屉又跳拨号"就是这个并行识别的必然结果。 // ⇒ 唯一可靠的办法是**不用横滑手势**。 // // 抽屉入口保留在:**顶栏左侧的家书图标**(`pages/Index.ets` 的 `topBarSlot`)。 // 抽屉从哪边出,改由「**握姿**」决定(见 `FbGripWatcher` / `app.drawerSide`): // 左手握 → 从左边出;右手握 → 从右边出。 } }