From 41e3354fc8d527a47dd2efd256078616c02aec6f Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 5 Nov 2026 20:32:02 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BA=AA=E5=BF=B5=E6=97=A5=EF=BC=9A=E7=94=9F?= =?UTF-8?q?=E6=97=A5=E6=94=B9=E6=88=90=E5=8F=AA=E8=AF=BB=E5=8D=A1=E3=80=81?= =?UTF-8?q?=E8=87=AA=E5=BB=BA=E7=BA=AA=E5=BF=B5=E6=97=A5=E6=89=8D=E5=8F=AF?= =?UTF-8?q?=E7=BC=96=E8=BE=91=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-11-05 四条: 1.「如果这个纪念日来自生日,我们是不能编辑的。」 2.「点击之后,像日程那样弹出一个框来显示就行:显示这个人的头像、姓名, 以及生日的『日』和日期就可以了。」 3.「如果是我们自己创建的纪念日,那么这里面就是编辑纪念日。」 4.「这个框和刚才我们编辑任务的框是一样的做法。样式、格式全部都一样。」 🔴 先说一个**会改坏用户数据的 bug**(这次一并修掉): 原来派生生日也走 FbDayEditSheet,而且传的是 editingMonth: 1, editingDay: 1 ← 写死的 1 月 1 日 结果点开任何一个生日都显示「1 月 1 日」,点保存还能把 **成员档案里的真实生日覆盖成 1 月 1 日**。 这不是样式问题,是数据损坏。 改动: ① 新增 components/FbDayBirthdaySheet.ets(派生生日的只读卡) · 外壳用 FbSheetRing + 遮罩点击关闭 + 右上角 FbSheetClose —— 与 FbTaskPanel / FbDayEditSheet 同一套版式(用户第 4 条) · 左上角「生日 / 农历生日」主题色粗体 + 一行小字 「来自成员资料,不能在这里修改」——把"不能编辑"这条边界写明白, 否则用户会以为卡片坏了(用户第 1 条) · 主体:FbAvatar(56) + 姓名(主题色粗体) + 完整日期 · 「日」做成视觉焦点:84×84 浅主题色块里放 34 号大字数字 + 「11 月」 · 右侧:还有几天 / 满 N 周岁 / 农历说明 · 底部:「要修改生日,请到「家人」里打开这位成员的资料」 —— 只读也要给出口,不然用户不知道去哪儿改 ② FbHomePage 按 source 分发 birthday_solar / birthday_lunar → FbDayBirthdaySheet(只读) custom → FbDayEditSheet(可编辑,原行为) ⚠️ 判据用 source 前缀而不是 id === 0:两者目前等价,但 source 是语义、 id 是实现细节 —— 万一以后派生条目也带 id,用 id 判会静默错。 ③ 服务端 Anniversary::occurrences() 补输出 · person_name —— `birthdays()` 算出来了,但 occurrences 逐个字段挑着抄时漏了 · avatar / avatar_color —— 只读卡要画头像(key,端上拼 URL) · day / month —— 用户要的「生日的『日』」 ⚠️ 用 $a['day'] 而不是从 $date 截:农历生日的 day 是农历日 (初五 = 5),date 是换算后的公历日 —— 两者不同,用户要看"生日那天" · birthdays() 同步补 avatar / avatar_color(它原来也没有) ④ Types / FbParse 补五个字段 personName / avatar / avatarColor / day / month 编译通过,已装真机。 --- .../ets/components/FbDayBirthdaySheet.ets | 288 ++++++++++++++++++ entry/src/main/ets/data/FbParse.ets | 8 + entry/src/main/ets/model/Types.ets | 23 ++ entry/src/main/ets/pages/home/FbHomePage.ets | 40 ++- 4 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 entry/src/main/ets/components/FbDayBirthdaySheet.ets diff --git a/entry/src/main/ets/components/FbDayBirthdaySheet.ets b/entry/src/main/ets/components/FbDayBirthdaySheet.ets new file mode 100644 index 0000000..25f3575 --- /dev/null +++ b/entry/src/main/ets/components/FbDayBirthdaySheet.ets @@ -0,0 +1,288 @@ +/** + * 家书 FamilyBook · 「生日」只读卡(鸿蒙端) + * + * 🔴🔴 #87(用户 2026-11-05)四条: + * 1.「如果这个纪念日来自生日,我们是不能编辑的。」 + * 2.「点击之后,像日程那样弹出一个框来显示就行:显示这个人的头像、姓名, + * 以及生日的『日』和日期就可以了。」 + * 3.「如果是我们自己创建的纪念日,那么这里面就是编辑纪念日。」 + * 4.「这个框和刚才我们编辑任务的框是一样的做法。样式、格式全部都一样。」 + * + * ⇒ 本组件只负责 **第 1、2 条**(派生生日的只读卡)。 + * 第 3 条(自建纪念日)仍走 `FbDayEditSheet`,由 `FbHomePage.openDayDetail()` 分发。 + * 第 4 条 = 外壳用 `FbSheetRing` + 遮罩点击关闭 + 右上角关闭按钮, + * 与 `FbTaskPanel` / `FbDayEditSheet` 同一套版式。 + * + * ⚠️ **为什么不做成"编辑表单禁用输入"**: + * 派生生日的日期来自 `persons.birthday`,改它等于改这个人的人事档案 —— + * 那件事该去「成员资料」里做,不该在纪念日卡片里顺手改。 + * 做成"只读卡"把这条边界表达清楚,比"表单全灰"更不容易让人误会。 + * ⚠️ **为什么不用 `FbDayEditSheet` 传空数据**: + * 之前的实现就是这样 —— 而且 `editingMonth: 1, editingDay: 1`(写死 1 月 1 日), + * 结果点开任何一个生日都显示"1 月 1 日",还能点保存把真实生日覆盖掉。 + * 这是个会**改坏用户数据**的 bug,不是样式问题。 + * + * ⚠️ 农历生日的「日」用的是**农历日**(`day` 字段),不是公历日期里截的那一位: + * 农历十月初五要显示「初五/5」,而 `date` 是换算出来的公历日 —— 两者不同。 + */ + +import { FbColors, FbMetric, FB_MASK, fbMixWhite } from '../common/Theme'; +import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; +import { FbAvatar } from './Widgets'; +import { FbRecentDay } from '../model/Types'; +import { fbImageSrc } from '../data/FbAttachQueue'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; + +@ComponentV2 +export struct FbDayBirthdaySheet { + /** 这条纪念日(必须是派生生日:source 以 `birthday` 开头) */ + @Param item: FbRecentDay | null = null; + /** 模块主题色(纪念日 = 暖橙) */ + @Param accent: string = '#C2703E'; + + @Event onClose: () => void = (): void => { + }; + + private token(): string { + return FB_TOKEN_STORE.get(); + } + + /** + * 头像 URL。 + * ⚠️ `avatar` 是 key(`avatars/xxx.jpg`),要经 `fbImageSrc` 拼成可加载地址; + * 空 key 返回空串 → `FbAvatar` 自动退回首字色块。 + */ + private avatarSrc(): string { + const d: FbRecentDay | null = this.item; + if (d === null || d.avatar === '') { + return ''; + } + return fbImageSrc(d.avatar, this.token()); + } + + /** 显示名:优先 `personName`,退回从标题里去掉「的生日」后缀 */ + private nameOf(): string { + const d: FbRecentDay | null = this.item; + if (d === null) { + return ''; + } + if (d.personName !== '') { + return d.personName; + } + /* 兜底:标题形如「老杨 的生日」「老杨 的农历生日」,切掉后缀 */ + const cut: number = d.title.indexOf(' 的'); + return cut > 0 ? d.title.substring(0, cut) : d.title; + } + + /** + * 生日是「几号」—— 卡片正中的那个大数字。 + * + * ⚠️ 优先用 `day`(用户填的那一天);为 0 才从 `date` 里截。 + * 农历生日的 `day` 是农历日,与 `date` 的公历日**不一样**, + * 用户要看的是"生日那天"。 + */ + private dayNum(): string { + const d: FbRecentDay | null = this.item; + if (d === null) { + return ''; + } + if (d.day > 0) { + return d.day.toString(); + } + const dd: number = d.date.length >= 10 ? parseInt(d.date.substring(8, 10), 10) : 0; + return dd > 0 ? dd.toString() : ''; + } + + /** 「11 月」—— 与「几号」配成「11 月 16 日」的读法 */ + private monthWord(): string { + const d: FbRecentDay | null = this.item; + if (d === null) { + return ''; + } + if (d.month > 0) { + return d.month.toString() + ' 月'; + } + const mm: number = d.date.length >= 7 ? parseInt(d.date.substring(5, 7), 10) : 0; + return mm > 0 ? mm.toString() + ' 月' : ''; + } + + /** 完整日期 `2026 年 11 月 16 日`(下一次发生的公历日期) */ + private dateLine(): string { + const d: FbRecentDay | null = this.item; + if (d === null || d.date.length < 10) { + return ''; + } + const y: number = parseInt(d.date.substring(0, 4), 10); + const m: number = parseInt(d.date.substring(5, 7), 10); + const dd: number = parseInt(d.date.substring(8, 10), 10); + return y.toString() + ' 年 ' + m.toString() + ' 月 ' + dd.toString() + ' 日'; + } + + /** 「还有 12 天」/「就是今天」 */ + private daysWord(): string { + const d: FbRecentDay | null = this.item; + if (d === null) { + return ''; + } + if (d.days === 0) { + return '就是今天'; + } + return '还有 ' + d.days.toString() + ' 天'; + } + + /** 是农历生日吗(决定副标题写「农历生日」还是「生日」) */ + private isLunar(): boolean { + const d: FbRecentDay | null = this.item; + return d !== null && d.source === 'birthday_lunar'; + } + + build() { + /* 根必须 Stack:全屏遮罩与卡片要**叠**(与 FbTaskPanel 同一条约定) */ + Stack({ alignContent: Alignment.Center }) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .onClick((): void => { + this.onClose(); + }) + + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { + Column({ space: 0 }) { + /* ---- 顶栏:标题 + 关闭 ---- */ + /* 与 FbTaskPanel.headBar 同一版式: + 左上角「XX」主题色粗体 + 右上角关闭。 + 用户第 4 条要求"这个框和编辑任务的框一样的做法"。 */ + Row({ space: 10 }) { + Column({ space: 2 }) { + Text(this.isLunar() ? '农历生日' : '生日') + .fontSize(FbMetric.fontBody) + .fontWeight(FontWeight.Bold) + .fontColor(this.accent) + /* ⚠️ 明确写出"来自成员资料,不能在这里改" —— + 用户第 1 条要的就是这个边界,不写清楚他会以为是卡坏了。 */ + Text('来自成员资料,不能在这里修改') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + + FbSheetClose({ + onClose: (): void => { + this.onClose(); + }, + color: '' + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Divider() + .color(FbColors.line) + .margin({ top: 12, bottom: 16 }) + + /* ---- 主角:头像 + 姓名 + 「日」 ---- */ + Row({ space: 14 }) { + FbAvatar({ + name: this.nameOf(), + color: this.item !== null ? this.item.avatarColor : '', + imageUrl: this.avatarSrc(), + dia: 56, + fontSizeVp: 22 + }) + + Column({ space: 4 }) { + Text(this.nameOf()) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Bold) + .fontColor(FbColors.ink) + .maxLines(2) + .width('100%') + Text(this.dateLine()) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + } + .width('100%') + .padding({ top: 4, bottom: 18 }) + .alignItems(VerticalAlign.Center) + + /* ---- 「几号」大字块 + 剩余天数 ---- */ + /* 用户第 2 条:「显示…生日的『日』和日期就可以了」 + ⇒ 把「日」做成一个视觉焦点(大数字),日期在下面一行小字。 + 底色用浅一档主题色(fbMixWhite 0.91),与下拉框/胶囊同一套。 */ + Row({ space: 12 }) { + Column({ space: 0 }) { + Text(this.dayNum()) + .fontSize(34) + .fontWeight(FontWeight.Bold) + .fontColor(this.accent) + Text(this.monthWord()) + .fontSize(FbMetric.fontTiny) + .fontColor(fbMixWhite(this.accent, 0.35)) + } + .width(84) + .height(84) + .justifyContent(FlexAlign.Center) + .backgroundColor(fbMixWhite(this.accent, 0.91)) + .borderRadius(FbMetric.radiusS) + + Column({ space: 6 }) { + Text(this.daysWord()) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(this.accent) + if (this.item !== null && this.item.age >= 0) { + Text('满 ' + this.item.age.toString() + ' 周岁') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + } + if (this.isLunar()) { + /* 农历生日时「日」是农历日,与上面的公历日期不同 —— + 不说明一句用户会以为"日子算错了"。 */ + Text('农历日期,每年按农历换算') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + /* ---- 底部:去成员资料改 ---- */ + /* 用户第 1 条:「不能编辑」⇒ 不给编辑按钮,但**要给出口**: + 想去改生日的人得知道该去哪儿,否则只会觉得"这卡不好用"。 */ + Text('要修改生日,请到「家人」里打开这位成员的资料') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .textAlign(TextAlign.Center) + .margin({ top: 20 }) + } + .width('100%') + .padding({ left: 18, right: 18, top: 16, bottom: 18 }) + .alignItems(HorizontalAlign.Start) + } + } + .width('90%') + .constraintSize({ maxHeight: '70%' }) + .onClick((): void => { + /* 吃掉点击,避免穿透到底下的遮罩把自己关了 */ + }) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbParse.ets b/entry/src/main/ets/data/FbParse.ets index f01690e..b215859 100644 --- a/entry/src/main/ets/data/FbParse.ets +++ b/entry/src/main/ets/data/FbParse.ets @@ -184,6 +184,9 @@ interface RawRecentDay { id?: number; date?: string; title?: string; kind?: string; person_id?: number; source?: string; days?: number; age?: number | null; nth?: number | null; + /* #87:生日只读卡要用(服务端 Anniversary::occurrences 已输出) */ + person_name?: string | null; avatar?: string | null; avatar_color?: string | null; + day?: number | null; month?: number | null; } interface RawTaskBoard { @@ -913,6 +916,11 @@ export function parseRecentDays(v: Object | null | undefined): FbRecentDay[] { days: n(r.days), age: r.age !== null && r.age !== undefined ? n(r.age) : -1, nth: r.nth !== null && r.nth !== undefined ? n(r.nth) : -1, + personName: s(r.person_name), + avatar: s(r.avatar), + avatarColor: s(r.avatar_color), + day: r.day !== null && r.day !== undefined ? n(r.day) : 0, + month: r.month !== null && r.month !== undefined ? n(r.month) : 0, }; out.push(it); } diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index 5580332..c29a9a5 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -215,6 +215,29 @@ export interface FbRecentDay { age: number; /** 是第几个(自建纪念日且填了起始年才有;null = 不显示序号) */ nth: number; + /** + * 关联人的显示名(派生生日一定有;自建纪念日可能为空)。 + * + * 🔴 #87(用户 2026-11-05):「如果这个纪念日来自生日…点击之后, + * 像日程那样弹出一个框来显示就行:显示这个人的头像、姓名, + * 以及生日的『日』和日期就可以了。」 + */ + personName: string; + /** 头像 key(`avatars/xxx.jpg`;空 = 没设头像,端上退回首字色块) */ + avatar: string; + /** 头像色(没头像时按它画色块) */ + avatarColor: string; + /** + * 「日」—— 生日是哪一天(**用户填的那一天**)。 + * + * ⚠️ 与 `date` 里截出来的"日"**可能不同**:农历生日时 + * `day` 是农历日(初五 = 5),而 `date` 是换算后的公历日期。 + * 用户要看的是"生日那天",所以显示这个。 + * ⚠️ 0 = 没这个字段(自建纪念日的老数据),端上退回从 `date` 截。 + */ + day: number; + /** 「月」—— 同理(农历生日的月份是农历月) */ + month: number; } /** 首页「模块卡片」—— 一块一块的入口,带一句关键数字 */ diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index 52abdad..c8e487a 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -62,6 +62,8 @@ import { FbTaskPanel } from '../../components/FbTaskPanel'; import { FbTaskEditSheet } from '../../components/FbTaskEditSheet'; /* v86:三张卡的新建表单 —— 全是**模块页正在用的同一个组件**,不另写。 */ import { FbDayEditSheet } from '../../components/FbDayEditSheet'; +/* #87:派生生日的只读卡(用户:「如果这个纪念日来自生日,我们是不能编辑的」) */ +import { FbDayBirthdaySheet } from '../../components/FbDayBirthdaySheet'; import { FbPersonSheet } from '../../components/FbPersonSheet'; import { FbSheetRing } from '../../components/FbSheetRing'; import { FbMovieNewSheet } from '../../components/FbMovieNewSheet'; @@ -681,6 +683,23 @@ export struct FbHomePage { this.pfDetail = null; } + /** + * 这条纪念日是不是「派生生日」(不能编辑的那种)。 + * + * 判据用 `source` 前缀而不是 `id === 0`: + * · 派生生日:`birthday_solar` / `birthday_lunar`,服务端保证 `id = 0`; + * · 自建纪念日:`custom`,`id > 0`。 + * 两个判据目前等价,但 `source` 是**语义**、`id` 是**实现细节** —— + * 万一以后派生条目也带 id(比如就想让它能点进某处),用 id 判会静默错。 + */ + private isBirthdayDay(): boolean { + const d: FbRecentDay | null = this.dayDetail; + if (d === null) { + return false; + } + return d.source === 'birthday_solar' || d.source === 'birthday_lunar'; + } + /** 打开纪念日详情(#63)。 */ private openDayDetail(d: FbRecentDay): void { this.dayDetail = d; @@ -2106,7 +2125,26 @@ export struct FbHomePage { ⚠️ 复用 `FbDayEditSheet`(它就是"回填一条已有纪念日"的表单)。 它的保存/删除按钮在卡里,看着是"可改的详情卡", 符合用户"先看到信息"的要求。 */ - if (this.dayDetail !== null) { + if (this.dayDetail !== null && this.isBirthdayDay()) { + /* 🔴🔴 #87(用户 2026-11-05): + 「1. 如果这个纪念日来自生日,我们是不能编辑的。 + 2. 点击之后,像日程那样弹出一个框来显示就行:显示这个人的头像、姓名, + 以及生日的『日』和日期就可以了。」 + + ⚠️ **这里原来是个会改坏数据的 bug**:派生生日也走 `FbDayEditSheet`, + 而且传的是 `editingMonth: 1, editingDay: 1`(写死的 1 月 1 日)。 + 结果点开任何一个生日都显示「1 月 1 日」,点保存还能把 + **真实生日覆盖成 1 月 1 日** —— 等于把成员档案里的生日改掉了。 + 只读卡从根上避开这件事。 */ + FbDayBirthdaySheet({ + accent: this.daysAccent(), + item: this.dayDetail, + onClose: (): void => { + this.closeDayDetail(); + } + }) + .transition(fbSheetTransition()) + } else if (this.dayDetail !== null) { FbDayEditSheet({ accent: this.daysAccent(), editingId: this.dayDetail.id,