Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbEventSheet.ets
T

354 lines
15 KiB
Plaintext
Raw Normal View History

/**
* 家书 FamilyBook · 日程详情浮层(鸿蒙端)
*
* 用户 2026-10-15:「……包括下面日程的显示啊什么之类的,就按照 SyncCalendar 那个模式去做。」
* SyncCalendar 的详情是**半屏 bindSheet**,内容为:
* 标题(带日历本色点 + 系统/只读徽标 + 关闭)→ 时间 → 日历本/重复 →
* 地点(可点导航)→ 备注 → 「编辑日程」。
*
* 🔴 这里用**页面内 Stack 覆盖层**而不是 `bindSheet`(与家书其他浮层一致,
* 理由见 `FbMovieDetailSheet` 的文件头);
* **功能与信息结构**照 SyncCalendar 对齐。
*
* ⚠️ 详情数据是**异步拉的**(`/events/<id>` 才给 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';
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;
@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;
}
/**
* 标题文字。
*
* 🔴 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)
}
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)
}
}
.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 !== '') {
this.row('地点', 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)
}
// ---- 底部按钮 ----
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.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(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)
}
}