From 853b0e0b2018832aec4745c27719c5e9995bdf88 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 19 Oct 2026 12:19:54 +0800 Subject: [PATCH] =?UTF-8?q?v61=EF=BC=9A=E9=A1=B6=E6=A0=8F=E6=A0=87?= =?UTF-8?q?=E9=A2=98=3D=E6=A8=A1=E5=9D=97=E5=90=8D=20/=20=E6=8A=BD?= =?UTF-8?q?=E5=B1=89=E7=98=A6=E8=BA=AB=20+=20=E5=A4=B4=E5=83=8F=E8=BF=9B?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E8=AE=BE=E7=BD=AE=20/=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=BA=A2=E5=BA=95=20/=20=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=20/=20=E6=97=A5=E7=A8=8B=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=B8=8E=E6=89=8B=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户这一批九条,逐条对应: 1)「页面上左上角的标题,不再是家书了,而是切换到那个模块,就显示哪个模块的标题。 这样在页面内容部,就不再出现模块的名字标题了。」 → TopBar 主标题改读 moduleName()(通知/设置/个人设置三个非模块路由也有名字); 观影/纪念日/读书/笔记/通知五处页内大标题删掉。 2)「按钮,也需要和主题色保持一致,出现文字的时候,例如观影模块, 在左上角出现的时候,颜色也需要和主题色保持一致。」 → 标题色走 fbModuleColor(app.activeModule)(一处算,11 个页面不用各写)。 3)「弹框的外圈颜色,跟模块的主题色保持一致。」 → 日程详情原来传 bookColor(日历本色)⇒ 改 accentColor(); 7 个模块设置浮层补传 accent: this.accent。 4)「删除按钮全部用红色背景。」 → 日程编辑/人员编辑从红描边改红底白字;详情「删除联系人」从浅红底改红底白字; 两处确认框统一 FbColors.red。 5)「做几个动画,例如,点击名册列表,应该有一个过渡动画出现弹框, 点击详情,框变大了,也应该有一个过渡动画,点编辑,也有动画。」 → 新建 common/FbAnim.ets(fbMaskTransition / fbSheetTransition / fbSheetGrowTransition / fbViewTransition),9 个页面共 17 遮罩 + 16 浮层挂上; 简要卡→详情用 0.96→1 的"继续长大"(0.94 会先缩一下)。 6)「左侧抽屉,把通知消息、个人设置、修改密码、系统设置,都去掉, 放在个人设置页,就是在点击右侧头像那里。消息通知不用放进去, 直接在头像旁边那里就可以了。」 → 抽屉底部那四行整块删除(抽屉只剩品牌区 + 模块列表); 新建 pages/account/FbAccountPage.ets(整页,__account 分发), 点头像 switchModule('__account');改密码真打服务端新接口。 7)「日程……日期下面:12本日历可见,本月124条,这句话去掉。 点击日程弹出的框,编辑按钮也颜色也要修改。 全天日程,例如农历,上面显示无标题,读不到这条日程,这个有问题。」 → 删统计行;编辑按钮从写死名册青 #4A7C82 改模块主题色; 真根因:衍生条目(农历/节气)的 detailFull **一直是 null** (它们没有 /events/ 可拉,原来直接 return)⇒ 垫 shellOf(), 并把"整卡报错"改成"照常画内容 + 一行小字"; 空标题兜底从「(无标题)」改成日历本名。 8)「再做几个卡片,先做日程的,这个参考 synccalendar。」 → FbDayEventPanel.eventRow 照 SyncCalendar:左侧 4vp 圆头色条 + 标题 + 时间/全天 + 「↻ 重复」徽标 + 右端日历本名(限宽 30%、同色); 一行一张独立圆角卡(12)+ 描边,卡间 space 8,不再用 Divider 连成一片。 9)「日程,有很多手势,你参考 synccalendar。」 → 月历上滑→周视图、周条下划→月视图、周条左右滑→换周。 真机实测两次踩空后定下写法:**竖向必须 parallelGesture(PanGesture)** (外层是 Scroll,SwipeGesture 的"快速划动"会被它的内置滚动先吃掉, gesture/priorityGesture 都不行);**横滑用 gesture(PanGesture)** (同一节点两条方向不同的 parallelGesture 会互相压制); 方向按累计位移判、并要求 |dy| > |dx|。切视图走 animateTo + fbViewTransition。 验证: · 编译 BUILD SUCCESSFUL · ArkTS 守卫 493/0;Schema 列守卫 12/0;反向验证 39/0(新增 22 条注入逐条确认能变红) · 服务端接口 e2e 20/0(独立副本 + 真 MariaDB,5 种错误路径 + 原令牌仍有效) · 真机 _fb_v61_verify.py 21/0(6 组全过);像素采样确认浮层主题色环实测存在 --- entry/src/main/ets/common/FbAnim.ets | 94 +++ entry/src/main/ets/common/FbTimeline.ets | 5 +- entry/src/main/ets/components/Drawer.ets | 88 +-- .../main/ets/components/FbDayEventPanel.ets | 142 +++-- .../src/main/ets/components/FbDayTimeline.ets | 3 +- .../main/ets/components/FbEventEditSheet.ets | 12 +- .../src/main/ets/components/FbEventSheet.ets | 83 ++- .../main/ets/components/FbPersonEditSheet.ets | 7 +- .../src/main/ets/components/FbPersonSheet.ets | 14 +- entry/src/main/ets/components/TopBar.ets | 195 ++---- entry/src/main/ets/pages/Index.ets | 6 + .../main/ets/pages/account/FbAccountPage.ets | 566 ++++++++++++++++++ .../src/main/ets/pages/books/FbBooksPage.ets | 14 +- .../ets/pages/calendar/FbCalendarPage.ets | 300 ++++++++-- entry/src/main/ets/pages/days/FbDaysPage.ets | 14 +- .../main/ets/pages/ledger/FbLedgerPage.ets | 5 + entry/src/main/ets/pages/misc/FbMiscPages.ets | 18 +- .../main/ets/pages/movies/FbMoviesPage.ets | 20 +- .../main/ets/pages/people/FbPeoplePage.ets | 13 + .../ets/pages/resources/FbResourcesPage.ets | 5 + .../src/main/ets/pages/tasks/FbTasksPage.ets | 5 + .../src/main/ets/pages/trips/FbTripsPage.ets | 5 + 22 files changed, 1252 insertions(+), 362 deletions(-) create mode 100644 entry/src/main/ets/common/FbAnim.ets create mode 100644 entry/src/main/ets/pages/account/FbAccountPage.ets diff --git a/entry/src/main/ets/common/FbAnim.ets b/entry/src/main/ets/common/FbAnim.ets new file mode 100644 index 0000000..26b8d4f --- /dev/null +++ b/entry/src/main/ets/common/FbAnim.ets @@ -0,0 +1,94 @@ +/** + * 家书 FamilyBook · 浮层过渡动画(鸿蒙端) + * + * 🔴 用户 2026-10-19:「做几个动画,例如,点击名册列表,应该有一个过渡动画出现弹框, + * 点击详情,框变大了,也应该有一个过渡动画,点编辑,也有动画。」 + * + * 为什么**统一抽在这里**(而不是每个浮层自己写一遍 `.transition(...)`): + * · 本工程有 9 个页面内浮层 + 一堆 bindSheet,逐个手写必然漂移 + * (时长/曲线/缩放比各不一样,动画看起来会"不是一个应用"); + * · 全应用的浮层**必须同一种出入场**:这是"同一个系统"的观感来源之一。 + * ⚠️ 与 `FbSheetRing` 的分工:那个管**静止时的样子**(环/圆角/阴影), + * 这个管**动起来的样子**(出现/消失)。两者都只有一处定义。 + * + * 技术选型(为什么用 `TransitionEffect` 的**自带 animation**): + * · `.transition(effect)` 挂在 `if` 分支里的组件上,分支切换时自动播出入场; + * · `effect.animation({...})` 让**动画参数挂在效果自己身上** ⇒ 不需要 + * 调用方再包一层 `animateTo`(漏包一处就变成"啪一下出现",而且很难查)。 + * ⚠️ `.combine()` 要求**两边都写 animation**:SDK 原文 + * (`common.d.ts` TransitionEffect.combine 的说明): + * "If combine is used for combining transition effects, the animation settings + * of a transition effect are not effective for the combined effects." + * ⇒ 所以我们自己在外面再挂一次(`SHEET_CARD` / `SHEET_MASK` 里就是这么做的)。 + */ + +/** 浮层出入场时长(毫秒)—— 220 是"跟手但不拖沓"的区间(与底栏位移动画同档) */ +const SHEET_MS: number = 220; + +/** 遮罩淡入淡出时长 —— 比卡片略短,让"压暗"先到位、卡片再落定 */ +const MASK_MS: number = 180; + +/** + * 遮罩过渡:只做透明度,不动位置。 + * + * ⚠️ 遮罩铺满全屏,**不能**用缩放/位移 —— 那会让边缘露出没压暗的底色。 + */ +export function fbMaskTransition(): TransitionEffect { + return TransitionEffect.OPACITY.animation({ duration: MASK_MS, curve: Curve.Friction }); +} + +/** + * 浮层卡片过渡:淡入 + 从 0.94 放大到 1(消失时反向)。 + * + * 🔴 用户说的"点击详情,框变大了"就是这一条: + * 从简要卡换到详情卡是一次**整层替换**(简要卡消失、详情卡出现), + * 两张卡各自做"淡入 + 放大"⇒ 观感上就是"同一个框长大了一号"。 + * + * ⚠️ 缩放比不要给太小(如 0.5):本工程的浮层卡片是"92% 宽、高度按内容", + * 缩太小会先看到一个小方块弹一下,反而像另一个控件; + * 0.94 是"刚好看出在长大、但不觉得跳"的甜点。 + * ⚠️ 缩放中心默认是组件中心;卡片根节点是"铺满屏幕 + 内容居中", + * 所以视觉上就是**围绕屏幕中心长大**,正是要的效果。 + */ +export function fbSheetTransition(): TransitionEffect { + const effect: TransitionEffect = TransitionEffect.OPACITY + .combine(TransitionEffect.scale({ x: 0.94, y: 0.94 })); + // ⚠️ combine 之后必须**再挂一次** animation(见文件头说明) + return effect.animation({ duration: SHEET_MS, curve: Curve.FastOutSlowIn }); +} + +/** + * 从小窗换到大窗时的"继续长大"过渡(简要卡 → 详情卡)。 + * + * 与 `fbSheetTransition` 的区别只在**起始缩放比**:这里是 0.96(几乎等大), + * 因为上一层的卡片已经在屏幕上了,从 0.94 起会看到"先缩一下再长大"的抖动。 + */ +export function fbSheetGrowTransition(): TransitionEffect { + const effect: TransitionEffect = TransitionEffect.OPACITY + .combine(TransitionEffect.scale({ x: 0.96, y: 0.96 })); + return effect.animation({ duration: SHEET_MS, curve: Curve.FastOutSlowIn }); +} + +/** + * 视图内容转场(月视图 ↔ 周视图 ↔ 列表视图)。 + * + * 🔴 用户 2026-10-19:「日程,有很多手势,你参考 synccalendar。」 + * SyncCalendar 的手势切视图是**带动画**的(`switchModeAnimated` + + * `modeTransition()`:前进时旧内容上移淡出、新内容自下而上淡入)。 + * ⇒ 这里做同一件事:按 `forward` 决定方向(1 = 往"更细"的视图走)。 + * + * ⚠️ 位移量给 **48vp**(与 SyncCalendar 同值):太小看不出方向、 + * 太大在手机屏上会飞出可视区显得突兀。 + * ⚠️ 与 `fbSheetTransition` 不同,这个**不做缩放** —— 视图之间是"平级替换", + * 缩放会让人以为"页面在变大变小"。 + * + * @param forward true = 月→周→列表这种"往细里走";false = 反向 + */ +export function fbViewTransition(forward: boolean): TransitionEffect { + const appear: TransitionEffect = TransitionEffect.OPACITY.combine( + TransitionEffect.translate({ y: forward ? 48 : -48 })); + const disappear: TransitionEffect = TransitionEffect.OPACITY.combine( + TransitionEffect.translate({ y: forward ? -48 : 48 })); + return TransitionEffect.asymmetric(appear, disappear) + .animation({ duration: 260, curve: Curve.EaseInOut }); +} \ No newline at end of file diff --git a/entry/src/main/ets/common/FbTimeline.ets b/entry/src/main/ets/common/FbTimeline.ets index da85677..000b886 100644 --- a/entry/src/main/ets/common/FbTimeline.ets +++ b/entry/src/main/ets/common/FbTimeline.ets @@ -261,7 +261,10 @@ export class FbTimelineUtil { const b: FbTimeBlock = new FbTimeBlock(); b.key = FbTimelineUtil.keyOf(e); - b.title = e.title !== '' ? e.title : '(无标题)'; + /* 🔴 2026-10-19:标题为空时用**日历本名**兜底(农历/节气这类衍生条目), + 而不是「(无标题)」—— 用户看到"农历显示无标题"会以为数据坏了。 */ + b.title = e.title !== '' ? e.title + : (e.calName !== '' ? e.calName : '未命名日程'); b.timeText = FbTimelineUtil.hhmm(s) + ' - ' + FbTimelineUtil.hhmm(en); b.place = e.place; b.color = e.color; diff --git a/entry/src/main/ets/components/Drawer.ets b/entry/src/main/ets/components/Drawer.ets index 083f87f..d9120c9 100644 --- a/entry/src/main/ets/components/Drawer.ets +++ b/entry/src/main/ets/components/Drawer.ets @@ -146,80 +146,6 @@ export struct FbDrawer { }) } - /** 底部账号区(对应 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() : '', '__notifications') - this.footerRow('fb_ic_user', '个人设置', '', '__settings') - // ⚠️ 下面两项端上**还没做页面**,传空 route ⇒ 灰显、不可点。 - // 别给它们绑 `__password` / `__system` —— Index.ets 没这两个分支, - // 点下去是**空白页**,比"点不动"更让人困惑。 - this.footerRow('fb_ic_lock', '修改密码', '', '') - if (this.app.session.me.role === 'admin') { - this.footerRow('fb_ic_gear', '系统设置', '', '') - } - } - .width('100%') - // 🔴 bottom 给底部**手势条区域**留一点(全屏布局下抽屉会铺到屏幕最下沿, - // 底部这两行若贴着边,手指从下往上滑时容易误触到系统手势)。 - .padding({ left: 12, right: 12, bottom: 10 + FB_BOTTOM_GAP }) - } - - /** - * 底部一行(通知消息 / 个人设置 / 修改密码 / 系统设置)。 - * - * 🔴 必须带 `route` 并绑 onClick —— 之前这四行**根本没有点击处理**, - * 界面上是个死按钮(点下去毫无反应)。 - * 排查记录:点「个人设置」后仍停在首页,看界面树发现该元素 - * `clickable=false` 且整行没有任何可点击祖先 —— 就是这个原因。 - * ⚠️ 与模块项(`moduleRow`)不同:这四项的路由 key 是 `__xxx` 形式, - * 不是 `FB_MODULES` 里的模块,走同一个 `switchModule` 分发 - * (Index.ets 里用 `activeModule === '__settings'` 渲染对应页面)。 - * - * ⚠️ `route` 传空串表示"**这个页面还没做**" ⇒ 不绑 onClick,且文字降为灰、 - * 保持不可点。为什么不给它绑个假跳转:那会跳到**空白页** - * (Index.ets 里没有对应分支),比"点了没反应"更让人困惑。 - * 现状:`__notifications` / `__settings` 已有页面; - * `修改密码` / `系统设置` **还没做**(Web 端有,端上待开发)。 - */ - @Builder - footerRow(iconName: string, label: string, badge: string, route: string) { - Row({ space: 12 }) { - Image(FbIcon.ui(iconName)) - .width(17) - .height(17) - .renderMode(ImageRenderMode.Template) - // 未实现的项图标也变灰,明示"暂不可用" - .colorFilter(fbTint(route === '' ? FbTint.faint : FbTint.muted)) - Text(label) - .fontSize(FbMetric.fontSmall) - .fontColor(route === '' ? FbColors.faint : 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) - .onClick(() => { - if (route !== '') { - this.app.switchModule(route); // switchModule 内部会收起抽屉 - } - }) - } - /** 当前是否选中该模块 */ private isActive(key: string): boolean { return this.app.activeModule === key; @@ -250,7 +176,19 @@ export struct FbDrawer { .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) - this.accountFooter() + /* 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、 + 修改密码、系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。 + 消息通知不用放进去,直接在头像旁边那里就可以了。」 + ⇒ 原来这里的底部账号区(四行:通知消息 / 个人设置 / + 修改密码 / 系统设置)**整块已删**: + · 通知 → 顶栏铃铛(一直在头像左边,比藏抽屉里近得多); + · 个人设置 / 修改密码 / 系统设置 → 点右上角头像进「个人设置」页。 + ⚠️ 抽屉现在是**纯模块导航**:品牌区 + 模块列表。 + ⚠️ 底部仍留手势条留白 —— 全屏布局下抽屉铺到屏幕最下沿, + 最后一行的点击区贴着边缘容易被"从下往上滑"误触。 */ + Column() + .width('100%') + .height(FB_BOTTOM_GAP) } .width(this.drawerW) .height('100%') diff --git a/entry/src/main/ets/components/FbDayEventPanel.ets b/entry/src/main/ets/components/FbDayEventPanel.ets index 7ce7903..2f41962 100644 --- a/entry/src/main/ets/components/FbDayEventPanel.ets +++ b/entry/src/main/ets/components/FbDayEventPanel.ets @@ -106,10 +106,10 @@ export struct FbDayEventPanel { .width('100%') .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 8 }) } else { - // ---- 列表:一条一行 ---- - Column({ space: 0 }) { + // ---- 列表:一条一行(**一行一张独立圆角卡**,卡间留缝)---- + Column({ space: 8 }) { ForEach(this.events, (ev: FbCalEvent, idx: number) => { - this.eventRow(ev, idx === this.events.length - 1) + this.eventRow(ev) }, (ev: FbCalEvent, idx: number): string => 'ev_' + this.label + '_' + idx.toString() + '_' + ev.id.toString()) } @@ -123,60 +123,108 @@ export struct FbDayEventPanel { .alignItems(HorizontalAlign.Start) } - /** 列表模式的一条(左侧色条 + 标题 + 时间 + 地点 + 日历本名) */ + /** + * 列表模式的一条(照 SyncCalendar 的 `eventRow`:**一行一张圆角卡**)。 + * + * 🔴 用户 2026-10-19:「再做几个卡片,先做日程的,这个参考 synccalendar。」 + * + 「日程,有很多手势,你参考 synccalendar。」 + * ⇒ 照本机范本 `D:\Yongquan\code\SyncCalendar\...\pages\Index.ets` 的 + * `eventRow()` 对齐: + * · 左侧 **4vp 圆头色条**(= 日历本颜色,一眼看出属于哪本); + * · 标题(空标题用日历本名兜底,不写「(无标题)」); + * · 次行:时间 / 全天 + 「↻ 重复」小徽标 + 地点; + * · **最右侧:日历本名**(同色、最多占 30%、超长截断)。 + * ⚠️ 与原来的差别:原来是一整块白底 + `Divider` 连成一片("表格"观感), + * 现在是**卡与卡之间有缝隙**(`space` 在容器上给),每张独立圆角 + 描边。 + * ⚠️ 不再靠"是不是最后一行"来决定画不画分隔线 —— 卡片形态下 + * 分隔线是多余的(卡片自己的描边已经分开了)。 + */ @Builder - eventRow(ev: FbCalEvent, isLast: boolean) { - Column({ space: 0 }) { - Row({ space: 10 }) { - Row() - .width(4) - .height(38) - .backgroundColor(ev.color) - .borderRadius(2) + eventRow(ev: FbCalEvent) { + Row({ space: 10 }) { + /* 左侧色条:`borderRadius(2)` 做成圆头(SyncCalendar 同款)。 + ⚠️ 高度**写死 38** 而不是 `'100%'`:行高由内容决定, + 百分比高度在 `Row` 里会拿到"内容高度"⇒ 色条跟着文字多少变长变短。 */ + Column() + .width(4) + .height(38) + .borderRadius(2) + .backgroundColor(ev.color) - Column({ space: 3 }) { - Text(ev.title !== '' ? ev.title : '(无标题)') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 6 }) { - Text(ev.allDay ? '全天' - : (ev.endTime !== '' ? (ev.time + ' - ' + ev.endTime) : ev.time)) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - if (ev.place !== '') { - Text('📍' + ev.place) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .maxLines(1) - .layoutWeight(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } + Column({ space: 3 }) { + /* 🔴 2026-10-19:衍生条目(农历/节气)标题为空时用**日历本名**兜底, + 而不是「(无标题)」—— 后者看着像数据坏了(用户就是这么报的)。 */ + Text(ev.title !== '' ? ev.title + : (ev.calName !== '' ? ev.calName : '未命名日程')) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .fontWeight(FontWeight.Medium) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) + Row({ space: 6 }) { + Text(ev.allDay ? '全天' + : (ev.endTime !== '' ? (ev.time + ' - ' + ev.endTime) : ev.time)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + // 「↻ 重复」小徽标(照 SyncCalendar:浅底小圆角) + if (ev.kind === 'custom' && ev.calId > 0 && this.repeatOf(ev)) { + Text('↻ 重复') + .fontSize(10) + .fontColor(FbColors.faint) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(FbColors.inputBg) + } + if (ev.place !== '') { + Text('📍' + ev.place) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + .layoutWeight(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + /* 所属日历本:最右、同色、最多 30% 宽 + (照 SyncCalendar —— 那里也是这么放的,窄屏下不会挤掉标题) */ + if (ev.calName !== '') { Text(ev.calName) .fontSize(10) .fontColor(ev.color) .maxLines(1) - } - .width('100%') - .padding({ top: 10, bottom: 10 }) - .alignItems(VerticalAlign.Center) - .onClick((): void => { - this.onPick(ev); - }) - - if (!isLast) { - Divider() - .color(FbColors.line2) - .margin({ left: 14 }) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '30%' }) } } .width('100%') + .padding(10) + .backgroundColor(FbColors.surface) + .borderRadius(12) + // 描边:卡与卡之间即使同色也能看出界线(与浮层卡片同一手法) + .border({ width: 1, color: FbColors.line2 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.onPick(ev); + }) + } + + /** + * 这条日程是不是重复日程。 + * + * ⚠️ 列表数据(`FbCalEvent`)里**没有 rrule** —— 重复信息只在 `/events/` + * 的详情里(见 `FbCalRepo.fetchEvent`)。所以这里只能给一个"**可能重复**" + * 的粗判:非农历/节气/节假日这些衍生条目、且 id 有效。 + * ⇒ 用日历本 kind 排除衍生条目,避免"农历每天都重复"这种误标。 + * ⚠️ 这是**显示层的保守估计**:宁可不标,也不要标错 + * (用户看到"↻ 重复"点进去发现不重复会更困惑)。 + */ + private repeatOf(ev: FbCalEvent): boolean { + return ev.kind === 'custom'; } } \ No newline at end of file diff --git a/entry/src/main/ets/components/FbDayTimeline.ets b/entry/src/main/ets/components/FbDayTimeline.ets index 174487a..68eac76 100644 --- a/entry/src/main/ets/components/FbDayTimeline.ets +++ b/entry/src/main/ets/components/FbDayTimeline.ets @@ -398,7 +398,8 @@ export struct FbDayTimeline { .backgroundColor('#26000000') .borderRadius(6) .padding({ left: 5, right: 5, top: 1, bottom: 1 }) - Text(e.title !== '' ? e.title : '(无标题)') + Text(e.title !== '' ? e.title + : (e.calName !== '' ? e.calName : '未命名日程')) .fontSize(12) .fontWeight(FontWeight.Medium) .fontColor(Color.White) diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index 74019aa..18c4d19 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -529,13 +529,15 @@ export struct FbEventEditSheet { } else { Row({ space: 10 }) { if (!this.isCreate) { + // 🔴 用户 2026-10-19:「删除按钮全部用红色背景」 + // (原来是红色描边、白底 —— 与"所有删除都红底"不一致) Text('删除') .fontSize(FbMetric.fontSmall) - .fontColor('#C0533F') + .fontColor(Color.White) .textAlign(TextAlign.Center) .height(42) .padding({ left: 18, right: 18 }) - .border({ width: 1, color: '#C0533F' }) + .backgroundColor(FbColors.red) .borderRadius(FbMetric.radiusS) .onClick((): void => { if (!this.busy) { @@ -549,7 +551,9 @@ export struct FbEventEditSheet { .textAlign(TextAlign.Center) .layoutWeight(1) .height(42) - .backgroundColor(this.busy ? '#4A7C8288' : '#4A7C82') + // 🔴 底色用**日程模块主题色**(原来写死名册青 #4A7C82, + // 在日程页里看着像走错了页面 —— 用户 2026-10-19) + .backgroundColor(this.busy ? FbColors.faint : this.accentColor()) .borderRadius(FbMetric.radiusS) .onClick((): void => { if (!this.busy) { @@ -793,7 +797,7 @@ export struct FbEventEditSheet { .textAlign(TextAlign.Center) .layoutWeight(1) .height(42) - .backgroundColor('#C0533F') + .backgroundColor(FbColors.red) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.confirmDel = false; diff --git a/entry/src/main/ets/components/FbEventSheet.ets b/entry/src/main/ets/components/FbEventSheet.ets index 36a256c..a422bbf 100644 --- a/entry/src/main/ets/components/FbEventSheet.ets +++ b/entry/src/main/ets/components/FbEventSheet.ets @@ -14,7 +14,8 @@ * ⇒ 三种态都要画:加载中 / 成功 / 失败。别只画成功态。 */ -import { FbColors, FbMetric } from '../common/Theme'; +import { FbColors, FbMetric, FbTint } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbCalEventFull } from '../data/FbCalRepo'; import { FbCalBook } from '../model/Types'; import { FbDot } from './Widgets'; @@ -33,6 +34,13 @@ export struct FbEventSheet { /** 这条日程属于哪本日历(列表里那份已知,用来显示本色/名字) */ @Param bookName: string = ''; @Param bookColor: string = '#9AA0A6'; + /** + * 外圈环色 —— **模块主题色**(用户 2026-10-19:「弹框的外圈颜色, + * 跟模块的主题色保持一致」)。空 = 日程模块色。 + * ⚠️ 与 `bookColor` 分工不同:环是"我在哪个模块里", + * 标题左侧那个点才是"这条日程属于哪本日历"(一本模块下可以有多本)。 + */ + @Param accent: string = ''; /** 能不能编辑(不能写的日历本不给"编辑"按钮) */ @Param canEdit: boolean = false; @Event onClose: () => void = (): void => { @@ -40,6 +48,52 @@ export struct FbEventSheet { @Event onEdit: () => void = (): void => { }; + /** 环色:显式给了就用给的,否则取日程模块色(与日程页其它浮层一致) */ + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('calendar'); + return m !== undefined ? fbThemeVars(m.color).brand : FbTint.brand; + } + + /** + * 标题文字。 + * + * 🔴 2026-10-19:原来为空时写死「(无标题)」—— 但那条文案在**农历/节气** + * 这些衍生条目上会误导(用户看到"农历显示无标题"以为数据错了, + * 实际是标题为空时的兜底文案不合适)。派生条目本来就该用**日历本名** + * 当兜底(「农历」「节假日」本身就是它是什么),自建日程才用「未命名日程」。 + */ + private titleText(): string { + const e: FbCalEventFull | null = this.event; + if (e === null) { + return ''; + } + if (e.title !== '') { + return e.title; + } + // 日历本名能说明"这是什么"(农历/节假日/节气…) + if (this.bookName !== '') { + return this.bookName; + } + return '未命名日程'; + } + + /** + * 详情加载失败的说明。 + * + * 🔴 2026-10-19:原来加载失败会把**整张卡**换成一句错误文案 —— + * 而本地列表那份本来就能画标题/时间/地点 ⇒ 现在改成 + * **照常画内容 + 底下加一行小字说明**(见 `openEvent` 的说明)。 + */ + private errorNote(): string { + if (this.error === '') { + return ''; + } + return '部分详情没读回来:' + this.error; + } + /** 时间显示:`10月15日 周三 09:00 - 10:30` / `全天 10月15日 周三` */ private timeText(): string { const e: FbCalEventFull | null = this.event; @@ -150,13 +204,16 @@ export struct FbEventSheet { // (用户:「所有弹框都加线,保持一致」)。环色用**这本日历的颜色**, // 一眼能看出这条日程属于哪个日历本。 Column() { - FbSheetRing({ radius: FbMetric.radius, ringColor: this.bookColor }) { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { // ---- 标题区 ---- Row({ space: 10 }) { FbDot({ dia: 12, fillC: this.bookColor }) - Text(this.event !== null && this.event.title !== '' - ? this.event.title : '(无标题)') + /* 🔴 2026-10-19:标题为空时要**说清这是什么**,而不是写「(无标题)」。 + 农历 / 节气这类衍生条目的标题服务端是给了的(如「九月初一」), + 真为空的情况主要是「没填标题的自建日程」—— 那种回「未命名日程」 + 比「(无标题)」更像人话。 */ + Text(this.titleText()) .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) @@ -189,7 +246,10 @@ export struct FbEventSheet { .padding({ top: 20, bottom: 20 }) .justifyContent(FlexAlign.Center) } else if (this.event === null) { - Text(this.error !== '' ? ('读不到这条日程:' + this.error) : '读不到这条日程') + // 正常情况下不会走到这里(`openEvent` 一定会垫一份壳); + // 只有"打开浮层时状态被清掉"这种瞬时态才可能空着 —— + // 所以这里说的是"正在读取",不是"读不到"。 + Text('正在读取…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) .width('100%') @@ -215,6 +275,14 @@ export struct FbEventSheet { if (this.event.note !== '') { this.row('备注', this.event.note) } + // 🔴 详情没拉全时给一行小字(而不是把整张卡变成"读不到") + if (this.errorNote() !== '') { + Text(this.errorNote()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 8 }) + } Row().height(8) } .width('100%') @@ -247,13 +315,16 @@ export struct FbEventSheet { this.onClose(); }) if (this.canEdit && this.event !== null) { + /* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」 + ⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。 + 改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */ Text('编辑日程') .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .textAlign(TextAlign.Center) .layoutWeight(1) .height(42) - .backgroundColor('#4A7C82') + .backgroundColor(this.accentColor()) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.onEdit(); diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index 7b0ef89..9b731d2 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -969,13 +969,14 @@ export struct FbPersonEditSheet { Row({ space: 10 }) { // 新建模式没有"删除"(这人还不存在) if (!this.isCreate) { + // 🔴 用户 2026-10-19:「删除按钮全部用红色背景」 Text('删除') .fontSize(FbMetric.fontSmall) - .fontColor('#C0533F') + .fontColor(Color.White) .textAlign(TextAlign.Center) .height(42) .padding({ left: 18, right: 18 }) - .border({ width: 1, color: '#C0533F' }) + .backgroundColor(FbColors.red) .borderRadius(FbMetric.radiusS) .onClick((): void => { if (!this.busy) { @@ -1070,7 +1071,7 @@ export struct FbPersonEditSheet { .textAlign(TextAlign.Center) .layoutWeight(1) .height(42) - .backgroundColor('#C0533F') + .backgroundColor(FbColors.red) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.confirmDel = false; diff --git a/entry/src/main/ets/components/FbPersonSheet.ets b/entry/src/main/ets/components/FbPersonSheet.ets index 63c5294..1ff5252 100644 --- a/entry/src/main/ets/components/FbPersonSheet.ets +++ b/entry/src/main/ets/components/FbPersonSheet.ets @@ -47,6 +47,9 @@ const FB_SHEET_BOTTOM_GAP: number = 16; * ⚠️ 不能直接用 `FbColors.red`(那是 `Resource`):本文件要拿它拼 * `fbAlpha(RED, 0.08)`(透明度后缀)做底色,`Resource` 拼不了字符串。 * ⚠️ color.json 改色时这里要同步改(只有这一个值,风险可控)。 + * 🔴 2026-10-19 起删除按钮统一**红底白字**(`FbColors.red`), + * 这个常量只剩"拼浅红底"的历史用途;没有那种用法时它就是个死常量, + * 留着只为对照 color.json —— 新代码请直接用 `FbColors.red`。 */ const RED: string = '#C0533F'; @@ -557,8 +560,9 @@ export struct FbPersonSheet { * 🔴 用户 2026-10-18:「应该在详情页面的最下面加一个删除按钮。」 * ⇒ 位置在**编辑/关闭之下**、并且与它们之间隔一条分隔线: * 手指从上往下扫时不会顺手点到;想删必须**再往下够**一次。 - * ⚠️ 视觉上只给文字 + 图标(不做成大红块): - * 它是"偶尔用一次"的操作,大红按钮会抢走"编辑"的注意力。 + * 🔴 用户 2026-10-19:「删除按钮全部用红色背景。」 + * ⇒ 从"浅红底 + 红字"改成**红底白字**(与编辑/删除浮层里的 + * 删除按钮同一规格;位置仍在最底部 + 有二次确认兜底)。 * ⚠️ 确认框在页面侧(`FbPeoplePage.confirmDelete`)—— 见 `onDelete` 的说明。 */ if (this.canDelete) { Divider() @@ -570,16 +574,16 @@ export struct FbPersonSheet { .width(14) .height(14) /* ⚠️ PNG 图标必须 colorFilter(fillColor 只对 SVG 生效) */ - .colorFilter(fbTint(RED)) + .colorFilter(fbTint(FbTint.white)) Text('删除联系人') .fontSize(FbMetric.fontSmall) - .fontColor(RED) + .fontColor(Color.White) .fontWeight(FontWeight.Medium) } .width('100%') .height(42) .justifyContent(FlexAlign.Center) - .backgroundColor(fbAlpha(RED, 0.08)) + .backgroundColor(FbColors.red) .borderRadius(FbMetric.radiusS) .margin({ top: 10 }) .onClick((): void => { diff --git a/entry/src/main/ets/components/TopBar.ets b/entry/src/main/ets/components/TopBar.ets index ba763fc..3260eee 100644 --- a/entry/src/main/ets/components/TopBar.ets +++ b/entry/src/main/ets/components/TopBar.ets @@ -2,7 +2,7 @@ * 家书 FamilyBook · 顶栏(鸿蒙端) * * 用户 2026-10-12 定的形态: - * · **应用图标 + 「家书」标题**贴左上(下行小字显示当前模块); + * · **应用图标 + 当前模块名**贴左上(用户 2026-10-19 改:不再是固定的「家书」); * · **点图标 = 开抽屉**(用户要求:汉堡按钮去掉后,把这个功能挪到图标上); * · 顶栏底色**白色**(与下面的个人信息卡同色,连成一片); * · 顶栏**并入页面滚动流**,向上滑动时跟着内容一起滑走。 @@ -19,12 +19,10 @@ import { FbColors, FbIcon, FbMetric, FbTint, fbTint } from '../common/Theme'; import { FbAppState, fbAppState } from '../common/Store'; import { FbSyncState, SYNC_DONE, SYNC_FAIL, SYNC_RUNNING, fbSyncState } from '../common/FbSyncStore'; -import { fbModuleOf } from '../model/Modules'; +import { fbModuleColor, fbModuleOf } from '../model/Modules'; import { FbAvatar } from './Widgets'; -import { FbPersonSheet } from './FbPersonSheet'; // ⚠️ `inst()` 拿单例(本工程所有 repo 都是这个约定,见 FbPersonRepo 文件头); // `FB_TOKEN_STORE` 在 **FbTokenStore.ets** 里(不是在 FbDeviceStore)。 -import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { fbMediaUrl } from '../data/FbHttp'; import { syncAll } from '../data/FbSyncAll'; @@ -48,14 +46,6 @@ export struct FbTopBar { /** 同步面板是否打开 */ @Local syncSheet: boolean = false; - /** 「我的详情」浮层是否打开(点头像触发) */ - @Local myDetailSheet: boolean = false; - /** 「我的详情」里的人(null = 还没取到) */ - @Local myDetail: FbRosterPerson | null = null; - /** 「我的详情」是否在加载 */ - @Local myDetailLoading: boolean = false; - /** 「我的详情」的兜底提示(拿不到资料时显示) */ - @Local myDetailNote: string = ''; build() { // 🔴 顶栏横向镜像 —— 「质感握姿」(用户 2026-10-16 第 4 条): @@ -87,15 +77,17 @@ export struct FbTopBar { }) Column({ space: 0 }) { - Text('家书') + // 🔴 标题 = **当前模块名**(用户 2026-10-19:「不再是家书了, + // 而是切换到那个模块,就显示哪个模块的标题」)。 + // ⇒ 模块名在页面内容部**不再重复出现**(各页头的大标题已删)。 + // ⚠️ 文字色用**模块主题色**(同一条要求:「出现文字的时候, + // 例如观影模块,在左上角出现的时候,颜色也需要和主题色保持一致」)。 + // ⇒ 走 `fbModuleColor()` 而不是各页自己传色:顶栏只有一个, + // 在这里算一次全模块都一致(也避免了 11 个页面各写一遍)。 + Text(this.moduleName()) .fontSize(19) .fontWeight(FontWeight.Bold) - .fontColor(FbColors.ink) - .maxLines(1) - // 副标题显示当前模块名 —— "品牌 + 当前位置"一行说清 - Text(this.moduleName()) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) + .fontColor(this.moduleColor()) .maxLines(1) } .alignItems(HorizontalAlign.Start) @@ -244,28 +236,13 @@ export struct FbTopBar { .justifyContent(FlexAlign.Center) .margin({ left: FbMetric.gapTopActions }) .onClick((): void => { - this.openMyDetail(); - }) - // 「我的详情」浮层 —— 复用名册那个人详情组件(同一个组件、同一套字段) - // - // ⚠️ 用 `bindSheet` 而不是自己在 `build` 里叠一层遮罩: - // 这条顶栏在**每个模块页**都会渲染(`topBarSlot`), - // 自己叠遮罩要处理"盖住悬浮栏 / 盖住抽屉 / 手势返回"一堆事; - // bindSheet 是系统能力,这些它都管(同步面板也是这么做的)。 - // - // ⚠️ `onDisappear` 必须复位 `myDetailSheet`(点遮罩/下滑关闭都会走它), - // 否则下次点头像**打不开**(状态位还是 true 但面板已经不在)。 - .bindSheet(this.myDetailSheet, this.myDetailPanel(), { - // ⚠️ 用 `LARGE`(= 大高度、内容自适应)而不是 `FIT_CONTENT`: - // 详情卡片有 `maxHeight: '72%'` 的内部约束,FIT_CONTENT 下 - // 系统会试图把 sheet 压到"刚好包住内容",与那个百分比约束打架。 - height: SheetSize.LARGE, - dragBar: true, - showClose: false, - backgroundColor: FbColors.surface, - onDisappear: (): void => { - this.myDetailSheet = false; - } + /* 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、 + 修改密码、系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。」 + ⇒ 点头像**进个人设置页**(整页),不再弹「我的详情」浮层。 + ⚠️ 换的是 `switchModule` 而不是自己开浮层:整页才有地方放 + "改密码 / 系统设置 / 退出登录",而那三样都是页面级的。 + ⚠️ 顶栏在**每个模块页**都存在 ⇒ 这里只切状态位,渲染由 Index 分发。 */ + this.app.switchModule('__account'); }) } .width('100%') @@ -300,11 +277,32 @@ export struct FbTopBar { .border({ width: { bottom: 1 }, color: FbColors.line2 }) } + /** + * 顶栏标题 = 当前模块名。 + * + * 🔴 两个非模块路由(通知 / 设置)也要有名字 —— 它们不在 `FB_MODULES` 里, + * 不认就会回落「家书」,而那一页明明不是首页。 + */ private moduleName(): string { - const m = fbModuleOf(this.app.activeModule); + const k: string = this.app.activeModule; + if (k === '__notifications') { + return '通知消息'; + } + if (k === '__settings') { + return '设置'; + } + if (k === '__account') { + return '个人设置'; + } + const m = fbModuleOf(k); return m !== undefined ? m.name : '家书'; } + /** 顶栏标题色 = 当前模块的主题色(认不出回落到品牌砖红) */ + private moduleColor(): string { + return fbModuleColor(this.app.activeModule); + } + /** * 手动同步(面板里的按钮)。 * @@ -333,119 +331,6 @@ export struct FbTopBar { : ''; } - /** - * 打开「我」的详情浮层(点右上角头像触发)。 - * - * 🔴 v59b(用户 2026-10-18):「点击头像进入我的详情页面。」 - * - * 为什么**先打开浮层、再补数据**(而不是等网络回来才开): - * 本地优先是本应用的立身之本 —— 点一下要**立刻**有东西出来, - * 断网时也必须能看。本地库那份已经够画详情的前几段, - * 网络回来后再原地补全(`fetchDetail`)。 - * - * ⚠️ 失败时**不弹错误**:`me.id` 拿不到(未登录/数据还没同步下来)就只弹一句 - * "资料还没同步下来",而不是让用户对着一个空白浮层猜。 - */ - private async openMyDetail(): Promise { - const meId: number = this.app.session.me.id; - if (meId <= 0) { - this.myDetailNote = '还没拿到你的资料,请先同步一次'; - this.myDetailSheet = true; - return; - } - /* ⚠️ 先开浮层(`myDetailSheet = true`)再 await: - 否则网络慢时用户点了一下**几十毫秒内毫无反应**,会以为按钮坏了。 - `loading` 由浮层自己显示,观感上"点开就有东西"。 */ - this.myDetailLoading = true; - this.myDetail = null; - this.myDetailSheet = true; - try { - const local: FbRosterPerson | null = await FbPersonRepo.inst().byId(meId); - this.myDetail = local; - this.myDetailLoading = false; - // 本地有 ⇒ 顺手用网络那份补全(口径与名册里点某人完全一致) - const token: string = FB_TOKEN_STORE.get(); - if (token !== '' && local !== null) { - const full: FbRosterPerson | null = await FbPersonRepo.inst().fetchDetail(meId, token); - // ⚠️ 浮层可能已经被关掉、或期间换了人 ⇒ 只在"还开着同一个人"时才覆盖 - if (full !== null && this.myDetailSheet - && this.myDetail !== null && this.myDetail.id === meId) { - this.myDetail = full; - } - } - } catch (err) { - const e: BusinessError = err as BusinessError; - hilog.warn(0x0000, 'FamilyBook', '取我的详情失败:%{public}s', - e.message !== undefined ? e.message : JSON.stringify(err)); - this.myDetailLoading = false; - } - } - - /** - * 「我的详情」面板(点头像弹出)。 - * - * ⚠️ 三种状态都要有话说(用户 2026-10-18 报过"点了没反应"这类问题): - * · 加载中 ⇒ 「正在读取…」(不要让浮层空着) - * · 拿到了 ⇒ 复用 `FbPersonSheet`(姓名/称呼/电话/生日/单位/标签…) - * · 拿不到 ⇒ 显式一句 `myDetailNote`(而不是空白) - */ - @Builder - myDetailPanel() { - Column() { - if (this.myDetailLoading) { - Row({ space: 8 }) { - LoadingProgress().width(18).height(18).color(FbColors.brand) - Text('正在读取我的资料…') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - } - .width('100%') - .justifyContent(FlexAlign.Center) - .padding({ top: 28, bottom: 28 }) - } else if (this.myDetail !== null) { - FbPersonSheet({ - person: this.myDetail, - /* 🔴🔴 必须传 embedded(2026-10-18 真机踩出来的真 bug): - `FbPersonSheet` 的根节点是"铺满全屏 + 内部居中"的浮层形态 - (它的设计宿主是"页面里叠遮罩 + Stack 居中",名册页就这么用)。 - 直接塞进 `bindSheet`(内容要自适应高度)会**测量互相打架**: - 真机症状是**每次点击 sheet 都创建了、紧接着 - `AceDrag: Drag gesture has been canceled` —— 起来立刻被关**, - 界面上看就是"点了没反应"(极易误判成"点击没接上")。 */ - embedded: true, - /* 🔴 不给「删除」:这是"我自己的详情"。 - 删除自己既没意义又危险(服务端是物理删除,删完账号都进不去)。 - 名册里的详情卡才显示删除(那里 `canDelete` 取默认值 true)。 */ - canDelete: false, - onClose: (): void => { - this.myDetailSheet = false; - }, - // ⚠️ 这里**不给编辑入口**:编辑要跳名册页那套编辑浮层(含标签/电话增删), - // 而顶栏在**每个模块页**都存在 —— 在这里接编辑会变成 - // "在日程页里编辑家庭成员的资料",职责与页面上下文都不对。 - // 要看/改详细资料,从名册进(那是它的主场)。 - onEdit: (): void => { - this.myDetailSheet = false; - } - }) - } else { - Text(this.myDetailNote !== '' - ? this.myDetailNote - : '暂时拿不到你的资料,下拉同步一次再试') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .textAlign(TextAlign.Center) - .width('100%') - .padding({ top: 28, bottom: 28, left: 24, right: 24 }) - } - } - .width('100%') - /* ⚠️ 不要给这层加 `constraintSize({maxHeight})`: - sheet 的高度由 `SheetSize.LARGE` 决定,卡片自己已有 `maxHeight:72%`; - 再叠一层百分比约束只会让"高度该多高"变成三方打架 - (真机症状就是浮层起来立刻被关)。 */ - } - /** * 同步面板(点顶栏图标弹出)。 * diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 5f0de3a..2b66f5e 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -32,6 +32,7 @@ import { FbLedgerPage } from './ledger/FbLedgerPage'; import { FbBooksPage } from './books/FbBooksPage'; import { FbNotesPage, FbNotificationsPage } from './misc/FbMiscPages'; import { FbSettingsPage } from './settings/FbSettingsPage'; +import { FbAccountPage } from './account/FbAccountPage'; import { FbLoginPage } from './login/FbLoginPage'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FB_SERVER_STORE } from '../data/FbServerStore'; @@ -670,6 +671,11 @@ struct Index { FbNotificationsPage({ app: this.app, topBar: this.topBarSlot }) } else if (this.app.activeModule === '__settings') { FbSettingsPage({ app: this.app, topBar: this.topBarSlot }) + } else if (this.app.activeModule === '__account') { + /* 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、修改密码、 + 系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。」 + ⇒ 点右上角头像进这一页(抽屉底部那四行已删)。 */ + FbAccountPage({ app: this.app, topBar: this.topBarSlot }) } else { // 兜底:模块还没做页面时给一句提示,而不是白屏 Column({ space: 8 }) { diff --git a/entry/src/main/ets/pages/account/FbAccountPage.ets b/entry/src/main/ets/pages/account/FbAccountPage.ets new file mode 100644 index 0000000..8fa7928 --- /dev/null +++ b/entry/src/main/ets/pages/account/FbAccountPage.ets @@ -0,0 +1,566 @@ +/** + * 家书 FamilyBook · 个人设置页(鸿蒙端) + * + * 🔴🔴 用户 2026-10-19 原话: + * 「左侧抽屉,把通知消息、个人设置、修改密码、系统设置,都去掉, + * 放在个人设置页,就是在点击右侧头像那里。 + * 消息通知不用放进去,直接在头像旁边那里就可以了。」 + * + * ⇒ 三件事: + * ① **抽屉底部那四行整块删掉**(见 `Drawer.ets`)——抽屉只留品牌区 + 模块列表; + * ② **点右上角头像进本页**(原来是弹「我的详情」浮层); + * ③ 本页承载:我的资料 / 修改密码 / 系统设置(管理员)/ 退出登录。 + * 消息通知**不在这里** —— 它在头像左边的铃铛上(用户明确要求)。 + * + * ⚠️ 为什么做成**整页**而不是浮层: + * 原来点头像弹的是 `FbPersonSheet`(详情浮层),但它只能看、不能改密码, + * 也没有"系统设置"这种需要独立空间的东西。用户说"放在个人设置页" —— + * 这是**一页**,不是一张卡。⇒ 走 `app.activeModule = '__account'`, + * 与 `__settings` / `__notifications` 同一条分发机制(`Index.moduleContent`)。 + * ⚠️ 不要"顺手"把它做成 bindSheet:那是浮层,返回手势、顶栏标题、 + * 悬浮栏让位全都要单独处理,而整页这三样都自动对。 + * + * ⚠️ 顶栏:本页也放 `topBar`(随内容滚走),所以左上角会显示「个人设置」 + * (见 `TopBar.moduleName()` 里那个 `__account` 分支)。 + */ + +import { FbColors, FbMetric, FbTint, fbTint } from '../../common/Theme'; +import { FbAppState, fbAppState } from '../../common/Store'; +import { FbAvatar, FbTag } from '../../components/Widgets'; +import { FB_DOCK_RESERVE } from '../../components/FloatDock'; +import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; +import { FB_SERVER_STORE } from '../../data/FbServerStore'; +import { FB_DEVICE_STORE, FB_APP_VERSION } from '../../data/FbDeviceStore'; +import { FB_SESSION } from '../../data/FbSession'; +import { fbApi, fbMediaUrl, FbApiResult } from '../../data/FbHttp'; +import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; +import { FbSheetRing } from '../../components/FbSheetRing'; +import { common } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbAccount'; + +/** + * 改密码表单的状态。 + * + * ⚠️ ArkTS 里给状态赋"空表单"时必须走**具名 interface 字面量** + * (`arkts-no-untyped-obj-literals`)—— 所以这个 interface 留着。 + * 请求体不走它(那里用 `Record`,见 `changePassword`)。 + */ +interface FbPwForm { + old: string; + fresh: string; + again: string; +} + +@ComponentV2 +export struct FbAccountPage { + /** 顶栏插槽(由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走) */ + @BuilderParam topBar: () => void; + @Param app: FbAppState = fbAppState(); + + /** 个人设置的主题色(取砖红:它是"应用级"设置,不属于任何业务模块) */ + private accent: string = '#A84A28'; + + /** 改密码浮层是否打开 */ + @Local pwOpen: boolean = false; + /** 三个输入框 */ + @Local pw: FbPwForm = { old: '', fresh: '', again: '' }; + /** 改密码进行中 */ + @Local pwBusy: boolean = false; + /** 改密码的结果文案(成功或失败都写这里) */ + @Local pwNote: string = ''; + /** 结果是不是"错误"(决定文字颜色) */ + @Local pwBad: boolean = false; + + /** 系统设置浮层是否打开(仅管理员) */ + @Local sysOpen: boolean = false; + + /** 账号接口拿回来的信息(拿不到就只显示会话里那份) */ + @Local accountNote: string = ''; + @Local lastLogin: string = ''; + + aboutToAppear(): void { + this.loadAccount(); + } + + /** + * 拉一次 `/account`(拿到"上次登录时间"这类会话里没有的信息)。 + * + * ⚠️ 失败**不弹错**:这些是"锦上添花"的信息,本页的骨架 + * (头像/姓名/角色/服务器/设备名)全部来自本地会话,断网也必须能看。 + * 只在下方留一句灰字说明,不打断用户。 + */ + private async loadAccount(): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + return; + } + try { + const r: FbApiResult = await fbApi('/api/v1/account'); + if (!r.ok) { + this.accountNote = r.error; + return; + } + const o: Record = JSON.parse(r.data) as Record; + const acc: Record = o['account'] as Record; + if (acc !== undefined && acc !== null) { + const last: string = acc['last_login'] as string; + this.lastLogin = last !== undefined ? this.cnTime(last) : ''; + } + this.accountNote = ''; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '读账号信息失败:%{public}s', + e.message !== undefined ? e.message : JSON.stringify(err)); + } + } + + /** `2026-10-19 08:57:08` → `10-19 08:57` */ + private cnTime(s: string): string { + return s.length >= 16 ? (s.substring(5, 10) + ' ' + s.substring(11, 16)) : s; + } + + /** 角色 → 中文 */ + private roleText(): string { + const r: string = this.app.session.me.role; + if (r === 'admin') { + return '管理员'; + } + return r === 'guest' ? '只读访客' : '家庭成员'; + } + + /** 成像样的服务器名(去掉协议) */ + private serverHost(): string { + const b: string = FB_SERVER_STORE.get(); + return b === '' ? '还没设置' : b.replace(/^https?:\/\//i, ''); + } + + /** 我的原始头像(可能是相对地址,交给 fbMediaUrl 归一化) */ + private avatarRaw(): string { + const u: string | undefined = this.app.session.me.avatarUrl; + return u !== undefined ? u : ''; + } + + /** + * 提交改密码。 + * + * 🔴 先把两处**能在本地判的**判掉(不一致 / 太短),再打接口 —— + * 少一次往返、提示也更即时(服务端那边同样会再判一次,不依赖端上)。 + */ + private async changePassword(): Promise { + if (this.pwBusy) { + return; + } + if (this.pw.old === '' || this.pw.fresh === '') { + this.pwBad = true; + this.pwNote = '当前密码和新密码都得填'; + return; + } + if (this.pw.fresh !== this.pw.again) { + this.pwBad = true; + this.pwNote = '两次输入的新密码不一致'; + return; + } + if (this.pw.fresh.length < 8) { + this.pwBad = true; + this.pwNote = '新密码至少 8 位,且要有字母和数字'; + return; + } + this.pwBusy = true; + this.pwNote = ''; + this.pwBad = false; + // ⚠️ 请求体按**项目惯例**逐键赋值(先建空 `Record` 再塞): + // 本工程 ArkTS 不允许把"带字面量属性"的对象直接当 `Record`, + // 要么显式 interface、要么逐键赋 —— 后者是 `FbCalRepo.toBody()` 的写法。 + const body: Record = {}; + body['old_password'] = this.pw.old; + body['password'] = this.pw.fresh; + body['password2'] = this.pw.again; + try { + const r: FbApiResult = await fbApi('/api/v1/account/password', + { method: 'POST', body: body }); + if (!r.ok) { + this.pwBad = true; + this.pwNote = r.error; + return; + } + this.pwBad = false; + this.pwNote = '密码已更新,下次登录用新密码。'; + // 清空输入框(成功之后留在屏幕上没意义,还可能被旁人瞄到) + const blank: FbPwForm = { old: '', fresh: '', again: '' }; + this.pw = blank; + } catch (err) { + const e: BusinessError = err as BusinessError; + this.pwBad = true; + this.pwNote = '改密码失败:' + (e.message !== undefined ? e.message : '网络不通'); + } finally { + this.pwBusy = false; + } + } + + build() { + Stack({ alignContent: Alignment.Bottom }) { + Scroll() { + Column({ space: 0 }) { + this.topBar() + + // ==================== 我的资料 ==================== + Row({ space: 14 }) { + FbAvatar({ + name: this.app.session.me.name, + color: this.app.session.me.avatarColor, + dia: 56, + fontSizeVp: 22, + imageUrl: fbMediaUrl(this.avatarRaw(), FB_TOKEN_STORE.get()) + }) + Column({ space: 5 }) { + Row({ space: 6 }) { + Text(this.app.session.me.name) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + FbTag({ text: this.roleText(), tone: 'brand' }) + } + .alignItems(VerticalAlign.Center) + Text(this.app.session.familyTitle + ' · ' + this.app.session.me.kinship) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .maxLines(1) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .padding(FbMetric.cardPad) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) + .alignItems(VerticalAlign.Center) + + // ==================== 账号 ==================== + Column({ space: 0 }) { + this.rowTitle('账号') + this.plainRow('服务器', this.serverHost()) + this.plainRow('这台设备', FB_DEVICE_STORE.getDeviceName()) + if (this.lastLogin !== '') { + this.plainRow('上次登录', this.lastLogin) + } + if (this.accountNote !== '') { + Text(this.accountNote) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 6 }) + } + } + .padding(FbMetric.cardPad) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) + .alignItems(HorizontalAlign.Start) + + // ==================== 安全 ==================== + Column({ space: 0 }) { + this.rowTitle('安全') + this.entryRow('修改密码', '定期换一次,登录更放心', (): void => { + this.pwNote = ''; + this.pwOpen = true; + }) + if (this.app.session.me.role === 'admin') { + this.entryRow('系统设置', '家庭成员、模块与存储(管理员)', (): void => { + this.sysOpen = true; + }) + } + } + .padding(FbMetric.cardPad) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) + .alignItems(HorizontalAlign.Start) + + // ==================== 关于 ==================== + Column({ space: 0 }) { + this.rowTitle('关于') + this.plainRow('版本', FB_APP_VERSION) + this.plainRow('本机数据', '已加密保存,退出登录会清空') + } + .padding(FbMetric.cardPad) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 }) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + // 底部悬浮快捷栏的预留(它浮在内容之上,不留白会压住最后一屏) + .padding({ bottom: FB_DOCK_RESERVE }) + } + .width('100%') + .height('100%') + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .onDidScroll(fbOnScroll) + .onTouch(fbOnTouch) + .align(Alignment.TopStart) + + // ---- 改密码浮层 ---- + if (this.pwOpen) { + Column() + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .transition(fbMaskTransition()) + .onClick((): void => { + this.pwOpen = false; + }) + + /* ⚠️ 过渡动画挂在 **@Builder 的根节点上**,不能写在**调用处** —— + `@Builder` 返回 void,在它后面接任何修饰符都编译不过 + (本项目在别处踩过同一条)。 */ + this.pwSheet() + } + + // ---- 系统设置浮层(仅管理员;先给入口与说明,细项后续补)---- + if (this.sysOpen) { + Column() + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .transition(fbMaskTransition()) + .onClick((): void => { + this.sysOpen = false; + }) + + this.sysSheet() + } + } + .width('100%') + .height('100%') + } + + /** 区块小标题 */ + @Builder + rowTitle(t: string) { + Text(t) + .fontSize(FbMetric.fontBody) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .width('100%') + .padding({ bottom: 6 }) + } + + /** 「标签 —— 值」一行 */ + @Builder + plainRow(label: string, value: string) { + Row({ space: 10 }) { + Text(label) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .width(72) + Text(value) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .layoutWeight(1) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .padding({ top: 9, bottom: 9 }) + .alignItems(VerticalAlign.Top) + } + + /** 可点的一行(右边带一个小箭头) */ + @Builder + entryRow(label: string, hint: string, onTap: () => void) { + Row({ space: 10 }) { + Column({ space: 2 }) { + Text(label) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + Text(hint) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Image($r('app.media.fb_ic_forward')) + .width(14) + .height(14) + /* ⚠️ PNG 图标必须 colorFilter(fillColor 只对 SVG 生效,见 Theme.fbTint) */ + .colorFilter(fbTint(FbTint.faint)) + } + .width('100%') + .padding({ top: 9, bottom: 9 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + onTap(); + }) + } + + /** 改密码浮层 */ + @Builder + pwSheet() { + Column() { + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { + Column({ space: 10 }) { + Text('修改密码') + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .width('100%') + + this.pwField('当前密码', this.pw.old, (v: string): void => { + this.pw.old = v; + }) + this.pwField('新密码(至少 8 位,含字母和数字)', this.pw.fresh, (v: string): void => { + this.pw.fresh = v; + }) + this.pwField('再输一次新密码', this.pw.again, (v: string): void => { + this.pw.again = v; + }) + + if (this.pwNote !== '') { + Text(this.pwNote) + .fontSize(FbMetric.fontTiny) + .fontColor(this.pwBad ? FbColors.red : FbColors.green) + .width('100%') + .maxLines(3) + } + + Row({ space: 10 }) { + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.pwOpen = false; + }) + Text(this.pwBusy ? '提交中…' : '确认修改') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(this.pwBusy ? FbColors.faint : this.accent) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.changePassword(); + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('100%') + .padding(18) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + .constraintSize({ maxHeight: '82%' }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + /* ⚠️ 透明命中:点卡片以外的区域要能穿透到宿主遮罩(点遮罩关浮层)。 + 不设的话这一层会把空白处也吃掉,点空白关不掉。 */ + .hitTestBehavior(HitTestMode.Transparent) + /* ⚠️ 过渡动画挂**根节点**(@Builder 返回 void,调用处不能链式修饰) */ + .transition(fbSheetTransition()) + } + + /** 改密码里的一个输入框 */ + @Builder + pwField(placeholder: string, value: string, onChange: (v: string) => void) { + TextInput({ placeholder: placeholder, text: value }) + .type(InputType.Password) + .placeholderColor(FbColors.faint) + .placeholderFont({ size: FbMetric.fontSmall }) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(42) + .padding({ left: 12, right: 12 }) + .onChange((v: string): void => { + onChange(v); + }) + } + + /** + * 系统设置浮层(管理员)。 + * + * ⚠️ 端上只给**已有的那几项**(成员与权限、模块主题色、存储)—— + * 它们本来就散在「设置页」里(`FbSettingsPage`)。 + * 这里先做成一个明确的入口 + 说明,点「打开完整设置」跳到那一页, + * 避免把同一套 UI 维护两遍(一处改了另一处漂移)。 + */ + @Builder + sysSheet() { + Column() { + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { + Column({ space: 10 }) { + Text('系统设置') + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .width('100%') + Text('家庭成员与权限、模块显隐与主题色、存储与诊断 —— 管理员专属。') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .width('100%') + + Row({ space: 10 }) { + Text('关闭') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.sysOpen = false; + }) + Text('打开完整设置') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(this.accent) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.sysOpen = false; + this.app.switchModule('__settings'); + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('100%') + .padding(18) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + .constraintSize({ maxHeight: '82%' }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) + /* ⚠️ 过渡动画挂**根节点**(同改密码浮层的说明) */ + .transition(fbSheetTransition()) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/books/FbBooksPage.ets b/entry/src/main/ets/pages/books/FbBooksPage.ets index 7f524fd..822e04f 100644 --- a/entry/src/main/ets/pages/books/FbBooksPage.ets +++ b/entry/src/main/ets/pages/books/FbBooksPage.ets @@ -17,6 +17,7 @@ import { loadModule } from '../../data/FbModuleLoad'; import { FbEmptyBox, FbIconButton, FbProgressLine, FbRating, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_BOOKS } from '../../model/FbSettingsMenus'; @@ -107,17 +108,14 @@ export struct FbBooksPage { this.topBar() // ---- 页头 ---- + // 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「读书」) Row({ space: 8 }) { Column({ space: 2 }) { - Text('书架') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) Text(this.all.length.toString() + ' 本 · ' + this.readingCount().toString() + ' 本在读 · ' + this.noteCount().toString() + ' 条笔记') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) @@ -184,11 +182,14 @@ export struct FbBooksPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) FbModuleSettingsSheet({ + // 🔴 环色 = 本模块主题色(用户 2026-10-19) + accent: this.accent, title: '读书设置', items: FB_SET_BOOKS, onClose: (): void => { @@ -199,6 +200,7 @@ export struct FbBooksPage { // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 } }) + .transition(fbSheetTransition()) } } .width('100%') diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index 89bb97d..cc30603 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -38,8 +38,12 @@ import { FbCalSettingsSheet } from '../../components/FbCalSettingsSheet'; import { FbDayEventPanel } from '../../components/FbDayEventPanel'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition, fbViewTransition } from '../../common/FbAnim'; import { FB_BACK } from '../../common/FbBack'; +/** 视图粗细顺序(决定切视图时过渡的上下方向,见 `switchModeAnimated`) */ +const FB_CAL_VIEW_ORDER: string[] = ['month', 'week', 'agenda']; + /** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */ /** * 列表(和月历)要覆盖的日期范围:今天 -7 ~ 今天 +60 天。 @@ -139,6 +143,111 @@ export struct FbCalendarPage { private weekLabels: string[] = ['一', '二', '三', '四', '五', '六', '日']; /** 详情请求序号(防"连点两条,前一条的响应覆盖后一条") */ private detailToken: number = 0; + /** + * 视图切换的过渡方向(1 = 往细里走:月→周→列表;-1 = 反向)。 + * + * ⚠️ **不是 `@Local`**:它只在一次过渡里被读一次(决定内容向上还是向下淡出), + * 本身不驱动渲染 —— 驱动渲染的是 `mode`。给它 `@Trace` 反而会多一轮通知。 + */ + private viewDir: number = 1; + + /* ══════════════════════════════════════════════════════════════ + 手势(用户 2026-10-19:「日程,有很多手势,你参考 synccalendar」) + ══════════════════════════════════════════════════════════════ + 照本机范本 SyncCalendar 的三条手势(见它 `pages/Index.ets`): + · 月视图**上划** → 切到周视图(`monthSwiper` 上的 Vertical SwipeGesture); + · 周视图**下划** → 回到月视图(左栏整块的 Vertical SwipeGesture); + · 周条**左右划** → 上一周 / 下一周(`weekStrip` 上的 Horizontal SwipeGesture)。 + ⚠️ 为什么月历上不做"左右滑翻月":那条在 SyncCalendar 里是 `Swiper` 干的 + (三页循环),而家书这里是**一个固定网格 + 上一月/下一月按钮**。 + 硬做横滑翻月要引入 Swiper 或自己算位移,收益(翻月)远小于风险 + (和格子点击、页面纵向滚动打架)——用户要的是"手势丰富", + 这三条已经覆盖"换视图 / 换周",翻月按钮仍在。 + ⚠️ 判定方向必须用 **`event.angle`**(SyncCalendar 的 `isSwipeUp/Left` 同款): + 它的取值区间在不同版本上是 `-180..180` 或 `0..360` 两种, + 所以**两种都要兼容**(照抄那份的写法,别自己"优化"成比大小)。 + ⚠️ 切视图要**带动画**:`switchModeAnimated()` 走 `animateTo`, + 与 SyncCalendar 的 260ms `EaseInOut` 同档。 */ + + /** + * 竖向手势的位移/方向记录(月历上滑切周、周条下划回月)。 + * + * 🔴🔴 为什么不用 `SwipeGesture`(真机实测两次都没成): + * 这一页最外层是 `Scroll`(整页可滚),月历卡在它的内容里。 + * `SwipeGesture` 要求"**快速**划动"才识别,而 `Scroll` 的内置滚动 + * 会**先**吃掉这次位移 ⇒ onAction 一次都不触发(真机:慢划 800ms、 + * `priorityGesture` 都试过,视图一动不动;节点里仍是「上一月/下一月」)。 + * ⚠️ SyncCalendar 能用 `SwipeGesture` 是因为它的月视图**不在外层 Scroll 里** + * (`Column().layoutWeight(1)` 直接占满),两者不可照搬。 + * ⇒ 改用 `PanGesture`(看**位移**、不看速度)+ `parallelGesture` + * (与外层 Scroll **并行**收到事件,Scroll 照常滚,我们只做方向判定)。 + * ⚠️ 阈值 + 方向判据:垂直位移必须明显大于水平位移(否则横滑换周会被误判)。 + */ + private panY0: number = 0; + private panX0: number = 0; + + /** PanGesture 落点:记起点 */ + private onPanStart(event: GestureEvent): void { + this.panY0 = event.offsetY; + this.panX0 = event.offsetX; + } + + /** + * PanGesture 结束:按**累计位移**判方向并触发。 + * + * @param onUp 向上滑动(指尖上移 ⇒ offsetY 为负)时触发 + * @param onDown 向下滑动时触发 + * @param minDx 位移阈值(vp)—— 太小会把"轻点抖动"当成滑动 + */ + private onPanEnd(event: GestureEvent, onUp: () => void, onDown: () => void, + minDx: number): void { + const dy: number = event.offsetY; + const dx: number = event.offsetX; + // 纵向位移必须**明显大于**横向(否则横滑换周会被误判成竖向切视图) + if (Math.abs(dy) < minDx || Math.abs(dy) <= Math.abs(dx)) { + return; + } + if (dy < 0) { + onUp(); + } else { + onDown(); + } + } + + /** 带过渡动画地切视图(月 / 周 / 列表) */ + private switchModeAnimated(target: FbCalView): void { + if (this.mode === target) { + return; + } + /* ⚠️ 先记方向再切:`viewDir` 只影响**过渡的上下方向** + (1 = 往细里走:月→周→列表;-1 = 反向)。 + 照 SyncCalendar 的 `navDir`(见它 `switchModeAnimated`)。 */ + this.viewDir = FB_CAL_VIEW_ORDER.indexOf(target) > FB_CAL_VIEW_ORDER.indexOf(this.mode) + ? 1 : -1; + this.getUIContext().animateTo({ duration: 260, curve: Curve.EaseInOut }, (): void => { + this.mode = target; + // ⚠️ 手势切视图**也存成默认视图** —— 与点胶囊分段同一口径 + // (否则"我划到周视图,下次进来又是月",用户会以为是 bug) + FB_CAL_SETTINGS.setView(target); + }); + } + + /** 周条左右滑 → 上一周 / 下一周(一次 7 天,跨月时顺带翻月) */ + private shiftWeek(delta: number): void { + const base: Date = this.pickedIso.length >= 10 + ? new Date(parseInt(this.pickedIso.substring(0, 4), 10), + parseInt(this.pickedIso.substring(5, 7), 10) - 1, + parseInt(this.pickedIso.substring(8, 10), 10)) + : new Date(); + const dt: Date = new Date(base.getFullYear(), base.getMonth(), + base.getDate() + delta * 7); + this.pickedIso = this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); + // 跨月了就把月历也翻过去(否则"周条到了下个月、月历还停在上个月") + if (dt.getFullYear() !== this.showYear || dt.getMonth() + 1 !== this.showMonth) { + this.showYear = dt.getFullYear(); + this.showMonth = dt.getMonth() + 1; + } + } /** * 🔴 v59e(用户 2026-10-18):系统返回手势 —— 只关**最上面一层**浮层。 @@ -384,6 +493,14 @@ export struct FbCalendarPage { return n; } + /** + * ⚠️ 2026-10-19:`monthEventCount()` 的**唯一调用方**(页头那句 + * 页头那句统计)已按用户要求删除 ⇒ 这个方法现在是**死代码**。 + * 留着的理由:它算的口径("本月有几条真日程")在"日程统计"这类功能里 + * 还会用到,且它很便宜(一次线性扫已筛选列表)。 + * 真不需要时连这个方法一起删即可 —— 别让守卫误以为"忘了删"。 + */ + // ============================================================ // 月历格子 // ============================================================ @@ -489,19 +606,37 @@ export struct FbCalendarPage { // 浮层开合 // ============================================================ - /** 点一条日程 → 开详情(先把列表那份垫上,再联网补全) */ + /** + * 点一条日程 → 开详情(先把列表那份垫上,再联网补全)。 + * + * 🔴🔴 2026-10-19 修的真 bug(用户:「全天日程,例如农历,上面显示无标题, + * 详情读不出来,这个有问题」)。 + * 真因:**衍生条目的 `detailFull` 一直是 `null`**。 + * 农历 / 节气 / 节假日 / 纪念日这些条目**没有** `/events/` 可拉 + * (服务端的 `ref_id` 是 0,它们根本不是 events 表里的行), + * 所以原来这里直接 `return`,`detailFull` 留在 null ⇒ 浮层标题行 + * 标题走空标题兜底、正文走"读不出来"那一支。 + * ⇒ 修法:**非可编辑条目也要把列表那份包装成 `FbCalEventFull`**, + * 这样浮层拿到的是一份完整可用(只是不能编辑)的详情。 + * ⚠️ 不要"在浮层里再判一次 isEditable 就显示别的话" —— 那是把同一个 + * 判断散到两处:以后加一种新 kind 又会漏(这里才是唯一入口)。 + */ private openEvent(e: FbCalEvent): void { this.detailBase = e; this.detailFull = null; this.detailError = ''; - // 非 custom 的衍生条目(农历/节气)没有 `/events/` 可拉 ⇒ 不请求 + // 非 custom 的衍生条目(农历/节气/节假日…)没有 `/events/` 可拉 + // ⇒ 直接用列表那份(字段够画详情;`canEdit` 那边会给 false,不给编辑按钮) if (!isEditableEvent(e)) { this.detailLoading = false; + this.detailFull = this.shellOf(e); return; } const token: string = FB_TOKEN_STORE.get(); if (token === '') { this.detailLoading = false; + // 没令牌(离线)也要能看列表里那份,而不是一句"读不到" + this.detailFull = this.shellOf(e); return; } this.detailLoading = true; @@ -513,29 +648,50 @@ export struct FbCalendarPage { } this.detailLoading = false; if (full === null) { + /* ⚠️ 拉不到详情时**不要只报错**:本地那份列表数据本来就够画标题/时间/地点, + 先把它垫上(用户至少能看到"这是哪条日程"),再把原因写进 error + 由浮层显示成一行小字。原来只报错会让整张卡变成"读不到"。 */ this.detailError = '读不到详情'; + this.detailFull = this.shellOf(e); return; } this.detailFull = full; }); } + /** + * 用列表里那条(`FbCalEvent`)拼一份"完整日程"的壳。 + * + * ⚠️ 只垫**列表已知的字段**:`rrule` / `reminder_minutes` 给空串 —— + * 浮层那边对空串就是"不显示这一行",不会画出假信息。 + * (宁可少一行,也不要说"不重复" —— 那是错的。) + */ + private shellOf(b: FbCalEvent): FbCalEventFull { + const shell: FbCalEventFull = { + id: b.id, calendar_id: b.calId, title: b.title, location: b.place, + starts_at: b.date + ' ' + (b.allDay ? '00:00:00' : (b.time + ':00')), + ends_at: b.endTime !== '' ? (b.date + ' ' + b.endTime + ':00') : '', + all_day: b.allDay, note: b.note, rrule: '', reminder_minutes: '', + status: 'confirmed', transp: false, created_by: 0 + }; + return shell; + } + /** 关详情并开编辑 */ private editFromDetail(): void { + /* 🔴 2026-10-19:编辑入口**只对可编辑条目开**。 + 原来这里只判 `detailFull !== null` —— 而修好"衍生条目也有 detailFull" + 之后(见 `openEvent`),农历/节气也会有值 ⇒ 会把它们当成可编辑日程 + 送进编辑浮层(那里点保存必然失败)。 + 判据用**列表那份**(`detailBase`):它是权威的"这条是从哪来的"。 */ + if (this.detailBase !== null && !isEditableEvent(this.detailBase)) { + return; + } if (this.detailFull !== null) { this.editEvent = this.detailFull; this.editing = true; } else if (this.detailBase !== null) { - // 详情还没拉回来 ⇒ 先垫一个"只有列表字段"的壳 - const b: FbCalEvent = this.detailBase; - const shell: FbCalEventFull = { - id: b.id, calendar_id: b.calId, title: b.title, location: b.place, - starts_at: b.date + ' ' + (b.allDay ? '00:00:00' : (b.time + ':00')), - ends_at: b.endTime !== '' ? (b.date + ' ' + b.endTime + ':00') : '', - all_day: b.allDay, note: b.note, rrule: '', reminder_minutes: '', - status: 'confirmed', transp: false, created_by: 0 - }; - this.editEvent = shell; + this.editEvent = this.shellOf(this.detailBase); this.editing = true; } this.detailBase = null; @@ -589,11 +745,28 @@ export struct FbCalendarPage { .padding({ top: 40, bottom: 40 }) .justifyContent(FlexAlign.Center) } else if (this.mode === 'month') { - this.monthBody() + /* 🔴 2026-10-19:三个视图各包一层 `Column` 再挂过渡 —— + `@Builder` 返回 void,**调用处不能链式** `.transition()` + (本项目在别处踩过),所以必须有个真组件当挂载点。 + `fbViewTransition(forward)` 的方向由 `viewDir` 决定 + (见 `switchModeAnimated`)。 */ + Column() { + this.monthBody() + } + .width('100%') + .transition(fbViewTransition(this.viewDir >= 0)) } else if (this.mode === 'week') { - this.weekBody() + Column() { + this.weekBody() + } + .width('100%') + .transition(fbViewTransition(this.viewDir >= 0)) } else { - this.agendaBody() + Column() { + this.agendaBody() + } + .width('100%') + .transition(fbViewTransition(this.viewDir >= 0)) } } .width('100%') @@ -632,6 +805,7 @@ export struct FbCalendarPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.closeDetail(); }) @@ -640,6 +814,8 @@ export struct FbCalendarPage { event: this.detailFull, loading: this.detailLoading, error: this.detailError, + // 🔴 环色/编辑按钮色 = 日程模块主题色(用户 2026-10-19) + accent: this.accent, bookName: this.detailBase !== null ? this.detailBase.calName : '', bookColor: this.detailBase !== null ? this.detailBase.color : '#9AA0A6', canEdit: this.detailBase !== null && isEditableEvent(this.detailBase), @@ -650,6 +826,7 @@ export struct FbCalendarPage { this.editFromDetail(); } }) + .transition(fbSheetTransition()) } if (this.editing && this.editEvent !== null) { @@ -657,6 +834,7 @@ export struct FbCalendarPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.editing = false; this.editEvent = null; @@ -677,6 +855,7 @@ export struct FbCalendarPage { this.refresh(); } }) + .transition(fbSheetTransition()) } if (this.creating) { @@ -684,6 +863,7 @@ export struct FbCalendarPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.creating = false; }) @@ -703,6 +883,7 @@ export struct FbCalendarPage { this.creating = false; } }) + .transition(fbSheetTransition()) } if (this.settingsOpen) { @@ -710,6 +891,7 @@ export struct FbCalendarPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) @@ -725,6 +907,7 @@ export struct FbCalendarPage { this.style = FB_CAL_SETTINGS.getStyle(); } }) + .transition(fbSheetTransition()) } } .width('100%') @@ -735,25 +918,21 @@ export struct FbCalendarPage { @Builder header() { Row({ space: 8 }) { - Column({ space: 2 }) { - if (this.mode === 'month') { - Text(this.showYear.toString() + ' 年 ' + this.showMonth.toString() + ' 月') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - } else { - Text(this.labelOf(this.pickedIso)) - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - } - Text(this.visibleBooks().length.toString() + ' 本日历可见 · 本月 ' - + this.monthEventCount().toString() + ' 条') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) + /* 🔴 用户 2026-10-19:页头日期下面那句**日历本数 + 本月条数**的统计 + 要整句去掉(原话见当轮记忆)。 + ⇒ 原来这里是两行(月份标题 + 那句统计),统计行**已删**。 + 那一行既占高度、又要算一遍 `visibleBooks()` 与 `monthEventCount()` + (每次重绘都跑),而"有几本可见 / 本月几条"用户点开设置就能看到。 + ⚠️ 留下月份标题(它是这一页唯一的"现在是哪个月"的指示)。 + ⚠️ `visibleBooks()` **仍在用**(别顺手删掉):月视图的筛选靠它。 + `monthEventCount()` 如果没有别的调用方,见它自己的说明。 */ + Text(this.mode === 'month' + ? (this.showYear.toString() + ' 年 ' + this.showMonth.toString() + ' 月') + : this.labelOf(this.pickedIso)) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .layoutWeight(1) // 「今天」(照 SyncCalendar 那颗:文字按钮、品牌色描边) Text('今天') @@ -866,6 +1045,24 @@ export struct FbCalendarPage { .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad }) + /* 🔴 用户 2026-10-19:「日程,有很多手势,你参考 synccalendar。」 + ⇒ 月历网格**上划**切到周视图。 + ⚠️ 用 `parallelGesture(PanGesture)` 而不是 `.gesture(SwipeGesture)` —— + 原因见 `onPanEnd` 上那段说明(外层 Scroll 会吃掉 SwipeGesture)。 + `parallelGesture` 让外层滚动**照常工作**,我们只并行做方向判定。 */ + .parallelGesture( + PanGesture({ direction: PanDirection.Vertical }) + .onActionStart((event: GestureEvent): void => { + this.onPanStart(event); + }) + .onActionEnd((event: GestureEvent): void => { + this.onPanEnd(event, (): void => { + this.switchModeAnimated('week'); + }, (): void => { + // 月视图下划不做事(回月视图是周视图那边的手势) + }, 40); + }) + ) // ---- 选中日的日程 ---- this.dayPanel() @@ -976,6 +1173,41 @@ export struct FbCalendarPage { .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad }) + /* 🔴 用户 2026-10-19(手势,参考 SyncCalendar): + · **左右划** → 上一周 / 下一周(同它 `weekStrip` 的 Horizontal SwipeGesture); + · **下划** → 回到月视图。 + ⚠️ 横滑用 `.gesture()`、竖滑用 `.parallelGesture()` —— **两者手段不同** + (真机实测定的): + · 横滑**不会**与外层 `Scroll` 的纵向滚动冲突 ⇒ 用普通 `gesture` 即可; + · 竖滑必须 `parallelGesture`,否则被 Scroll 吃掉(见 `onPanEnd` 的说明)。 + ⚠️ 一开始为了"写法统一"把横滑也挂 `parallelGesture` ⇒ **横滑失效** + (真机:左滑后周条日期一动不动)。同一节点上挂两条 `parallelGesture` + 且方向不同时,另一条会被压制 —— 别为了整齐牺牲可用性。 */ + .gesture( + PanGesture({ direction: PanDirection.Horizontal }) + .onActionEnd((event: GestureEvent): void => { + const dx: number = event.offsetX; + const dy: number = event.offsetY; + if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) { + return; + } + // 左滑(dx < 0)= 下一周;右滑 = 上一周 + this.shiftWeek(dx < 0 ? 1 : -1); + }) + ) + .parallelGesture( + PanGesture({ direction: PanDirection.Vertical }) + .onActionStart((event: GestureEvent): void => { + this.onPanStart(event); + }) + .onActionEnd((event: GestureEvent): void => { + this.onPanEnd(event, (): void => { + // 周视图上划不做事 + }, (): void => { + this.switchModeAnimated('month'); + }, 40); + }) + ) this.dayPanel() } diff --git a/entry/src/main/ets/pages/days/FbDaysPage.ets b/entry/src/main/ets/pages/days/FbDaysPage.ets index 654dff8..06c25b6 100644 --- a/entry/src/main/ets/pages/days/FbDaysPage.ets +++ b/entry/src/main/ets/pages/days/FbDaysPage.ets @@ -18,6 +18,7 @@ import { loadModule } from '../../data/FbModuleLoad'; import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_DAYS } from '../../model/FbSettingsMenus'; @@ -110,15 +111,12 @@ export struct FbDaysPage { this.topBar() // ---- 页头 ---- + // 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「纪念日」) Row({ space: 8 }) { Column({ space: 2 }) { - Text('纪念日') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) Text('节日 / 纪念日 / 生日,都在这一条时间线上') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) @@ -236,11 +234,14 @@ export struct FbDaysPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) FbModuleSettingsSheet({ + // 🔴 环色 = 本模块主题色(用户 2026-10-19) + accent: this.accent, title: '纪念日设置', items: FB_SET_DAYS, onClose: (): void => { @@ -251,6 +252,7 @@ export struct FbDaysPage { // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 } }) + .transition(fbSheetTransition()) } } .width('100%') diff --git a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets index 3904dcf..64dfc07 100644 --- a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets +++ b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets @@ -23,6 +23,7 @@ import { loadModule, loadPath } from '../../data/FbModuleLoad'; import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_LEDGER } from '../../model/FbSettingsMenus'; @@ -370,11 +371,14 @@ export struct FbLedgerPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) FbModuleSettingsSheet({ + // 🔴 环色 = 本模块主题色(用户 2026-10-19) + accent: this.accent, title: '账本设置', items: FB_SET_LEDGER, onClose: (): void => { @@ -385,6 +389,7 @@ export struct FbLedgerPage { // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 } }) + .transition(fbSheetTransition()) } } .width('100%') diff --git a/entry/src/main/ets/pages/misc/FbMiscPages.ets b/entry/src/main/ets/pages/misc/FbMiscPages.ets index e8bfeb7..44e3d3a 100644 --- a/entry/src/main/ets/pages/misc/FbMiscPages.ets +++ b/entry/src/main/ets/pages/misc/FbMiscPages.ets @@ -64,15 +64,12 @@ export struct FbNotesPage { Column() { this.topBar() + // 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「笔记」) Row({ space: 8 }) { Column({ space: 2 }) { - Text('笔记') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) Text(this.all.length.toString() + ' 条 · 分块 + 标签') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) @@ -224,16 +221,13 @@ export struct FbNotificationsPage { Column() { this.topBar() + // 🔴 2026-10-19:页面名大标题已删(顶栏左上角就是「通知消息」) Row({ space: 8 }) { Column({ space: 2 }) { - Text('通知消息') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) Text(this.unreadCount().toString() + ' 条未读 · 共 ' + this.all.length.toString() + ' 条') - .fontSize(FbMetric.fontTiny) - .fontColor(this.unreadCount() > 0 ? FbColors.red : FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(this.unreadCount() > 0 ? FbColors.red : FbColors.muted) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets index d76954a..200435c 100644 --- a/entry/src/main/ets/pages/movies/FbMoviesPage.ets +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -39,6 +39,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { fbImageSrc } from '../../data/FbAttachQueue'; const DOMAIN: number = 0x0000; @@ -359,6 +360,7 @@ export struct FbMoviesPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.sheetOpen = false; }) @@ -373,6 +375,7 @@ export struct FbMoviesPage { this.sheetOpen = false; } }) + .transition(fbSheetTransition()) } // ---- 影院列表浮层 ---- @@ -384,6 +387,7 @@ export struct FbMoviesPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.cinemaSheetOpen = false; }) @@ -394,6 +398,7 @@ export struct FbMoviesPage { this.cinemaSheetOpen = false; } }) + .transition(fbSheetTransition()) } // ---- 模块设置浮层(三条横线点开的那个)---- @@ -402,11 +407,14 @@ export struct FbMoviesPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) FbModuleSettingsSheet({ + // 🔴 环色 = 本模块主题色(用户 2026-10-19) + accent: this.accent, title: '观影设置', items: FB_SET_MOVIES, onClose: (): void => { @@ -420,6 +428,7 @@ export struct FbMoviesPage { } } }) + .transition(fbSheetTransition()) } } .width('100%') @@ -433,16 +442,13 @@ export struct FbMoviesPage { Column() { this.topBar() - // ---- 页头:标题 + 记一场电影 + 设置 ---- + // ---- 页头:统计 + 记一场电影 + 设置 ---- + // 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「观影」,不再重复) Row({ space: 8 }) { Column({ space: 2 }) { - Text('观影记录') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) Text('共 ' + this.movies.length.toString() + ' 部 · 按观影时间排序') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets index 751f89f..7ef507f 100644 --- a/entry/src/main/ets/pages/people/FbPeoplePage.ets +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -34,6 +34,7 @@ import { call } from '@kit.TelephonyKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetGrowTransition, fbSheetTransition } from '../../common/FbAnim'; import { fbImageSrc } from '../../data/FbAttachQueue'; import { FB_BACK } from '../../common/FbBack'; import { FbSharePerson, fbPersonShareText, fbShareText } from '../../common/FbShare'; @@ -629,6 +630,7 @@ export struct FbPeoplePage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.detailPerson = null; }) @@ -657,6 +659,11 @@ export struct FbPeoplePage { this.confirmDelete(); } }) + /* 🔴 用户 2026-10-19:「点击详情,框变大了,也应该有一个过渡动画。」 + ⇒ 详情用 **grow** 过渡(0.96→1)而不是标准弹框的 0.94→1: + 从简要卡换到详情卡是"整层替换",起始比接近 1 才像"同一个框长大", + 用 0.94 会先看到轻微缩一下再长大(抖动)。 */ + .transition(fbSheetGrowTransition()) } // ---- 简要卡片(点列表整行弹出)---- @@ -669,6 +676,7 @@ export struct FbPeoplePage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.quickPerson = null; this.quickFromDetail = null; @@ -713,6 +721,7 @@ export struct FbPeoplePage { } } }) + .transition(fbSheetTransition()) } // ---- 编辑浮层 ---- @@ -721,6 +730,7 @@ export struct FbPeoplePage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.editPerson = null; }) @@ -762,6 +772,7 @@ export struct FbPeoplePage { this.syncNote = who !== null ? ('已删除 ' + who.shown) : '已删除'; } }) + .transition(fbSheetTransition()) } // ---- 新建成员浮层(复用编辑浮层,`isCreate` 打开新建模式)---- @@ -770,6 +781,7 @@ export struct FbPeoplePage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.creating = false; }) @@ -792,6 +804,7 @@ export struct FbPeoplePage { this.creating = false; } }) + .transition(fbSheetTransition()) } } .width('100%') diff --git a/entry/src/main/ets/pages/resources/FbResourcesPage.ets b/entry/src/main/ets/pages/resources/FbResourcesPage.ets index dbcb4e8..4d745f6 100644 --- a/entry/src/main/ets/pages/resources/FbResourcesPage.ets +++ b/entry/src/main/ets/pages/resources/FbResourcesPage.ets @@ -17,6 +17,7 @@ import { loadModule } from '../../data/FbModuleLoad'; import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus'; @@ -201,11 +202,14 @@ export struct FbResourcesPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) FbModuleSettingsSheet({ + // 🔴 环色 = 本模块主题色(用户 2026-10-19) + accent: this.accent, title: '资源设置', items: FB_SET_RESOURCES, onClose: (): void => { @@ -216,6 +220,7 @@ export struct FbResourcesPage { // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 } }) + .transition(fbSheetTransition()) } } .width('100%') diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets index 18dd1df..8eaf856 100644 --- a/entry/src/main/ets/pages/tasks/FbTasksPage.ets +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -18,6 +18,7 @@ import { loadModule } from '../../data/FbModuleLoad'; import { FbCheckCircle, FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_TASKS } from '../../model/FbSettingsMenus'; @@ -207,11 +208,14 @@ export struct FbTasksPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) FbModuleSettingsSheet({ + // 🔴 环色 = 本模块主题色(用户 2026-10-19) + accent: this.accent, title: '任务设置', items: FB_SET_TASKS, onClose: (): void => { @@ -222,6 +226,7 @@ export struct FbTasksPage { // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 } }) + .transition(fbSheetTransition()) } } .width('100%') diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets index efe73fd..d4cc86f 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -21,6 +21,7 @@ import { loadModule } from '../../data/FbModuleLoad'; import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_TRIPS } from '../../model/FbSettingsMenus'; @@ -200,11 +201,14 @@ export struct FbTripsPage { .width('100%') .height('100%') .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) .onClick((): void => { this.settingsOpen = false; }) FbModuleSettingsSheet({ + // 🔴 环色 = 本模块主题色(用户 2026-10-19) + accent: this.accent, title: '旅程设置', items: FB_SET_TRIPS, onClose: (): void => { @@ -215,6 +219,7 @@ export struct FbTripsPage { // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 } }) + .transition(fbSheetTransition()) } } .width('100%')