2026-10-11 22:07:48 +08:00
|
|
|
|
/**
|
2026-10-12 11:37:45 +08:00
|
|
|
|
* 家书 FamilyBook · 抽屉(鸿蒙端)
|
2026-10-11 22:07:48 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 形态对齐 Web 端顶栏 `.nav` 那一排模块入口,但**改成抽屉**(用户建议):
|
|
|
|
|
|
* Web 端导航条最多放 12 个模块,手机宽度下必然溢出;抽屉竖向排列,
|
|
|
|
|
|
* 还能顺带放下「模块说明 + 主题色」这层信息,一眼看清每个模块是干什么的。
|
|
|
|
|
|
*
|
2026-10-12 11:37:45 +08:00
|
|
|
|
* 三条形态,同一个组件:
|
2026-10-11 22:07:48 +08:00
|
|
|
|
* · **常驻侧栏**(宽屏:窗口短边 ≥ 600vp)—— 固定在左边,占 264vp;
|
2026-10-12 11:37:45 +08:00
|
|
|
|
* · **左滑出层**(手机,从左边缘向右滑)—— 覆盖内容,点遮罩或选中项即收起;
|
|
|
|
|
|
* · **右滑出层**(手机,从右边缘向左滑)—— 同上,只是贴右边(右手单手持机顺手)。
|
2026-10-11 22:07:48 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 这里**不用 SideBarContainer**:它的控制按钮与「滑出」是内建的一套,
|
|
|
|
|
|
* 样式(图标、位置、宽度动画)很难与家书的视觉统一,且它只能有一层侧栏
|
|
|
|
|
|
* 内容 —— 而我们要在抽屉里放「品牌区 + 模块列表 + 底部账号区」三段。
|
|
|
|
|
|
* 自己用 Stack + 位移动画做,行为完全可控。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
|
|
|
|
|
|
import { FbAppState, fbAppState } from '../common/Store';
|
|
|
|
|
|
import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules';
|
|
|
|
|
|
import { FbAvatar } from './Widgets';
|
|
|
|
|
|
|
|
|
|
|
|
@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();
|
|
|
|
|
|
|
2026-10-11 23:20:49 +08:00
|
|
|
|
/** 品牌区:书名(家书)+ 家庭名 */
|
2026-10-11 22:07:48 +08:00
|
|
|
|
@Builder
|
|
|
|
|
|
brandHeader() {
|
|
|
|
|
|
Row({ space: 10 }) {
|
|
|
|
|
|
Image($r('app.media.fb_logo'))
|
2026-10-11 23:20:49 +08:00
|
|
|
|
.width(32)
|
|
|
|
|
|
.height(32)
|
2026-10-11 22:07:48 +08:00
|
|
|
|
.borderRadius(9)
|
|
|
|
|
|
Column({ space: 1 }) {
|
|
|
|
|
|
Text('家书')
|
2026-10-11 23:20:49 +08:00
|
|
|
|
.fontSize(16)
|
2026-10-11 22:07:48 +08:00
|
|
|
|
.fontWeight(FontWeight.Medium)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.letterSpacing(1)
|
|
|
|
|
|
Text(this.app.session.familyTitle)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
.maxLines(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-10-11 23:20:49 +08:00
|
|
|
|
// 顶部内边距 = 状态栏高度 + 一点呼吸,保证不压状态栏
|
2026-10-12 08:43:32 +08:00
|
|
|
|
.padding({ left: 18, right: 12, top: 16, bottom: 10 })
|
2026-10-11 22:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 一个模块行 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
moduleRow(m: FbModuleDef) {
|
|
|
|
|
|
Row({ space: 12 }) {
|
|
|
|
|
|
// 图标底:选中时用模块色实底(白图标),未选中时用极浅染色底(彩色图标)
|
|
|
|
|
|
Stack() {
|
|
|
|
|
|
Image(FbIcon.module(m.key))
|
|
|
|
|
|
.width(18)
|
|
|
|
|
|
.height(18)
|
|
|
|
|
|
.renderMode(ImageRenderMode.Template)
|
|
|
|
|
|
.fillColor(this.isActive(m.key) ? Color.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();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 底部账号区(对应 Web 端右上角头像菜单里的那几项) */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
accountFooter() {
|
|
|
|
|
|
Column({ space: 2 }) {
|
|
|
|
|
|
Divider()
|
|
|
|
|
|
.color(FbColors.line2)
|
|
|
|
|
|
.margin({ bottom: 6 })
|
|
|
|
|
|
|
|
|
|
|
|
this.footerRow('fb_ic_bell', '通知消息', this.app.unread > 0 ? this.app.unread.toString() : '')
|
|
|
|
|
|
this.footerRow('fb_ic_user', '个人设置', '')
|
|
|
|
|
|
this.footerRow('fb_ic_lock', '修改密码', '')
|
|
|
|
|
|
if (this.app.session.me.role === 'admin') {
|
|
|
|
|
|
this.footerRow('fb_ic_gear', '系统设置', '')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 12, right: 12, bottom: 10 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 底部一行 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
footerRow(iconName: string, label: string, badge: string) {
|
|
|
|
|
|
Row({ space: 12 }) {
|
|
|
|
|
|
Image(FbIcon.ui(iconName))
|
|
|
|
|
|
.width(17)
|
|
|
|
|
|
.height(17)
|
|
|
|
|
|
.renderMode(ImageRenderMode.Template)
|
|
|
|
|
|
.fillColor(FbColors.muted)
|
|
|
|
|
|
Text(label)
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
if (badge.length > 0) {
|
|
|
|
|
|
Text(badge)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(Color.White)
|
|
|
|
|
|
.backgroundColor(FbColors.red)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前是否选中该模块 */
|
|
|
|
|
|
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 t.brand + '1A';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
this.accountFooter()
|
|
|
|
|
|
}
|
|
|
|
|
|
.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() {
|
2026-10-12 11:37:45 +08:00
|
|
|
|
// 抽屉贴哪边由 drawerSide 决定 ⇒ Stack 的 alignment 也跟着换。
|
|
|
|
|
|
Stack({
|
|
|
|
|
|
alignContent: this.app.drawerSide === 'right' ? Alignment.TopEnd : Alignment.TopStart
|
|
|
|
|
|
}) {
|
2026-10-11 22:07:48 +08:00
|
|
|
|
// ---- 宽屏:并排 ----
|
|
|
|
|
|
if (this.app.wideLayout) {
|
|
|
|
|
|
Row() {
|
|
|
|
|
|
FbDrawer({
|
|
|
|
|
|
app: this.app,
|
2026-10-12 08:43:32 +08:00
|
|
|
|
overlayMode: false
|
2026-10-11 22:07:48 +08:00
|
|
|
|
})
|
|
|
|
|
|
Column() {
|
|
|
|
|
|
this.content()
|
|
|
|
|
|
}
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.height('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height('100%')
|
|
|
|
|
|
} else {
|
2026-10-12 11:37:45 +08:00
|
|
|
|
// ---- 窄屏:内容铺满 + 抽屉浮层(可从左或右滑出)----
|
2026-10-11 22:07:48 +08:00
|
|
|
|
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 }))
|
|
|
|
|
|
|
2026-10-12 11:37:45 +08:00
|
|
|
|
// 抽屉本体:由 drawerSide 决定贴左还是贴右
|
|
|
|
|
|
// ⚠️ 用 Stack 的 alignment 而不是 margin 来定位:
|
|
|
|
|
|
// 动画是 translateX,用 alignment 铺位更稳(不参与测量)。
|
2026-10-11 22:07:48 +08:00
|
|
|
|
FbDrawer({
|
|
|
|
|
|
app: this.app,
|
|
|
|
|
|
overlayMode: true,
|
|
|
|
|
|
onDismiss: (): void => {
|
|
|
|
|
|
this.app.drawerOpen = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-10-12 11:37:45 +08:00
|
|
|
|
.transition(TransitionEffect.translate({
|
|
|
|
|
|
x: this.app.drawerSide === 'right' ? FbMetric.drawerW : -FbMetric.drawerW
|
|
|
|
|
|
}).animation({ duration: 220, curve: Curve.FastOutSlowIn }))
|
2026-10-11 22:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height('100%')
|
|
|
|
|
|
.backgroundColor(FbColors.paper)
|
2026-10-12 11:37:45 +08:00
|
|
|
|
// 🔴 横向滑动开抽屉(用户 2026-10-12 定的规则)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ **不要判断"起点是否贴屏幕边缘"** —— 用户实测反馈:屏幕边缘是
|
|
|
|
|
|
// **系统手势区**(返回/侧边栏),从边缘往里滑的事件根本轮不到应用处理,
|
|
|
|
|
|
// 我们无法控制。所以判据只能是"**在应用内部水平滑动**":
|
|
|
|
|
|
// 向右滑 → 抽屉从左边出;向左滑 → 抽屉从右边出。
|
|
|
|
|
|
// 这样左手持机时往右划、右手持机时往左划,单手都顺。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ `offsetX` 的单位是 **vp**(SDK 原文 "in vp"),不要再除以 density ——
|
|
|
|
|
|
// 除一次会把位移缩到 1/3,导致阈值永远达不到、手势"看起来没生效"。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 用 `parallelGesture` 而不是 `gesture`:内容区自己还有竖向滚动
|
|
|
|
|
|
// (以及纪念日/财务/任务的横向滚动条),用 gesture 会把它们抢掉。
|
|
|
|
|
|
// 代价是横向滚动条扫得猛时也可能触发 —— 所以阈值给到 80vp,
|
|
|
|
|
|
// 轻扫(浏览横向列表)不会误开。
|
|
|
|
|
|
.parallelGesture(
|
|
|
|
|
|
PanGesture({ direction: PanDirection.Horizontal, distance: 30 })
|
|
|
|
|
|
.onActionEnd((e: GestureEvent) => {
|
|
|
|
|
|
// 宽屏下抽屉常驻,不需要手势
|
|
|
|
|
|
if (this.app.wideLayout) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const dx: number = e.offsetX; // vp
|
|
|
|
|
|
// 向右滑 ⇒ 抽屉在左
|
|
|
|
|
|
if (dx > 80) {
|
|
|
|
|
|
this.app.drawerSide = 'left';
|
|
|
|
|
|
this.app.drawerOpen = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 向左滑 ⇒ 抽屉在右
|
|
|
|
|
|
if (dx < -80) {
|
|
|
|
|
|
this.app.drawerSide = 'right';
|
|
|
|
|
|
this.app.drawerOpen = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
2026-10-11 22:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|