/** * 家书 FamilyBook · 日程详情浮层(鸿蒙端) * * 用户 2026-10-15:「……包括下面日程的显示啊什么之类的,就按照 SyncCalendar 那个模式去做。」 * SyncCalendar 的详情是**半屏 bindSheet**,内容为: * 标题(带日历本色点 + 系统/只读徽标 + 关闭)→ 时间 → 日历本/重复 → * 地点(可点导航)→ 备注 → 「编辑日程」。 * * 🔴 这里用**页面内 Stack 覆盖层**而不是 `bindSheet`(与家书其他浮层一致, * 理由见 `FbMovieDetailSheet` 的文件头); * **功能与信息结构**照 SyncCalendar 对齐。 * * ⚠️ 详情数据是**异步拉的**(`/events/` 才给 note/rrule/reminder/ends_at) * ⇒ 三种态都要画:加载中 / 成功 / 失败。别只画成功态。 */ 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'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; import { fbCanNavigate } from '../common/FbNav'; const FB_SHEET_BOTTOM_GAP: number = 16; @ComponentV2 export struct FbEventSheet { /** 日程详情(null = 还在加载) */ @Param event: FbCalEventFull | null = null; /** 加载中 */ @Param loading: boolean = false; /** 加载失败说明(空 = 没失败) */ @Param error: string = ''; /** 这条日程属于哪本日历(列表里那份已知,用来显示本色/名字) */ @Param bookName: string = ''; @Param bookColor: string = '#9AA0A6'; /** * 外圈环色 —— **模块主题色**(用户 2026-10-19:「弹框的外圈颜色, * 跟模块的主题色保持一致」)。空 = 日程模块色。 * ⚠️ 与 `bookColor` 分工不同:环是"我在哪个模块里", * 标题左侧那个点才是"这条日程属于哪本日历"(一本模块下可以有多本)。 */ @Param accent: string = ''; /** 能不能编辑(不能写的日历本不给"编辑"按钮) */ @Param canEdit: boolean = false; /** * 点「导航」。 * * 🔴 v73(用户 2026-10-21「有地址的可以一键导航」): * 地点那一行从"纯文字"变成"可点"。 * ⚠️ 为什么把动作**抛给宿主**而不是在这里直接拉地图: * 拉起应用要 `UIAbilityContext`,而组件里 `getUIContext().getHostContext()` * 拿到的是 `common.Context`,还得强转 —— 那个强转在**卡片/浮层重建**时可能拿到 * undefined。宿主页本来就有可靠的上下文来源,且"要不要拉地图"属于导航决策 * (跟"当前在哪个模块"有关),不是这张卡的职责。 */ @Event onNavigate: () => void = (): void => { }; @Event onClose: () => void = (): void => { }; @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; } /** * 取主题色的**半透明版本**(八位色 `#AARRGGBB`)。 * * 🔴 本项目踩过:ArkUI 的八位色是 **alpha 在前**(`#AARRGGBB`), * 与 CSS 的 `#RRGGBBAA` **相反** ⇒ 直接把 CSS 那套搬过来会得到 * "几乎透明的怪色"或者干脆渲染成黑色。 * ⚠️ 还要兼容"传进来的本身就是八位色"(模块色可能带 alpha): * 先剥掉已有的 alpha,再拼新的,否则会得到 10 位非法色值。 */ private accentAlpha(aa: string): string { const c: string = this.accentColor().replace('#', ''); const rgb: string = c.length >= 8 ? c.substring(2) : c; return '#' + aa + rgb; } /** * 标题文字。 * * 🔴 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; if (e === null) { return ''; } const d: string = this.cnDate(e.starts_at); if (e.all_day) { return '全天 ' + d; } const t1: string = this.hhmm(e.starts_at); if (e.ends_at === '') { return d + ' ' + t1; } // 同一天就只写一次日期 const sameDay: boolean = e.ends_at.length >= 10 && e.starts_at.length >= 10 && e.ends_at.substring(0, 10) === e.starts_at.substring(0, 10); const t2: string = this.hhmm(e.ends_at); return sameDay ? (d + ' ' + t1 + ' - ' + t2) : (d + ' ' + t1 + ' → ' + this.cnDate(e.ends_at) + ' ' + t2); } /** `YYYY-MM-DD HH:MM:SS` → `10月15日 周三` */ private cnDate(dt: string): string { if (dt.length < 10) { return ''; } const y: number = parseInt(dt.substring(0, 4), 10); const m: number = parseInt(dt.substring(5, 7), 10); const d: number = parseInt(dt.substring(8, 10), 10); const w: string[] = ['日', '一', '二', '三', '四', '五', '六']; const day: number = new Date(y, m - 1, d).getDay(); return m.toString() + '月' + d.toString() + '日 周' + w[day]; } /** `YYYY-MM-DD HH:MM:SS` → `HH:mm` */ private hhmm(dt: string): string { return dt.length >= 16 ? dt.substring(11, 16) : ''; } /** * 重复规则 → 中文。 * * ⚠️ 只翻译**端上能生成的那几种**(见 `FbEventEditSheet.REPEAT_OPTS`); * 认不出的(网页端配的"每两周"等)原样显示 RRULE 串 —— * "看不懂"也好过"显示成不重复"(那是错的)。 */ private repeatText(): string { const e: FbCalEventFull | null = this.event; if (e === null || e.rrule === '') { return ''; } const r: string = e.rrule; if (r.indexOf('FREQ=DAILY') === 0) { return '每天'; } if (r.indexOf('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR') === 0) { return '每个工作日'; } if (r.indexOf('FREQ=WEEKLY') === 0) { return '每周'; } if (r.indexOf('FREQ=MONTHLY') === 0) { return '每月'; } if (r.indexOf('FREQ=YEARLY') === 0) { return '每年'; } return r; } /** 提醒 → 中文(多个用顿号连) */ private remindText(): string { const e: FbCalEventFull | null = this.event; if (e === null || e.reminder_minutes === '') { return ''; } const out: string[] = []; for (const x of e.reminder_minutes.split(',')) { const n: number = parseInt(x.trim(), 10); if (isNaN(n)) { continue; } if (n === 0) { continue; } if (n < 60) { out.push('提前 ' + n.toString() + ' 分钟'); } else if (n === 60) { out.push('提前 1 小时'); } else if (n === 1440) { out.push('提前 1 天'); } else if (n % 1440 === 0) { out.push('提前 ' + (n / 1440).toString() + ' 天'); } else if (n % 60 === 0) { out.push('提前 ' + (n / 60).toString() + ' 小时'); } else { out.push('提前 ' + n.toString() + ' 分钟'); } } return out.join('、'); } @Builder row(label: string, value: string) { Row({ space: 10 }) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(56) Text(value) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .layoutWeight(1) } .width('100%') .padding({ top: 8, bottom: 8 }) .alignItems(VerticalAlign.Top) } /** * 「地点」这一行 —— 比普通 `row()` 多一颗「导航」标签,整行可点。 * * ⚠️ 为什么单独抽一个 Builder 而不是给 `row()` 加参数: * `row()` 有 2 个参数,加个 `tappable: boolean` 就变成 3 个 —— * 3 个参数的 @Builder 更危险(多值参数不参与状态观察,本项目已踩 6+ 次)。 * 这里的数据是**每次打开浮层才建一次**(详情卡整体由 `if (detailOpen())` * 重建),不存在"数据变了行不刷新"的场景,所以用 Builder 是安全的; * 但仍保持**单参数**,把风险面压到最小。 */ @Builder navRow(place: string) { Row({ space: 10 }) { Text('地点') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(56) Text(place) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .layoutWeight(1) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) /* 标签用模块色(与「编辑日程」按钮同色系),一眼看出"这是这颗票能做的事"。 ⚠️ 底色用**极浅的主题色**(`fbAlpha` 那套思路:八位色 `#AARRGGBB`, alpha 在前)—— 深底白字会把这一行压得比标题还重。 */ if (fbCanNavigate(place)) { Row({ space: 3 }) { Text('➤') .fontSize(10) .fontColor(this.accentColor()) Text('导航') .fontSize(FbMetric.fontTiny) .fontColor(this.accentColor()) } .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .borderRadius(FbMetric.radiusPill) .backgroundColor(this.accentAlpha('1A')) .border({ width: 1, color: this.accentAlpha('33') }) } } .width('100%') .padding({ top: 8, bottom: 8 }) .alignItems(VerticalAlign.Center) .onClick((): void => { this.onNavigate(); }) } build() { // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— // 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 // ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 Column() { // 🔴 2026-10-19:卡片外面的主题色环统一由 `FbSheetRing` 给 // (用户:「所有弹框都加线,保持一致」)。环色用**这本日历的颜色**, // 一眼能看出这条日程属于哪个日历本。 Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { // ---- 标题区 ---- Row({ space: 10 }) { FbDot({ dia: 12, fillC: this.bookColor }) /* 🔴 2026-10-19:标题为空时要**说清这是什么**,而不是写「(无标题)」。 农历 / 节气这类衍生条目的标题服务端是给了的(如「九月初一」), 真为空的情况主要是「没填标题的自建日程」—— 那种回「未命名日程」 比「(无标题)」更像人话。 */ Text(this.titleText()) .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(2) .layoutWeight(1) if (this.event !== null && this.event.all_day) { Text('全天') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusPill) } /* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。 ⚠️ 放在标题行的最右端,而不是下面独立一行 —— 独立一行会让卡片 顶部多出一段空白,标题反而往下掉。 */ FbSheetClose({ onClose: (): void => { this.onClose(); }, color: '' }) } .width('100%') .alignItems(VerticalAlign.Top) Divider() .color(FbColors.line) .margin({ top: 12, bottom: 4 }) if (this.loading) { Row({ space: 8 }) { LoadingProgress().width(18).height(18).color(FbColors.faint) Text('正在读取…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } .width('100%') .padding({ top: 20, bottom: 20 }) .justifyContent(FlexAlign.Center) } else if (this.event === null) { // 正常情况下不会走到这里(`openEvent` 一定会垫一份壳); // 只有"打开浮层时状态被清掉"这种瞬时态才可能空着 —— // 所以这里说的是"正在读取",不是"读不到"。 Text('正在读取…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) .width('100%') .padding({ top: 20, bottom: 20 }) } else { Scroll() { Column({ space: 0 }) { this.row('时间', this.timeText()) if (this.bookName !== '') { this.row('日历本', this.bookName) } // ⚠️ build() 里**不能声明变量**(只有 UI 组件语法)⇒ // 条件直接调方法(方法返回空串就不显示) if (this.repeatText() !== '') { this.row('重复', this.repeatText()) } if (this.remindText() !== '') { this.row('提醒', this.remindText()) } if (this.event.location !== '') { /* 🔴 v73(用户 2026-10-21「有地址的还可以直接一键导航」): 地点不再是纯文字 —— **整行可点**,右侧带一颗「导航」小标签。 ⚠️ 为什么整行可点而不是只让那颗小标签可点: 小标签只有 40vp 宽,手指点不中会以为"没反应"; 整行(含地点文字)都是热区,命中率高得多。 ⚠️ 但**视觉上**仍要有那颗「导航」标签 —— 只有"整行可点" 而没有任何标记的话,用户根本不知道地点能点 (本项目踩过:卡尾"查看行程"是纯文本,用户以为按钮坏了)。 */ this.navRow(this.event.location) } 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%') .alignItems(HorizontalAlign.Start) } /* 🔴 2026-10-19:原来是 `.layoutWeight(1)` —— 它让 Scroll **吃满剩余高度**, 于是短内容的日程也会把卡片顶到 `maxHeight`(72%), 按钮被推到最底、中间留一大片空白(真机截图:标题下面空半屏)。 ⇒ 改成 `flexShrink(1)`:卡片按内容自适应高, 只有当内容真的超过 72% 时这一块才收缩并滚动。 ⚠️ 不能只删掉 `layoutWeight`:`Column` 的子项默认 `flexShrink = 0`, 不收缩 ⇒ 长内容会**溢出卡片**被裁掉(反方向踩坑)。 */ .flexShrink(1) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) } // ---- 底部按钮(只有动作,关闭统一到右上角 ✕)---- /* 🔴 v73(用户 2026-10-21「有地址的还可以直接一键导航」): 有地点时底部多一颗「导航」—— 与上面那行可点的地点**并存**, 是有意的:地点行是"看的时候顺手点",底部按钮是"看完了决定要去"。 两个入口做同一件事,比只留一个更好用(日期详情卡不长, 地点行可能已经滚出视野)。 ⚠️ 判据用 `event.location`,不用别的字段 —— 那是唯一权威的地点来源。 ⚠️ 两个按钮用 `layoutWeight(1)` 均分宽度;只有一个时它自己占满。 */ if (this.event !== null && (fbCanNavigate(this.event.location) || this.canEdit)) { Row({ space: 10 }) { if (fbCanNavigate(this.event.location)) { Text('导航') .fontSize(FbMetric.fontSmall) .fontColor(this.accentColor()) .textAlign(TextAlign.Center) .layoutWeight(1) .height(42) .backgroundColor(this.accentAlpha('14')) .borderRadius(FbMetric.radiusS) .border({ width: 1, color: this.accentAlpha('33') }) .onClick((): void => { this.onNavigate(); }) } if (this.canEdit) { /* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」 ⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。 改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */ Text('编辑日程') .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .textAlign(TextAlign.Center) .layoutWeight(1) .height(42) .backgroundColor(this.accentColor()) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.onEdit(); }) } } .width('100%') .margin({ top: 14 }) } } // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 .width('100%') .constraintSize({ maxHeight: '72%' }) .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) .alignItems(HorizontalAlign.Start) } } .width('92%') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .hitTestBehavior(HitTestMode.Transparent) } }