v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据

从「演示数据壳子」走到「真的能连家里的服务器用」。

登录(用户 2026-10-17「完整的登录功能」)
  · 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
  · 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
  · FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
    设备名取机型(marketName),并上报 platform / app_version

设备会话(v57,用户 2026-10-17)
  · 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
  · 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
    (离线超 24h → 只锁界面、不清数据、联网自动恢复)——
    混成一种就会违背用户要的"一直联网就不用每天输密码"
  · 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
    让登录/校验类请求跳过,避免"两个地方同时跳登录页")
  · 退出登录:先清本地令牌(主防线),再尽力通知服务端
  · 设置页显示版本号与「这台设备」

数据层
  · FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
  · 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
  · 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致

验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
This commit is contained in:
2026-10-18 03:36:04 +08:00
parent 7eaa641437
commit 776c95ba0b
64 changed files with 18297 additions and 1519 deletions
+72 -45
View File
@@ -7,8 +7,16 @@
*
* 三条形态,同一个组件:
* · **常驻侧栏**(宽屏:窗口短边 ≥ 600vp)—— 固定在左边,占 264vp;
* · **左滑出层**(手机,从左边缘向右滑)—— 覆盖内容,点遮罩或选中项即收起;
* · **右滑出层**(手机,从右边缘向左滑)—— 同上,只是贴右边(右手单手持机顺手)。
* · **左滑出层**(手机)—— 覆盖内容,点遮罩或选中项即收起;
* · **右滑出层**(手机)—— 同上,只是贴右边(右手单手持机顺手)。
*
* 🔴 抽屉**从哪边出**由「**握姿**」决定(用户 2026-10-16 第 4 条):
* 左手握 → `app.drawerSide = 'left'`;右手握 → `'right'`。
* 逻辑在 `common/FbGrip.ets`(用官方 `multimodalAwareness.motion` 的
* `HoldingHandStatus`),不在本组件里。
* 🔴 **打开方式只有一种:点顶栏左侧的家书图标**(`pages/Index.ets`)。
* ⚠️ **横向滑动开抽屉的旧手势已删**(用户要求)—— 它与页面里的横滑、
* 与"点人名拨号"在 `parallelGesture` 下**并行触发**,冲突无法根治。
*
* ⚠️ 这里**不用 SideBarContainer**:它的控制按钮与「滑出」是内建的一套,
* 样式(图标、位置、宽度动画)很难与家书的视觉统一,且它只能有一层侧栏
@@ -21,6 +29,16 @@ import { FbAppState, fbAppState } from '../common/Store';
import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules';
import { FbAvatar } from './Widgets';
/**
* 底部手势条区域的留白(vp)。
*
* 🔴 应用已开 `setWindowLayoutFullScreen(true)`(见 EntryAbility):
* 内容会铺到屏幕最下沿,而**系统手势条已隐藏**(用户要求"下面没有任何的条")
* ⇒ 手势区**没有任何视觉提示**,贴着底边的元素很容易被"从下往上滑"误触。
* 这里留一点缓冲,是给"手势区"而不是给"某个控件"。
*/
const FB_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbDrawer {
/** 全局状态(父组件传入,保证与页面同一份实例) */
@@ -60,7 +78,7 @@ export struct FbDrawer {
}
.width('100%')
// 顶部内边距 = 状态栏高度 + 一点呼吸,保证不压状态栏
.padding({ left: 18, right: 12, top: 16, bottom: 10 })
.padding({ left: 18, right: 12, top: 16 + this.app.statusBarH, bottom: 10 })
}
/** 一个模块行 */
@@ -112,29 +130,52 @@ export struct FbDrawer {
.color(FbColors.line2)
.margin({ bottom: 6 })
this.footerRow('fb_ic_bell', '通知消息', this.app.unread > 0 ? this.app.unread.toString() : '')
this.footerRow('fb_ic_user', '个人设置', '')
this.footerRow('fb_ic_lock', '修改密码', '')
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', '系统设置', '')
this.footerRow('fb_ic_gear', '系统设置', '', '')
}
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 10 })
// 🔴 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) {
footerRow(iconName: string, label: string, badge: string, route: string) {
Row({ space: 12 }) {
Image(FbIcon.ui(iconName))
.width(17)
.height(17)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.muted)
// 未实现的项图标也变灰,明示"暂不可用"
.fillColor(route === '' ? FbColors.faint : FbColors.muted)
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.fontColor(route === '' ? FbColors.faint : FbColors.ink)
.layoutWeight(1)
if (badge.length > 0) {
Text(badge)
@@ -148,6 +189,11 @@ export struct FbDrawer {
.width('100%')
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
.borderRadius(FbMetric.radiusS)
.onClick(() => {
if (route !== '') {
this.app.switchModule(route); // switchModule 内部会收起抽屉
}
})
}
/** 当前是否选中该模块 */
@@ -263,41 +309,22 @@ export struct FbDrawerShell {
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
// 🔴 横向滑动开抽屉(用户 2026-10-12 定的规则)。
// 🔴🔴 **横向滑动开抽屉的手势已删除**(用户 2026-10-16 第 3、4 条)。
//
// ⚠️ **不要判断"起点是否贴屏幕边缘"** —— 用户实测反馈:屏幕边缘是
// **系统手势区**(返回/侧边栏),从边缘往里滑的事件根本轮不到应用处理,
// 我们无法控制。所以判据只能是"**在应用内部水平滑动**":
// 向右滑 → 抽屉从左边出;向左滑 → 抽屉从右边出。
// 这样左手持机时往右划、右手持机时往左划,单手都顺。
// 用户原话:「这个列表上左滑、右滑和点击后打电话的事件冲突了……
// 如果实在不行,就把左滑、右滑弹出抽屉这个功能去掉吧,
// 只保留点击标题或图标后弹出抽屉的功能。」
// 并再次强调:「**先这样做,把滑动出抽屉这个功能先去掉**,
// 因为它现在在很多地方都跟别的功能有冲突。」
//
// ⚠️ `offsetX` 的单位是 **vp**(SDK 原文 "in vp"),不要再除以 density ——
// 除一次会把位移缩到 1/3,导致阈值永远达不到、手势"看起来没生效"。
// 历史:`parallelGesture(PanGesture)` + `fbHScrollBusy()` 的那套"让位"
// 机制(见 git 历史)**治不了根本** —— 因为 `parallelGesture` 是**并行**识别,
// 它和应用自己的横滑/点按/拨号**同时**成立,没有"谁先抢到"的机制。
// 名册里"滑动既弹抽屉又跳拨号"就是这个并行识别的必然结果。
// ⇒ 唯一可靠的办法是**不用横滑手势**。
//
// ⚠️ 用 `parallelGesture` 而不是 `gesture`:内容区自己还有竖向滚动
// (以及纪念日/财务/任务的横向滚动条),用 gesture 会把它们抢掉。
// 代价是横向滚动条扫得猛时也可能触发 —— 所以阈值给到 80vp,
// 轻扫(浏览横向列表)不会误开。
.parallelGesture(
PanGesture({ direction: PanDirection.Horizontal, distance: 30 })
.onActionEnd((e: GestureEvent) => {
// 宽屏下抽屉常驻,不需要手势
if (this.app.wideLayout) {
return;
}
const dx: number = e.offsetX; // vp
// 向右滑 ⇒ 抽屉在左
if (dx > 80) {
this.app.drawerSide = 'left';
this.app.drawerOpen = true;
return;
}
// 向左滑 ⇒ 抽屉在右
if (dx < -80) {
this.app.drawerSide = 'right';
this.app.drawerOpen = true;
}
})
)
// 抽屉入口保留在:**顶栏左侧的家书图标**(`pages/Index.ets` 的 `topBarSlot`)。
// 抽屉从哪边出,改由「**握姿**」决定(见 `FbGripWatcher` / `app.drawerSide`):
// 左手握 → 从左边出;右手握 → 从右边出。
}
}
@@ -0,0 +1,72 @@
/**
* 家书 FamilyBook · 设置里的「单选行」(鸿蒙端)
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug):
* 「设置里面这个"**默认视图**",**都没法点**,也没有办法设置。」
* 根因:原来是 `FbCalSettingsSheet.radioRow(label, hint, on: boolean, onTap)` ——
* **`@Builder` 的值类型参数(boolean)不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」)。
* ⇒ 点一下 `this.view` 确实变了、`FB_CAL_SETTINGS.setView()` 也确实写了,
* 但**打勾 ✓ 与高亮底色的那份子树不重建** ⇒ 看着"点了没反应"。
* 本项目已在 `FbTagChip` 上踩过同一个坑(见那份文件头),这里照同样办法修。
*
* ⚠️ 顺带说明:ForEach 的 key **不用**带选中态 —— 只要这一片是
* `@ComponentV2` + `@Param`,`active` 变了它自己就会重建。
* (`FbTagChip` 的 key 也只是标签名。)
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbDot } from './Widgets';
@ComponentV2
export struct FbCalOptionRow {
/** 主文字 */
@Param label: string = '';
/** 副说明(小字;空则不占位) */
@Param hint: string = '';
/** 是否选中(**必须由父组件算好传进来**) */
@Param active: boolean = false;
/** 左侧色点(日历本那几行用它;空则不画) */
@Param dotColor: string = '';
/** 被点击 */
@Event onPick: () => void = (): void => {
};
build() {
Row({ space: 10 }) {
if (this.dotColor !== '') {
FbDot({ dia: 10, fillC: this.dotColor })
}
Column({ space: 2 }) {
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.active ? '#4A7C82' : FbColors.ink)
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.maxLines(2)
if (this.hint !== '') {
Text(this.hint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(this.active ? '✓' : '')
.fontSize(FbMetric.fontSmall)
.fontColor('#4A7C82')
.width(20)
.textAlign(TextAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
.backgroundColor(this.active ? '#EAF2F2' : FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onPick();
})
}
}
@@ -0,0 +1,253 @@
/**
* 家书 FamilyBook · 日程设置浮层(鸿蒙端)
*
* 用户 2026-10-15:「在新建的那个地方,**右侧那加一个设置按钮**,
* 点进去之后实际上就是**对这个日程的设置**。」
* 用户 2026-10-16:「设置里面这个"**默认视图**",都没法点,也没有办法设置。」
*
* 对齐 SyncCalendar 设置页里**与日程显示相关**的那几项
* (它那一页还有一堆 DAV 账号/镜像/系统的设置,那些家书用不上,不抄):
* · **默认视图**:月 / 周 / 列表
* · **日程显示方式**:时间轴 / 列表
* · **新建日程落到哪本日历**(默认日历本)
* · **哪些日历本可见**(逐本开关)
*
* 🔴🔴 v53 修的真 bug:「默认视图点不动」
* 根因:单选行原来是 `@Builder radioRow(type, hint, on: boolean, onTap)` ——
* **`@Builder` 的值类型参数不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`)⇒ 点一下 `this.view` 变了、
* 服务端也写了,但打勾/高亮那份子树**不重建** ⇒ 看着"点了没反应"。
* ⇒ 改成 `FbCalOptionRow`(`@ComponentV2` + `@Param`)。
*
* ⚠️ 这些设置**只存在本机**(`FbCalSettings` / preferences),
* 不上传服务端 —— 它是"这台设备上的人怎么看日历",不是共享数据。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalBook } from '../model/Types';
import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings';
import { FbCalOptionRow } from './FbCalOptionRow';
import { FbDot } from './Widgets';
const FB_SHEET_BOTTOM_GAP: number = 16;
/** 视图选项 */
interface FbViewOpt {
key: FbCalView;
label: string;
hint: string;
}
const VIEW_OPTS: FbViewOpt[] = [
{ key: 'month', label: '月视图', hint: '看整月,点某天看当天日程' },
{ key: 'week', label: '周视图', hint: '看这一周,一周里的安排一目了然' },
{ key: 'agenda', label: '列表视图', hint: '从今天往后排,按天分组' }
];
/** 显示方式选项 */
interface FbStyleOpt {
key: FbCalStyle;
label: string;
hint: string;
}
const STYLE_OPTS: FbStyleOpt[] = [
{ key: 'timeline', label: '时间轴', hint: '按小时排布,一眼看出时间分布' },
{ key: 'list', label: '列表', hint: '一条一行,看得更清楚' }
];
@ComponentV2
export struct FbCalSettingsSheet {
/** 日历本(可见性开关要列出来) */
@Param books: FbCalBook[] = [];
/** 关掉 */
@Event onClose: () => void = (): void => {
};
/**
* 设置变了(父页面据此重新渲染 —— 比如切换视图后马上生效)。
*/
@Event onChanged: () => void = (): void => {
};
@Local view: FbCalView = 'month';
@Local style: FbCalStyle = 'timeline';
@Local bookId: number = 0;
aboutToAppear(): void {
this.view = FB_CAL_SETTINGS.getView();
this.style = FB_CAL_SETTINGS.getStyle();
this.bookId = FB_CAL_SETTINGS.getBookId();
}
/** 某一本当前是否可见 */
private visibleOf(id: number): boolean {
return FB_CAL_SETTINGS.isVisible(id);
}
/** 视图 key → 中文(给"当前:xx"那行用) */
private viewWord(v: FbCalView): string {
if (v === 'week') { return '周视图'; }
if (v === 'agenda') { return '列表视图'; }
return '月视图';
}
private styleWord(s: FbCalStyle): string {
return s === 'list' ? '列表' : '时间轴';
}
@Builder
sectionTitle(t: string) {
Text(t)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 4 })
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row({ space: 12 }) {
Column({ space: 2 }) {
Text('日程设置')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
// 🔴 把"当前是什么"直接写在标题下面 ——
// 用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾)
Text('当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style))
.fontSize(FbMetric.fontTiny)
.fontColor('#4A7C82')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('完成')
.fontSize(FbMetric.fontSmall)
.fontColor('#4A7C82')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 6 }) {
// ---- 默认视图 ----
this.sectionTitle('打开的默认视图')
ForEach(VIEW_OPTS, (o: FbViewOpt) => {
// 🔴 用**组件**而不是 @Builder —— @Builder 的值类型参数不参与观察,
// 点了不会重建(这正是"默认视图点不动"的根因)
FbCalOptionRow({
label: o.label,
hint: o.hint,
active: this.view === o.key,
onPick: (): void => {
this.view = o.key;
FB_CAL_SETTINGS.setView(o.key);
this.onChanged();
}
})
}, (o: FbViewOpt): string => 'vo_' + o.key)
// ---- 显示方式 ----
this.sectionTitle('日程怎么显示')
ForEach(STYLE_OPTS, (o: FbStyleOpt) => {
FbCalOptionRow({
label: o.label,
hint: o.hint,
active: this.style === o.key,
onPick: (): void => {
this.style = o.key;
FB_CAL_SETTINGS.setStyle(o.key);
this.onChanged();
}
})
}, (o: FbStyleOpt): string => 'so_' + o.key)
// ---- 默认日历本 ----
if (this.books.length > 0) {
this.sectionTitle('新建日程默认加入')
ForEach(this.books, (b: FbCalBook) => {
if (b.canWrite) {
FbCalOptionRow({
label: b.name,
hint: '',
active: this.bookId === b.id,
dotColor: b.color,
onPick: (): void => {
this.bookId = b.id;
FB_CAL_SETTINGS.setBookId(b.id);
this.onChanged();
}
})
}
}, (b: FbCalBook): string => 'db_' + b.id.toString())
// ---- 哪些日历本可见 ----
this.sectionTitle('显示哪些日历本')
ForEach(this.books, (b: FbCalBook) => {
// ⚠️ 这里**不用** `FbCalOptionRow`(那个是"单选、带打勾"的行);
// 可见性是**独立开关**,一行配一个 Toggle 更直观。
Row({ space: 10 }) {
FbDot({ dia: 10, fillC: b.color })
Text(b.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Toggle({ type: ToggleType.Switch, isOn: this.visibleOf(b.id) })
.selectedColor(b.color)
.onChange((on: boolean): void => {
FB_CAL_SETTINGS.setVisible(b.id, on);
this.onChanged();
})
}
.width('100%')
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (b: FbCalBook): string => 'vb_' + b.id.toString())
}
Text('这些设置只存在这台手机上,不会同步给别人。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding({ top: 14 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,158 @@
/**
* 家书 FamilyBook · 影院列表浮层(鸿蒙端)
*
* 观影模块的"影院管理"入口(Web 端在观影页的设置里)。
*
* 🔴 为什么影院**不占抽屉里的位置**:
* 它不是独立模块,是"观影"的一部分 —— 一家影院只有"在哪、
* 叫什么、在那看过几部"这三个属性,独立成一页太空。
* ⇒ 做成观影页里的浮层(与详情、设置同一套交互)。
*
* ⚠️ 数据来自**本地库**(观影同步时一起落库的 `cinemas` 表),
* 不是现拉接口 —— 见 `FbMovieRepo.cinemaList()` 的说明。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCinemaItem } from '../data/FbMovieRepo';
/**
* 贴底卡片的手势区缓冲(vp)。
*
* 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)——
* 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段,
* 否则最后一排按钮会被"从下往上滑"误触。
*/
const FB_SHEET_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbCinemaSheet {
/** 影院列表 */
@Param cinemas: FbCinemaItem[] = [];
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
Column({ space: 0 }) {
// ---- 标题 ----
Row() {
Column({ space: 2 }) {
Text('影院')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.cinemas.length.toString() + ' 家 · 按名字排序')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Divider()
.color(FbColors.line)
.margin({ top: 12, bottom: 4 })
// ---- 列表 ----
// ⚠️ 用 List 而不是 ForEach 堆在 Column 里:影院可能几十家,
// List 才会**按需渲染**(进视口才建),Column 会一次全建。
if (this.cinemas.length === 0) {
Text('还没有影院。同步观影记录后,去过的地方会自动出现在这里。')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 32, bottom: 32, left: 12, right: 12 })
.textAlign(TextAlign.Center)
} else {
List({ space: 0 }) {
ForEach(this.cinemas, (c: FbCinemaItem, idx: number) => {
ListItem() {
Column({ space: 0 }) {
Row({ space: 12 }) {
// 色点:每家影院一个色(与 Web 端一致,便于在别处一眼认出来)
Column()
.width(10)
.height(10)
.backgroundColor(c.color)
.borderRadius(5)
Column({ space: 3 }) {
Row({ space: 8 }) {
Text(c.name)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.maxLines(1)
if (c.movieCount > 0) {
Text(c.movieCount.toString() + ' 部')
.fontSize(10)
.fontColor(FbColors.faint)
}
}
.alignItems(VerticalAlign.Center)
// 场所 / 地址(有才显示)
if (c.place !== '' || c.address !== '') {
Text(c.place !== '' ? c.place : c.address)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 4, right: 4, top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (idx < this.cinemas.length - 1) {
Divider()
.strokeWidth(0.5)
.color(FbColors.line)
}
}
.width('100%')
}
}, (c: FbCinemaItem): string => 'cin_' + c.id.toString())
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,182 @@
/**
* 家书 FamilyBook · 「某一天的日程」面板(鸿蒙端)
*
* 日程页里"日历下方 / 周条下方 / 列表里某天"共用的那一块。
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug):
* 「你在日历上点击任何一个地方,**下面的日程都不变**。」
* 根因:原来是 `@Builder dayPanel(iso: string)` ——
* **`@Builder` 的值类型参数不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」;
* 本项目已在 `FbTagChip` 上踩过同一个坑,见那份的文件头)。
* ⇒ 点格子后 `pickedIso` 确实变了、`eventsOf()` 也能算对,
* 但**已经建好的那份 Builder 子树不会重建** ⇒ 看起来"下面不变"。
* ⇒ 改成 `@ComponentV2` + `@Param`:`@Param` **是参与观察的**,
* 父组件传进来的日期/条目一变,这一整块就重建。
*
* ⚠️ 父组件必须把**算好的条目**传进来(`events`),
* 而不是传日期让本组件自己去查 —— 组件拿不到父组件的筛选状态。
* (传 `events` 还有个好处:每次都是新数组引用 ⇒ 一定触发重建。)
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEvent } from '../model/Types';
import { FbCalStyle } from '../data/FbCalSettings';
import { FbTimelineUtil } from '../common/FbTimeline';
import { FbDayTimeline } from './FbDayTimeline';
import { FbEmptyBox } from './Widgets';
@ComponentV2
export struct FbDayEventPanel {
/** 面板标题(`10月15日 周三`) */
@Param label: string = '';
/** 这一天的条目(**父组件已筛选排序好的**) */
@Param events: FbCalEvent[] = [];
/** 显示方式:时间轴 / 列表(来自日程设置) */
@Param style: FbCalStyle = 'timeline';
/** 是不是今天(时间轴画红线用) */
@Param isToday: boolean = false;
/** 当前时刻(当天第几分钟) */
@Param nowMin: number = 0;
/**
* 面板头是否显示。
*
* 🔴 v54:首页复用了本组件(用户 2026-10-16:「把**今天那个块**拿到首页上来」),
* 而首页自己有一个 `FbSectionTitle`("今天的日程 · N 条")——
* 两个头叠在一起会重复。⇒ 首页传 `false` 把内置头关掉。
* ⚠️ 日程页保持 `true`(那里没有别的标题)。
*/
@Param showHead: boolean = true;
/**
* 空态的提示语。空串 = 用默认那句(日程页:点右下角 + 加一条)。
*
* ⚠️ 首页**不能**说"点右下角的 +" —— 那是日程页的悬浮按钮,
* 首页没有它,说了等于骗人。
*/
@Param emptyHint: string = '';
/**
* 空态的标题。空串 = `这天没有安排`。
* ⚠️ 日程页的 `label` 是具体日期(`10月15日 周三`),说"这天"是对的;
* 首页说的是"今天",所以那边传 `今天没有安排`。
*/
@Param emptyTitle: string = '';
/** 点一条日程 */
@Event onPick: (e: FbCalEvent) => void = (e: FbCalEvent): void => {
};
build() {
Column({ space: 0 }) {
// ---- 面板头(首页会关掉它,见 showHead 的说明)----
if (this.showHead) {
Row() {
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.events.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
}
// ---- 主体 ----
if (this.events.length === 0) {
Column() {
FbEmptyBox({
title: this.emptyTitle !== '' ? this.emptyTitle : '这天没有安排',
hint: this.emptyHint !== '' ? this.emptyHint : '点右下角的 + 加一条日程',
icon: $r('app.media.fb_ic_calendar')
})
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
} else if (this.style === 'timeline') {
// ---- 时间轴:按真实时间铺在 0-24 小时格子上 ----
FbDayTimeline({
timeline: FbTimelineUtil.build(this.events, this.isToday, this.nowMin),
isToday: this.isToday,
nowMin: this.nowMin,
onPick: (e: FbCalEvent): void => {
this.onPick(e);
}
})
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 8 })
} else {
// ---- 列表:一条一行 ----
Column({ space: 0 }) {
ForEach(this.events, (ev: FbCalEvent, idx: number) => {
this.eventRow(ev, idx === this.events.length - 1)
}, (ev: FbCalEvent, idx: number): string =>
'ev_' + this.label + '_' + idx.toString() + '_' + ev.id.toString())
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
}
}
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 12 })
.alignItems(HorizontalAlign.Start)
}
/** 列表模式的一条(左侧色条 + 标题 + 时间 + 地点 + 日历本名) */
@Builder
eventRow(ev: FbCalEvent, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
Row()
.width(4)
.height(38)
.backgroundColor(ev.color)
.borderRadius(2)
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 })
}
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
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 })
}
}
.width('100%')
}
}
@@ -0,0 +1,476 @@
/**
* 家书 FamilyBook · 日程「时间轴」视图(鸿蒙端)
*
* 🔴 用户 2026-10-16:「**没有时间轴展示**……你这不是说让你把 SyncCalendar
* 里面的功能搬过来吗?」
* ⇒ 照 SyncCalendar 的 `DayTimelineView`(在 `Index.ets` 里)移植:
* 左侧小时刻度 + 右侧**三层堆叠**渲染。
*
* 为什么是"三层堆叠"(照 SyncCalendar 的设计,别改):
* · 第 1 层 **整点网格**:每小时一格、格底一条灰线;
* · 第 2 层 **日程色块**:按冲突组分列、纵向用 `Blank` 定位;
* · 第 3 层 **当前时间红线**:浮在最上面。
* ⚠️ 三层尺寸与分格完全一致、上层透明 ⇒
* **色块不会被整点线或红线"切开"**,红线也永远压在最上面。
* 如果图省事把线直接画进日程层,一条日程就会被切成两段。
*
* 🔴 为什么是"组件"而不是 `@Builder`:
* `@Builder` 的**值类型参数不参与状态观察**(见 `FbTagChip.ets` 的说明、
* SDK 规则 `r/quick-rules/05-state.md`)—— 本项目的日程页正是因此
* 出现"点日历下面的日程不变"。这里用 `@ComponentV2` + `@Param`,
* `timeline` 一变整棵子树就重建。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEvent } from '../model/Types';
import {
FbTimelineData, FbTimeBlock, FbTimeGroup, FbTimeRow, FbTimelineUtil, FbViewRange
} from '../common/FbTimeline';
/** 「当前时间红线」的颜色(照 SyncCalendar:系统红) */
const FB_NOW_RED: string = '#FF3B30';
@ComponentV2
export struct FbDayTimeline {
/** 一天的布局(**必须由父组件按选中日期算好传进来**) */
@Param timeline: FbTimelineData = new FbTimelineData();
/** 是不是"今天"(决定要不要画红线) */
@Param isToday: boolean = false;
/** 当前时刻(当天第几分钟) */
@Param nowMin: number = 0;
/** 点一条日程 */
@Event onPick: (e: FbCalEvent) => void = (e: FbCalEvent): void => {
};
/** 1 小时的高度(vp) */
private hourUnit: number = FbTimelineUtil.HOUR_UNIT;
/** 极短色块的可读高度下限(一行"标题 + 时间") */
private minBlockVp: number = 16;
/** 能放下两行的高度门槛 */
private twoLineVp: number = 30;
/** 能放下三行的高度门槛 */
private threeLineVp: number = 46;
// ---- 视窗 ----
private range(): FbViewRange {
return FbTimelineUtil.viewRange(this.timeline.blocks);
}
private viewTop(): number {
return this.range().startHour / 24;
}
private viewBot(): number {
return this.range().endHour / 24;
}
/** 视窗总高(vp) */
private viewH(): number {
const r: FbViewRange = this.range();
return (r.endHour - r.startHour) * this.hourUnit;
}
/** 整日高度(vp) */
private totalH(): number {
return this.hourUnit * 24;
}
/** 视窗内的小时刻度 */
private hourLabels(): number[] {
const r: FbViewRange = this.range();
const out: number[] = [];
for (let h = r.startHour; h < r.endHour; h++) {
out.push(h);
}
return out;
}
private hourText(h: number): string {
return h < 10 ? ('0' + h.toString() + ':00') : (h.toString() + ':00');
}
/** 当前小时是否高亮(仅今天) */
private isNowHour(h: number): boolean {
return this.isToday && Math.floor(FbTimelineUtil.nowRatio(this.nowMin) * 24) === h;
}
/** 红线是否可见(今天 + 落在视窗内) */
private nowVisible(): boolean {
if (!this.isToday) {
return false;
}
const r: number = FbTimelineUtil.nowRatio(this.nowMin);
return r >= this.viewTop() - 0.0001 && r <= this.viewBot() + 0.0001;
}
/** 红线距层顶的偏移(vp) */
private nowOffsetVp(): number {
const y: number = (FbTimelineUtil.nowRatio(this.nowMin) - this.viewTop()) * this.totalH() - 1;
return y < 0 ? 0 : y;
}
// ---- 行序列(空隙 + 冲突组)----
private groups(): FbTimeGroup[] {
return FbTimelineUtil.groupsOf(this.timeline.blocks);
}
/**
* 纵向序列:只看视窗内的部分。
* **本层只有两样东西:空隙(Blank)与冲突组行** ——
* 整点线与红线交给另外两层,所以每条日程永远是完整连续的色块。
*/
private buildRows(): FbTimeRow[] {
const rows: FbTimeRow[] = [];
const gs: FbTimeGroup[] = this.groups();
const top: number = this.viewTop();
const bot: number = this.viewBot();
const dk: string = this.range().startHour.toString() + '_' + this.range().endHour.toString();
let cursor: number = top;
for (const g of gs) {
const gsx: number = g.startRatio > top ? g.startRatio : top;
const gex: number = g.endRatio < bot ? g.endRatio : bot;
if (gex - gsx <= 0.0002) {
continue; // 整组都在视窗外
}
this.pushRow(rows, cursor, gsx, null, dk);
this.pushRow(rows, gsx, gex, g, dk);
cursor = gex;
}
this.pushRow(rows, cursor, bot, null, dk);
return rows;
}
private pushRow(rows: FbTimeRow[], from: number, to: number,
g: FbTimeGroup | null, dk: string): void {
if (to - from <= 0.0002) {
return;
}
const r: FbTimeRow = new FbTimeRow();
r.topRatio = from;
r.botRatio = to;
if (g === null) {
r.kind = 0;
r.key = 'g_' + dk + '_' + rows.length.toString() + '_'
+ Math.round((to - from) * 100000).toString();
} else {
r.kind = 1;
r.group = g;
let sig: string = '';
for (const b of g.blocks) {
sig = sig + '_' + b.key;
}
r.key = 'b_' + dk + '_' + rows.length.toString() + sig;
}
rows.push(r);
}
/** 某一列中、与本行窗口有交集的色块 */
private laneBlocks(r: FbTimeRow, lane: FbTimeBlock[]): FbTimeBlock[] {
const out: FbTimeBlock[] = [];
for (const b of lane) {
const be: number = b.topRatio + b.heightRatio;
if (be > r.topRatio + 0.0001 && b.topRatio < r.botRatio - 0.0001) {
out.push(b);
}
}
return out;
}
/** 列内第 index 个色块之前要空出的高度(vp) */
private lanePadH(r: FbTimeRow, lane: FbTimeBlock[], index: number): number {
const arr: FbTimeBlock[] = this.laneBlocks(r, lane);
let prevEnd: number = r.topRatio;
if (index > 0) {
const p: FbTimeBlock = arr[index - 1];
const pe: number = p.topRatio + p.heightRatio;
prevEnd = pe > r.topRatio ? pe : r.topRatio;
}
const b: FbTimeBlock = arr[index];
const top: number = b.topRatio > r.topRatio ? b.topRatio : r.topRatio;
const d: number = (top - prevEnd) * this.totalH();
return d < 0 ? 0 : d;
}
/** 色块实际渲染高度(vp):极短的尽量垫到可读高度,但不越过同列下一块 */
private blockVp(r: FbTimeRow, lane: FbTimeBlock[], index: number): number {
const arr: FbTimeBlock[] = this.laneBlocks(r, lane);
const b: FbTimeBlock = arr[index];
const real: number = b.heightRatio * this.totalH();
if (real >= this.minBlockVp) {
return real;
}
let room: number = 0;
if (index + 1 < arr.length) {
room = this.lanePadH(r, lane, index + 1);
} else {
const tail: number = (r.botRatio - (b.topRatio + b.heightRatio)) * this.totalH();
room = tail > 0 ? tail : 0;
}
const h: number = real + room;
return h > this.minBlockVp ? this.minBlockVp : h;
}
/** 色块信息行数(按渲染高度判定) */
private blockLines(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): number {
const h: number = this.blockVp(r, lane, index);
if (h >= this.threeLineVp && b.place !== '') {
return 3;
}
if (h >= this.twoLineVp) {
return 2;
}
return 1;
}
/** 两行色块的第二行:`09:00 - 10:30 · 地点` */
private blockMeta(b: FbTimeBlock): string {
if (b.place !== '') {
return b.timeText + ' · ' + b.place;
}
return b.timeText;
}
/** 色块 key 的内容签名(内容变了必须重建,否则 ArkUI 复用旧子组件) */
private blockSig(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): string {
return b.title.length.toString() + '_' + b.place.length.toString() + '_'
+ Math.round(this.blockVp(r, lane, index)).toString();
}
// ============================================================
// 三层
// ============================================================
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@Builder
gridLayer() {
Column() {
ForEach(this.hourLabels(), (h: number) => {
Column()
.width('100%')
.height(this.hourUnit)
.border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } })
.hitTestBehavior(HitTestMode.None)
}, (h: number): string => 'gd_' + h.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None) // 装饰层:点击穿透到色块
}
/** 第 2 层:日程色块(冲突组分行 + 组内分列,纵向用 Blank 定位) */
@Builder
eventLayer() {
Column() {
ForEach(this.buildRows(), (r: FbTimeRow) => {
if (r.kind === 1 && r.group !== null) {
Row() {
ForEach(r.group!.lanes, (lane: FbTimeBlock[], li: number) => {
Column() {
ForEach(this.laneBlocks(r, lane), (b: FbTimeBlock, index: number) => {
Blank().height(this.lanePadH(r, lane, index))
Column({ space: 1 }) {
if (this.blockLines(r, lane, index, b) === 1) {
// 一行:标题 · 时间(地点放不下)
Row({ space: 4 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '55%' })
}
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
if (this.blockLines(r, lane, index, b) >= 3) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.place)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
Text(this.blockMeta(b))
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Start)
.padding({ left: 6, right: 3, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor(b.color)
.clip(true)
.width('100%')
.height(this.blockVp(r, lane, index))
.onClick((): void => {
if (b.ev !== null) {
this.onPick(b.ev);
}
})
}, (b: FbTimeBlock, index: number): string =>
'blk_' + r.key + '_' + b.key + '_' + this.blockSig(r, lane, index, b))
}
.layoutWeight(1)
.height('100%')
.padding({ right: 2 })
.clip(true)
}, (lane: FbTimeBlock[], li: number): string => 'ln_' + r.key + '_' + li.toString())
}
.width('100%')
.height((r.botRatio - r.topRatio) * this.totalH())
.alignItems(VerticalAlign.Top)
} else {
// 空隙:纯占位
Blank().height((r.botRatio - r.topRatio) * this.totalH())
}
}, (r: FbTimeRow): string => r.key)
}
.width('100%')
.height('100%')
}
/** 第 3 层:当前时间红线(浮在最上层) */
@Builder
nowLayer() {
if (this.nowVisible()) {
Row() {
// 左侧时间文字(红线那头)
Text(FbTimelineUtil.hhmm(this.nowMin))
.fontSize(9)
.fontColor(FB_NOW_RED)
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.End)
.width(34)
Row()
.layoutWeight(1)
.height(2)
.backgroundColor(FB_NOW_RED)
Row()
.width(6)
.height(6)
.borderRadius(3)
.backgroundColor(FB_NOW_RED)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: this.nowOffsetVp() })
.hitTestBehavior(HitTestMode.None)
}
}
build() {
Column({ space: 0 }) {
// ---- 全天 / 跨天:画在 0 点之前,用色块展示 ----
if (this.timeline.allDay.length > 0) {
Column({ space: 4 }) {
ForEach(this.timeline.allDay, (e: FbCalEvent) => {
Row({ space: 6 }) {
Text('全天')
.fontSize(9)
.fontColor(Color.White)
.backgroundColor('#26000000')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
Text(e.title !== '' ? e.title : '(无标题)')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (e.place !== '') {
Text(e.place)
.fontSize(10)
.fontColor('#D9FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 110 })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.height(26)
.padding({ left: 8, right: 8 })
.borderRadius(8)
.backgroundColor(e.color)
.onClick((): void => {
this.onPick(e);
})
}, (e: FbCalEvent): string => 'al_' + FbTimelineUtil.keyOf(e))
}
.width('100%')
.padding({ bottom: 6 })
}
// ---- 时间轴主体 ----
Row() {
// 左侧小时刻度
Column() {
ForEach(this.hourLabels(), (h: number) => {
Text(this.hourText(h))
.fontSize(10)
.fontColor(this.isNowHour(h) ? FB_NOW_RED : FbColors.faint)
.width(36)
.height(this.hourUnit)
.textAlign(TextAlign.End)
.padding({ right: 4, top: 2 })
.border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } })
}, (h: number): string => 'h' + h.toString())
}
.width(36)
// 右侧内容区:三层堆叠
Stack() {
this.gridLayer()
this.eventLayer()
this.nowLayer()
}
.layoutWeight(1)
.height(this.viewH())
.alignContent(Alignment.TopStart)
.border({ width: { left: 0.5 }, color: { left: FbColors.line2 } })
}
.width('100%')
.alignItems(VerticalAlign.Top)
// ---- 当天没有带时间的日程 ----
if (!this.timeline.hasTimed && this.timeline.allDay.length === 0) {
Text('这天没有安排')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 20, bottom: 20 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
@@ -0,0 +1,50 @@
/**
* 家书 FamilyBook · 可选中胶囊片(鸿蒙端)
*
* 日程「新建/编辑」里给**重复**和**提醒**两组选项用。
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug):
* 「重复和提醒都挤在一块了……而且**重复和提醒什么的都点不了**。」
* 根因:原来是 `FbEventEditSheet.chip(label, on: boolean, onTap)` ——
* **`@Builder` 的值类型参数(boolean)不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」)。
* ⇒ 点一下 `this.fRepeat` / `this.fRemind` 确实改了、保存也发得出去,
* 但**选中态那份子树不重建** ⇒ 界面上没有任何反馈 ⇒ 看着"点不了"。
* 本项目已在 `FbTagChip`、`FbCalOptionRow` 上踩过同一个坑(各见其文件头)。
* ⇒ 统一做法:抽成 `@ComponentV2` + `@Param active`。
*
* ⚠️ 顺带的另一处:它原来是裸放在 `Flex({wrap})` 里、**没有 margin** ——
* 项目里别处的 wrap 都写了 `.margin({ right: 8, bottom: 8 })`
* (见 `FbHomePage` 的标签云、`FbSettingsPage` 的色板)。
* 少了 margin,两行 chip 会**贴在一起**(这正是"挤在一块"的观感来源)。
* ⇒ margin 放在**本组件根节点**上,调用方不用再写。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbEventChip {
/** 显示文字 */
@Param label: string = '';
/** 是否选中(**必须由父组件按状态算好传进来**) */
@Param active: boolean = false;
/** 选中时的底色(默认用日程模块的主题色;日历本那组传本色) */
@Param activeBg: string = '#4A7C82';
/** 被点击 */
@Event onPick: () => void = (): void => {
};
build() {
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.active ? Color.White : FbColors.muted)
.backgroundColor(this.active ? this.activeBg : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
// ⚠️ 间距放在组件内部:wrap 容器里的子项必须自己有 margin,否则会贴在一起
.margin({ right: 8, bottom: 8 })
.onClick((): void => {
this.onPick();
})
}
}
@@ -0,0 +1,908 @@
/**
* 家书 FamilyBook · 日程新建 / 编辑浮层(鸿蒙端)
*
* 用户 2026-10-15:「我们家书的日程功能模块……跟 SyncCalendar 做得一样」。
*
* 字段对齐 SyncCalendar 的 `EventEditPage`:
* **标题 / 全天 / 日期与时间(同一行)/ 重复 / 提醒 / 日历本 / 地点 / 备注**,
* 编辑既有日程时底部还有一颗**删除**。
*
* 🔴 用户 2026-10-16 的两条修正:
* 1. 「**重复和提醒都挤在一块了**……而且**重复和提醒什么的都点不了**。」
* · "点不了" = chip 曾是 `@Builder` 值参数(不参与观察)⇒ 换成 `FbEventChip` 组件;
* · "挤在一块" = 裸 `Flex({wrap})` 里的 chip **没有 margin** ⇒
* 间距放进 `FbEventChip` 组件内部(项目别处的 wrap 也都写了 margin)。
* 2. 「时间和日期……**放在一行上吧**,别放上下了。」
* · 原来开始/结束各占一整行(`日期 时间` 拼成一串)。
* 3. **第二轮**:「你参考一下 SyncCalendar 的做法,实际上就是
* **把日期和时间放在一个控件里面**。」
* · 🔴 第一版我只做到"日期块 + 时间块并排一行",**但仍是两个可点区**、
* 点开的是**两个独立选择器** —— 不是 SyncCalendar 的形态。
* · SyncCalendar(`EventEditPage.dateTimePickerSheet`)是:
* **一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**,
* 选完点「确定」**一次回填**。
* · ⇒ 改成:控件是**一整块可点的框**(显示 `10月16日 周五 20:00`),
* 点它开弹层、里面两个滚轮并排、点「确定」写回;
* 弹层内部用**草稿**(`pickDate`/`pickHour`/`pickMin`)—— 取消可丢弃。
*
* 🔴 为什么用**浮层**而不是像 SyncCalendar 那样跳新页面:
* 本项目的浮层范式已经统一(见 `FbPersonEditSheet` / `FbMovieDetailSheet`)——
* 都是页面内的 `Stack` 覆盖层。跳页面会丢列表滚动位置、还要处理返回栈;
* 浮层状态完全由父页面掌控(一个 `@Param`),更简单。
* ⚠️ 与 SyncCalendar 的**功能对齐**是有意的;**形态不同**是有意的。
*
* ⚠️ 服务端 `POST/PATCH /api/v1/events` 的字段名是**下划线**(`starts_at` 等),
* 且"没传的字段沿用旧值" ⇒ 编辑时只需发改动的那些(见 `FbCalRepo.toBody`)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEventFull, FbCalEventInput, FbCalRepo } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbEventChip } from './FbEventChip';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbEventEdit';
/** 贴底卡片的手势区缓冲(同其他浮层) */
const FB_SHEET_BOTTOM_GAP: number = 16;
/**
* 提醒选项(分钟 → 文案)。
*
* 🔴 与 SyncCalendar 的 `reminderChip` 同一组值(0/5/10/15/30/60/1440)——
* 两边口径一致,用户在两个 App 里看到的选项不会不一样。
*/
interface FbRemindOpt {
min: number;
label: string;
}
const REMIND_OPTS: FbRemindOpt[] = [
{ min: 0, label: '不提醒' },
{ min: 5, label: '提前5分钟' },
{ min: 10, label: '提前10分钟' },
{ min: 15, label: '提前15分钟' },
{ min: 30, label: '提前30分钟' },
{ min: 60, label: '提前1小时' },
{ min: 1440, label: '提前1天' }
];
/**
* 重复选项(key → 文案 + RRULE)。
*
* ⚠️ 只做**最常见的那几种**(对齐 SyncCalendar 的 chip 组),
* "每两周/自定义"这类留给网页端 —— 手机上点五层下拉去拼 RRULE 得不偿失。
* ⚠️ 端上拼的是**完整 RRULE 串**,服务端 `rruleFromBody()` 也认这个口径。
*/
interface FbRepeatOpt {
key: string;
label: string;
rule: string;
}
const REPEAT_OPTS: FbRepeatOpt[] = [
{ key: 'none', label: '不重复', rule: '' },
{ key: 'daily', label: '每天', rule: 'FREQ=DAILY' },
{ key: 'weekday', label: '工作日', rule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' },
{ key: 'weekly', label: '每周', rule: 'FREQ=WEEKLY' },
{ key: 'monthly', label: '每月', rule: 'FREQ=MONTHLY' },
{ key: 'yearly', label: '每年', rule: 'FREQ=YEARLY' }
];
@ComponentV2
export struct FbEventEditSheet {
/** 要编辑的日程(null + isCreate=false 表示没打开;isCreate=true 时是"新建") */
@Param event: FbCalEventFull | null = null;
/** 新建模式 */
@Param isCreate: boolean = false;
/** 新建时预填的日期(`YYYY-MM-DD`,从"点哪天的格子"来) */
@Param presetDate: string = '';
/** 可选的日历本(新建时要选落到哪本) */
@Param books: FbCalBook[] = [];
/** 取消 */
@Event onCancel: () => void = (): void => {
};
/** 保存成功 */
@Event onSaved: () => void = (): void => {
};
/** 删除成功 */
@Event onDeleted: () => void = (): void => {
};
// ---- 表单状态 ----
@Local fTitle: string = '';
@Local fAllDay: boolean = false;
/** `YYYY-MM-DD` */
@Local fStartDate: string = '';
/** `HH:mm` */
@Local fStartTime: string = '09:00';
@Local fEndDate: string = '';
@Local fEndTime: string = '10:00';
@Local fPlace: string = '';
@Local fNote: string = '';
/** 重复的 key(REPEAT_OPTS 里那个) */
@Local fRepeat: string = 'none';
/** 提醒分钟(可多选;空数组 = 不提醒) */
@Local fRemind: number[] = [];
/** 选中的日历本 id */
@Local fBookId: number = 0;
@Local busy: boolean = false;
@Local err: string = '';
@Local confirmDel: boolean = false;
@Local deleting: boolean = false;
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar 的 `dateTimePickerSheet`)----
/**
* 弹层开着吗。
*
* 🔴 **为什么不是"就地展开"**(这是用户 2026-10-16 的第二条反馈):
* SyncCalendar 的做法是**一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**
* (见其 `EventEditPage.dateTimePickerSheet`),选完点「确定」一次回填。
* 我第一版做成了"点日期块就地展开一个 DatePicker、点时间块再展开一个 TimePicker"
* —— 那是两个控件、分两次选,不是它的做法。
*/
@Local pickOpen: boolean = false;
/** 正在选的是"结束"(false = 开始) */
@Local pickIsEnd: boolean = false;
/** 弹层里的**草稿**值:点「确定」才写回 `fStartXxx`/`fEndXxx`(取消则丢弃) */
@Local pickDate: Date = new Date();
@Local pickHour: number = 9;
@Local pickMin: number = 0;
aboutToAppear(): void {
const e: FbCalEventFull | null = this.event;
if (e !== null) {
// ---- 编辑:回填 ----
this.fTitle = e.title;
this.fAllDay = e.all_day;
this.fStartDate = this.dateOf(e.starts_at);
this.fStartTime = this.timeOf(e.starts_at);
this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at);
this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : this.addHour(e.starts_at);
this.fPlace = e.location;
this.fNote = e.note;
this.fRepeat = this.repeatKeyOf(e.rrule);
this.fRemind = this.minsOf(e.reminder_minutes);
this.fBookId = e.calendar_id;
return;
}
// ---- 新建:预填 ----
const d: string = this.presetDate !== '' ? this.presetDate : this.todayStr();
this.fStartDate = d;
this.fEndDate = d;
// 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 ——
// 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。
const now: Date = new Date();
const nh: number = Math.min(now.getHours() + 1, 22);
this.fStartTime = this.pad(nh) + ':00';
this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00';
this.fBookId = this.defaultBookId();
}
// ============================================================
// 取值 / 换算
// ============================================================
private todayStr(): string {
const n: Date = new Date();
return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate());
}
private pad(n: number): string {
return n < 10 ? ('0' + n.toString()) : n.toString();
}
/** 从 `YYYY-MM-DD HH:MM:SS` 取日期段 */
private dateOf(dt: string): string {
return dt.length >= 10 ? dt.substring(0, 10) : this.todayStr();
}
/** 从 `YYYY-MM-DD HH:MM:SS` 取时间段的 `HH:mm` */
private timeOf(dt: string): string {
return dt.length >= 16 ? dt.substring(11, 16) : '09:00';
}
/** 给一个时间的时分 +1 小时(当结束时间没填时的兜底) */
private addHour(dt: string): string {
const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9;
const h2: number = Math.min(hh + 1, 23);
return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '00');
}
/** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */
private defaultBookId(): number {
const want: number = FB_CAL_SETTINGS.getBookId();
for (const b of this.books) {
if (b.id === want && b.canWrite) {
return b.id;
}
}
for (const b of this.books) {
if (b.canWrite) {
return b.id;
}
}
return 0;
}
/** 重复:从 RRULE 串反推是哪个 chip(认不出就归"不重复") */
private repeatKeyOf(rrule: string): string {
if (rrule === '') {
return 'none';
}
for (const o of REPEAT_OPTS) {
if (o.rule !== '' && rrule.indexOf(o.rule) === 0) {
return o.key;
}
}
// 认不出的复杂规则:给"不重复"更危险(会覆盖掉原规则),
// 但端上确实没有编辑自定义规则的能力 ⇒ 保留原文不覆盖(见 save() 的处理)
return 'none';
}
/** 重复 chip → RRULE 串 */
private ruleOfRepeat(): string {
for (const o of REPEAT_OPTS) {
if (o.key === this.fRepeat) {
return o.rule;
}
}
return '';
}
/** 提醒串 → 分钟数组 */
private minsOf(s: string): number[] {
const out: number[] = [];
if (s === '') {
return out;
}
for (const x of s.split(',')) {
const n: number = parseInt(x.trim(), 10);
if (!isNaN(n) && n > 0) {
out.push(n);
}
}
return out;
}
/** 登录令牌 */
private token(): string {
return FB_TOKEN_STORE.get();
}
// ============================================================
// 小件
// ============================================================
@Builder
field(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
TextInput({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
area(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
TextArea({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 小标题(把一大串字段分开) */
@Builder
section(title: string) {
Text(title)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 6 })
}
/**
* ⚠️ 这里删掉过两个 `@Builder`,都**不要**加回来:
* · `chip(label, on: boolean, onTap)` —— "重复/提醒点不了"的根因
* (@Builder 值参数不参与观察)⇒ 已换成 `FbEventChip` 组件。
* · `pickRow(label, value, onTap)` —— 被"日期时间并排一行"取代
* (用户要求时间和日期同一行,不再上下堆)⇒ 见 `dateTimeRow`。
*/
build() {
// 🔴 外层 `Stack` 是**必需**的:本组件有**三个**要显示的层
// (编辑卡片 + 日期时间弹层 + 删除确认遮罩),而 build() 只能有一个根节点。
// ⚠️ `alignContent: Bottom` 让卡片/弹层都贴底(同一形态)。
Stack({ alignContent: Alignment.Bottom }) {
this.editCard()
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar)----
if (this.pickOpen) {
this.dateTimePickerSheet()
}
if (this.confirmDel) {
this.deleteConfirm()
}
}
.width('100%')
.height('100%')
}
@Builder
editCard() {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row({ space: 12 }) {
Text(this.isCreate ? '新建日程' : '编辑日程')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick((): void => {
this.onCancel();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
this.field('标题', this.fTitle, '日程得有个标题', (v: string): void => {
this.fTitle = v;
})
// ---- 全天 ----
Row() {
Text('全天')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.fAllDay })
.selectedColor('#4A7C82')
.onChange((on: boolean): void => {
this.fAllDay = on;
})
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
// ---- 日期与时间 ----
// 🔴 照 SyncCalendar:**一行一个"日期 + 时间"控件**(点开一个弹层、
// 里面 DatePicker 与 TimePicker 并排,选完点「确定」)。
// 用户 2026-10-16:「实际上就是把日期和时间放在一个控件里面。」
this.section('日期与时间')
this.dateTimeRow('开始', true)
if (!this.fAllDay) {
this.dateTimeRow('结束', false)
}
// ---- 重复 ----
// 🔴 用 `FbEventChip` **组件**而不是 @Builder ——
// @Builder 的值类型参数(on: boolean)变化**不触发重建**,
// 这正是"重复/提醒点不了"的根因(见该组件文件头)。
this.section('重复')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(REPEAT_OPTS, (o: FbRepeatOpt) => {
FbEventChip({
label: o.label,
active: this.fRepeat === o.key,
onPick: (): void => {
this.fRepeat = o.key;
}
})
}, (o: FbRepeatOpt): string => 'rp_' + o.key)
}
.width('100%')
.margin({ top: 2 })
// ---- 提醒 ----
this.section('提醒(可多选)')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(REMIND_OPTS, (o: FbRemindOpt) => {
FbEventChip({
label: o.label,
active: this.hasRemind(o.min),
onPick: (): void => {
this.toggleRemind(o.min);
}
})
}, (o: FbRemindOpt): string => 'rm_' + o.min.toString())
}
.width('100%')
.margin({ top: 2 })
// ---- 日历本 ----
if (this.books.length > 0) {
this.section('加入哪本日历')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.books, (b: FbCalBook) => {
FbEventChip({
label: b.name,
active: this.fBookId === b.id,
// 选中时用**这本日历自己的颜色**(一眼看出落到哪本)
activeBg: b.color,
onPick: (): void => {
this.fBookId = b.id;
}
})
}, (b: FbCalBook): string => 'bk_' + b.id.toString())
}
.width('100%')
}
// ---- 地点 / 备注 ----
this.field('地点', this.fPlace, '地点(可选)', (v: string): void => {
this.fPlace = v;
})
this.area('备注', this.fNote, '备注(可选)', (v: string): void => {
this.fNote = v;
})
if (this.err !== '') {
Text(this.err)
.fontSize(FbMetric.fontTiny)
.fontColor('#A32D2D')
.width('100%')
}
}
.width('100%')
.padding({ top: 14 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
// ---- 底部按钮:删除 / 保存 ----
if (this.deleting) {
Text('删除中…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.margin({ top: 16 })
} else {
Row({ space: 10 }) {
if (!this.isCreate) {
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor('#C0533F')
.textAlign(TextAlign.Center)
.height(42)
.padding({ left: 18, right: 18 })
.border({ width: 1, color: '#C0533F' })
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.confirmDel = true;
}
})
}
Text(this.busy ? '保存中…' : (this.isCreate ? '创建' : '保存'))
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(this.busy ? '#4A7C8288' : '#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.save();
}
})
}
.width('100%')
.margin({ top: 16 })
}
}
.width('100%')
.constraintSize({ maxHeight: '86%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.alignItems(HorizontalAlign.Start)
}
/**
* **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。
*
* 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是
* **把日期和时间放在一个控件里面**。」
* 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与
* `TimePicker` 并排**,选完点「确定」一次回填。
* ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker"
* —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。
*
* 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`;
* 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。
*/
@Builder
dateTimeRow(label: string, isStart: boolean) {
Row({ space: 10 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(28)
// 整块可点:日期与时间在**同一个控件**里,中间不再分两块
Row() {
Text(this.cnDate(isStart ? this.fStartDate : this.fEndDate))
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
if (!this.fAllDay) {
Text(' ' + (isStart ? this.fStartTime : this.fEndTime))
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
}
}
.layoutWeight(1)
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.openPicker(isStart);
})
// 右侧小箭头:暗示"点开会选"(与 SyncCalendar 的行样式一致)
Text('›')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.faint)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/**
* 打开「日期 + 时间」弹层(把当前值装进草稿)。
*
* ⚠️ 草稿的意义:用户滚了一半又点「取消」⇒ **不写回**(`fStartXxx` 保持原样)。
* 若直接改 `fStartXxx`,"取消"就没法撤销了。
*/
private openPicker(isEnd: boolean): void {
this.pickIsEnd = isEnd;
const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate);
this.pickDate = d;
const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime);
this.pickHour = tm.getHours();
this.pickMin = tm.getMinutes();
this.pickOpen = true;
}
/** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */
private applyPicker(): void {
const iso: string = this.pad(this.pickDate.getFullYear()) + '-'
+ this.pad(this.pickDate.getMonth() + 1) + '-' + this.pad(this.pickDate.getDate());
const hm: string = this.pad(this.pickHour) + ':' + this.pad(this.pickMin);
if (this.pickIsEnd) {
this.fEndDate = iso;
this.fEndTime = hm;
} else {
this.fStartDate = iso;
this.fStartTime = hm;
// ⚠️ 开始改到"结束之后" ⇒ 把结束顺延到同一时刻(服务端不校验,但负时长是坏的)
if (this.fEndDate < iso || (this.fEndDate === iso && this.fEndTime < hm)) {
this.fEndDate = iso;
this.fEndTime = hm;
}
}
this.pickOpen = false;
}
/**
* 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。
*
* 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。
* ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**:
* 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。
* ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。
*/
@Builder
dateTimePickerSheet() {
Column({ space: 14 }) {
Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12 })
Row({ space: 6 }) {
DatePicker({
start: new Date('2000-01-01'),
end: new Date('2035-12-31'),
selected: this.pickDate
})
.onDateChange((v: Date): void => {
this.pickDate = v;
})
.textStyle({ color: FbColors.faint, font: { size: 14 } })
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
.layoutWeight(1)
.height(210)
if (!this.fAllDay) {
TimePicker({ selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) })
.useMilitaryTime(true)
.onChange((v: TimePickerResult): void => {
// ⚠️ `hour`/`minute` 是**数字**;这里存草稿数字,拼 `HH:MM` 时再补零
this.pickHour = v.hour;
this.pickMin = v.minute;
})
.textStyle({ color: FbColors.faint, font: { size: 14 } })
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
.layoutWeight(1)
.height(210)
}
}
.width('100%')
Button(this.fAllDay ? '确定日期' : '确定')
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.fontSize(FbMetric.fontBody)
.backgroundColor('#4A7C82')
.fontColor(Color.White)
.onClick((): void => {
this.applyPicker();
})
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.padding({ left: 18, right: 18, top: 8, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.alignItems(HorizontalAlign.Start)
}
/** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */
private cnDate(iso: string): string {
if (iso.length < 10) {
return iso;
}
const y: number = parseInt(iso.substring(0, 4), 10);
const m: number = parseInt(iso.substring(5, 7), 10);
const d: number = parseInt(iso.substring(8, 10), 10);
const w: string[] = ['日', '一', '二', '三', '四', '五', '六'];
return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()];
}
private parseDate(s: string): Date {
if (s.length < 10) {
return new Date();
}
const y: number = parseInt(s.substring(0, 4), 10);
const m: number = parseInt(s.substring(5, 7), 10) - 1;
const d: number = parseInt(s.substring(8, 10), 10);
return new Date(y, m, d);
}
private parseTime(s: string): Date {
const out: Date = new Date();
if (s.length >= 5) {
out.setHours(parseInt(s.substring(0, 2), 10));
out.setMinutes(parseInt(s.substring(3, 5), 10));
}
return out;
}
@Builder
deleteConfirm() {
Column() {
Column({ space: 8 }) {
Text('删除这条日程?')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('删掉之后不能撤销。若这条日程是重复的,这一次和以后的都会不见。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.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.confirmDel = false;
})
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor('#C0533F')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
this.doDelete();
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(18)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding({ left: 32, right: 32 })
.backgroundColor('#00000066')
.onClick((): void => {
this.confirmDel = false;
})
}
// ============================================================
// 提醒多选
// ============================================================
private hasRemind(min: number): boolean {
// 0 = "不提醒":选中它等价于"没有任何提醒"
if (min === 0) {
return this.fRemind.length === 0;
}
return this.fRemind.indexOf(min) >= 0;
}
private toggleRemind(min: number): void {
if (min === 0) {
this.fRemind = [];
return;
}
const at: number = this.fRemind.indexOf(min);
if (at >= 0) {
this.fRemind.splice(at, 1);
} else {
this.fRemind.push(min);
}
}
// ============================================================
// 网络
// ============================================================
private async doDelete(): Promise<void> {
const e: FbCalEventFull | null = this.event;
if (e === null) {
return;
}
const token: string = this.token();
if (token === '') {
this.err = '未登录,无法删除';
return;
}
this.deleting = true;
this.err = '';
const res = await FbCalRepo.inst().remove(e.id, token);
this.deleting = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '删除日程失败:%{public}s', res.error);
this.err = '删除失败:' + res.error;
return;
}
this.onDeleted();
}
/** 保存(新建走 POST、编辑走 PATCH) */
private async save(): Promise<void> {
const title: string = this.fTitle.trim();
if (title === '') {
this.err = '日程得有个标题';
return;
}
const token: string = this.token();
if (token === '') {
this.err = this.isCreate ? '未登录,无法创建' : '未登录,无法保存';
return;
}
const isNew: boolean = this.isCreate || this.event === null;
if (isNew && this.fBookId <= 0) {
this.err = '请选一本日历';
return;
}
// ---- 时间:全天归零到 00:00;否则拼上时分 ----
const startAt: string = this.fAllDay
? (this.fStartDate + ' 00:00:00')
: (this.fStartDate + ' ' + this.fStartTime + ':00');
let endAt: string = this.fAllDay
? (this.fEndDate + ' 00:00:00')
: (this.fEndDate + ' ' + this.fEndTime + ':00');
// ⚠️ 结束早于开始 ⇒ 补成同一时刻(服务端不校验时长,但"负时长"看着是坏的)
if (endAt < startAt) {
endAt = startAt;
}
this.busy = true;
this.err = '';
const input: FbCalEventInput = {
title: title,
location: this.fPlace.trim(),
starts_at: startAt,
ends_at: endAt,
all_day: this.fAllDay,
note: this.fNote,
reminder_minutes: this.fRemind
};
// ⚠️ 重复:只在用户**确实选过**时才发。
// 编辑一条"自定义重复"的日程时,端上认不出来(归到"不重复"),
// 若照样发个空 rrule,就会把人家原来的规则**清掉**。
// ⇒ 规则原文认不出(非空且 chip 是 none)⇒ 这一项**不发**,保持原样。
const recognize: boolean = this.fRepeat !== 'none'
|| (this.event !== null && this.event.rrule === '');
if (recognize) {
input.rrule = this.ruleOfRepeat();
}
if (isNew) {
input.calendar_id = this.fBookId;
}
const repo: FbCalRepo = FbCalRepo.inst();
const res = isNew ? await repo.create(input, token) : await repo.update(this.event!.id, input, token);
this.busy = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '保存日程失败:%{public}s', res.error);
this.err = (isNew ? '创建失败:' : '保存失败:') + res.error;
return;
}
// 新建时把选的日历本记成"以后默认用它"(与 SyncCalendar 的默认本同一体验)
if (isNew) {
FB_CAL_SETTINGS.setBookId(this.fBookId);
}
this.onSaved();
}
}
@@ -0,0 +1,269 @@
/**
* 家书 FamilyBook · 日程详情浮层(鸿蒙端)
*
* 用户 2026-10-15:「……包括下面日程的显示啊什么之类的,就按照 SyncCalendar 那个模式去做。」
* SyncCalendar 的详情是**半屏 bindSheet**,内容为:
* 标题(带日历本色点 + 系统/只读徽标 + 关闭)→ 时间 → 日历本/重复 →
* 地点(可点导航)→ 备注 → 「编辑日程」。
*
* 🔴 这里用**页面内 Stack 覆盖层**而不是 `bindSheet`(与家书其他浮层一致,
* 理由见 `FbMovieDetailSheet` 的文件头);
* **功能与信息结构**照 SyncCalendar 对齐。
*
* ⚠️ 详情数据是**异步拉的**(`/events/<id>` 才给 note/rrule/reminder/ends_at)
* ⇒ 三种态都要画:加载中 / 成功 / 失败。别只画成功态。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEventFull } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FbDot } from './Widgets';
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';
/** 能不能编辑(不能写的日历本不给"编辑"按钮) */
@Param canEdit: boolean = false;
@Event onClose: () => void = (): void => {
};
@Event onEdit: () => void = (): void => {
};
/** 时间显示:`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() {
Column({ space: 0 }) {
// ---- 标题区 ----
Row({ space: 10 }) {
FbDot({ dia: 12, fillC: this.bookColor })
Text(this.event !== null && this.event.title !== ''
? this.event.title : '(无标题)')
.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) {
Text(this.error !== '' ? ('读不到这条日程:' + this.error) : '读不到这条日程')
.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)
}
Row().height(8)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(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) {
Text('编辑日程')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor('#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onEdit();
})
}
}
.width('100%')
.margin({ top: 14 })
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,288 @@
/**
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
*
* 把亲属关系画成**知识图谱**那样的图:节点是圆形头像,边是连线。
*
* 🔴 为什么用 `Canvas` 而不是 `Circle`/`Path` 组件拼:
* · 布局是**算出来的**(按代数分层、同代均分、夫妻相邻),
* 节点数量与位置都不定 —— 组件树的写法要动态拼 Path 的 `commands`,
* 比直接算坐标画线**难得多**、也更难调;
* · 连线要**画在节点下面**(否则线会压在头像上)——
* Canvas 一次画完,天然有先后顺序。
* ⚠️ 代价:Canvas **不是响应式的**,数据变了要**自己重画**。
* 所以这里在 `onReady`(首帧)与**每次数据/尺寸变化时**都重画一遍。
*
* 布局规则(与"从上往下读家谱"的直觉一致):
* · **纵向 = 代数**:长辈在上、我居中、晚辈在下(`gen` 越小越靠上);
* · **横向 = 同代展开**:同代的人均分宽度;配偶排在彼此旁边;
* · 连线:父母→子女(带箭头感的折线)、夫妻(水平短线,更粗)。
* ⚠️ 不做"力导向"那种自动布局:家谱有**明确的层次语义**,
* 力导向会把人随机甩开,反而看不懂谁是长辈。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbKinPerson } from '../model/Types';
/** 一个节点在画布上的位置 */
interface KinNode {
p: FbKinPerson;
x: number;
y: number;
}
@ComponentV2
export struct FbKinGraphView {
/** 家谱里的人 */
@Param people: FbKinPerson[] = [];
/** 我(居中显示、加粗) */
@Param meId: number = 0;
/** 点某个节点(弹详情) */
@Event onPick: (id: number) => void = (id: number): void => {
};
/** 画布上下文(必须在组件里创建,不能跨组件共享) */
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
/** 画布尺寸(onAreaChange 拿到) */
@Local cw: number = 0;
@Local ch: number = 0;
/** 算好的节点位置(画与点击都用它) */
private nodes: KinNode[] = [];
/** 节点半径(vp) */
private readonly R: number = 22;
build() {
Canvas(this.ctx)
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
// ⚠️ `onReady` 只在**首帧**触发一次;后续尺寸变化要靠 `onAreaChange`。
// 只用 onReady 的话,切到家谱页(宽高这时才定下来)会画不出东西。
.onReady((): void => {
this.redraw();
})
.onAreaChange((_old: Area, now: Area): void => {
this.cw = Number(now.width);
this.ch = Number(now.height);
this.redraw();
})
// 🔴 `Canvas` **没有**"点到了哪个图形"的概念 —— 命中检测要自己算。
// 画的位置由 `layout()` 定,点击时拿同一份 `this.nodes` 比距离即可。
.onClick((ev: ClickEvent): void => {
const x: number = ev.x;
const y: number = ev.y;
// 命中半径放宽一点(手指比鼠标粗):用 1.35×R 更"跟手"
const hit: number = this.R * 1.35;
for (const n of this.nodes) {
const dx: number = x - n.x;
const dy: number = y - n.y;
if (dx * dx + dy * dy <= hit * hit) {
this.onPick(n.p.id);
return;
}
}
})
}
/**
* 重画整张图(**先算位置、再画线、最后画节点**)。
*
* ⚠️ 顺序很重要:线在节点下面 —— 否则线会横穿头像。
* Canvas 没有 z-index,只能靠绘制顺序。
*/
private redraw(): void {
if (this.cw <= 0 || this.ch <= 0 || this.people.length === 0) {
return;
}
this.layout();
this.drawEdges();
this.drawNodes();
}
/**
* 算节点位置(按代数分层)。
*
* ⚠️ 结果存进 `this.nodes` —— 点击命中检测要用**同一份坐标**,
* 重算一遍容易出现"看到的和点到的不一样"。
*/
private layout(): void {
// ---- ① 按代数分组 ----
const byGen: Map<number, FbKinPerson[]> = new Map<number, FbKinPerson[]>();
for (const p of this.people) {
const cur: FbKinPerson[] | undefined = byGen.get(p.gen);
if (cur !== undefined) {
cur.push(p);
} else {
byGen.set(p.gen, [p]);
}
}
const gens: number[] = [];
byGen.forEach((_v: FbKinPerson[], k: number): void => {
gens.push(k);
});
// ⚠️ 必须按**数值**排(Map 的键直接 sort 会按字符串:-1,-2,0,1)
gens.sort((a: number, b: number): number => a - b);
// ---- ② 纵向:每代一条水平带,均分高度 ----
const padTop: number = 46; // 给顶部代数标签留位置
const padBottom: number = 26;
const bandH: number = (this.ch - padTop - padBottom) / Math.max(1, gens.length);
const nodes: KinNode[] = [];
for (let gi: number = 0; gi < gens.length; gi++) {
const g: number = gens[gi];
const ps: FbKinPerson[] = byGen.get(g) !== undefined ? (byGen.get(g) as FbKinPerson[]) : [];
const y: number = padTop + bandH * gi + bandH / 2;
// ---- ③ 横向:同代均分 ----
// ⚠️ 左右各留 `R + 8`,否则第一个/最后一个节点会贴边、头像被切掉。
const padX: number = this.R + 10;
const usable: number = this.cw - padX * 2;
const step: number = ps.length > 1 ? usable / (ps.length - 1) : 0;
// ⚠️ 只有一个人时放**正中间**(step=0 会让它贴在左边)
const startX: number = ps.length > 1 ? padX : this.cw / 2;
for (let i: number = 0; i < ps.length; i++) {
nodes.push({ p: ps[i], x: startX + step * i, y: y });
}
}
this.nodes = nodes;
}
/** 按 id 找节点 */
private nodeOf(id: number): KinNode | undefined {
for (const n of this.nodes) {
if (n.p.id === id) {
return n;
}
}
return undefined;
}
/** 画连线 */
private drawEdges(): void {
const c: CanvasRenderingContext2D = this.ctx;
c.lineWidth = 1.6;
c.strokeStyle = '#B9A896';
for (const n of this.nodes) {
// ---- 父母 → 子女(折线:下-横-下)----
const parents: number[] = n.p.parents;
for (const pid of parents) {
const par: KinNode | undefined = this.nodeOf(pid);
if (par === undefined) {
continue;
}
// 折线中点取两者的横向中点,纵向上 1/3 处拐
const midY: number = par.y + (n.y - par.y) * 0.55;
c.beginPath();
c.moveTo(par.x, par.y + this.R);
c.lineTo(par.x, midY);
c.lineTo(n.x, midY);
c.lineTo(n.x, n.y - this.R);
c.stroke();
}
// ---- 夫妻(水平短线,**画粗一点**,与血缘线区分)----
for (const sid of n.p.spouses) {
// 只画一次(按 id 大小去重,否则同一对会画两条)
if (sid < n.p.id) {
continue;
}
const sp: KinNode | undefined = this.nodeOf(sid);
if (sp === undefined) {
continue;
}
c.save();
c.lineWidth = 3;
c.strokeStyle = '#C97B6A';
c.beginPath();
c.moveTo(n.x + this.R, n.y);
c.lineTo(sp.x - this.R, sp.y);
c.stroke();
c.restore();
}
}
}
/** 画节点(圆 + 首字 + 称谓) */
private drawNodes(): void {
const c: CanvasRenderingContext2D = this.ctx;
for (const n of this.nodes) {
const isMe: boolean = n.p.id === this.meId;
// 圆底(用头像色)
c.beginPath();
c.arc(n.x, n.y, this.R, 0, Math.PI * 2);
c.fillStyle = n.p.avatarColor !== '' ? n.p.avatarColor : '#8A6B4B';
c.fill();
// 「我」加一圈描边,一眼认出中心
if (isMe) {
c.lineWidth = 2.5;
c.strokeStyle = '#A84A28';
c.stroke();
}
// 名字首字
c.font = '16px sans-serif';
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillStyle = '#FFFFFF';
const nm: string = n.p.shown !== '' ? n.p.shown : n.p.name;
c.fillText(nm.length > 0 ? nm.substring(0, 1) : '?', n.x, n.y);
// 名字(圆下方)
c.font = isMe ? 'bold 12px sans-serif' : '12px sans-serif';
c.fillStyle = isMe ? '#A84A28' : '#2C231D';
// ⚠️ 名字可能很长("青岛西海岸电影城"那种)—— 截断,别糊成一片
const label: string = nm.length > 5 ? nm.substring(0, 5) + '…' : nm;
c.fillText(label, n.x, n.y + this.R + 11);
// 称谓(名字下面,小字灰色)
if (n.p.label !== '' && n.p.label !== '我') {
c.font = '10px sans-serif';
c.fillStyle = '#8A7A6A';
c.fillText(n.p.label, n.x, n.y + this.R + 24);
}
// ---- 代数标签(每层左边)----
// ⚠️ 只在每层**第一个**节点旁写,避免重复画
const first: KinNode | undefined = this.firstOfGen(n.p.gen);
if (first !== undefined && first.p.id === n.p.id) {
c.font = '11px sans-serif';
c.textAlign = 'left';
c.fillStyle = '#8A7A6A';
c.fillText(this.genLabel(n.p.gen), 8, n.y - this.R - 8);
c.textAlign = 'center';
}
}
}
/** 某代里排在最前的那个节点(用来放代数标签) */
private firstOfGen(gen: number): KinNode | undefined {
for (const n of this.nodes) {
if (n.p.gen === gen) {
return n;
}
}
return undefined;
}
/** 代数 → 层名(与家谱页的分层标题同口径) */
private genLabel(g: number): string {
if (g === 0) {
return '我这一辈';
}
if (g === -1) {
return '父母辈';
}
if (g <= -2) {
return '祖辈';
}
if (g === 1) {
return '子女辈';
}
return '孙辈';
}
}
@@ -0,0 +1,150 @@
/**
* 家书 FamilyBook · 模块设置浮层(鸿蒙端)
*
* 🔴 用户 2026-10-16:「**所有页面的设置按钮全部都改成和网页版一致**,
* 就是**三个横线**那种设置按钮。」
*
* 网页版的做法(`templates/` 下各模块的 `_settings_menu.php`):
* 每个模块的动作区最右有一个 `<details class="movie-set menu">`,
* `summary` 里就是**三条横线**的 SVG
* (`M2.5 4.5h11M2.5 8h11M2.5 11.5h11`),点开是一个菜单列表,
* 每条**图标 + 文字**。
*
* 🔴 本组件就是那个"点开后的菜单"(鸿蒙端做成底部浮层,与家书其它浮层同一形态)。
* ⚠️ **通用**:各模块只是条目不同,所以做成一个组件 + 传 `items`,
* 而不是给 8 个模块各写一个浮层(那种写法必然漂移)。
*
* ⚠️ 条目点了"还没做"的怎么办:**照实显示为不可用**(灰掉 + "暂未开放"),
* 而不是让点击没反应 —— 后者用户会以为卡了。
*/
import { FbColors, FbMetric, FbIcon } from '../common/Theme';
/** 贴底卡片的手势区缓冲(vp)—— 同其它浮层 */
const FB_SHEET_BOTTOM_GAP: number = 16;
/** 设置菜单里的一条 */
export interface FbSettingItem {
/** 图标名(`FbIcon.ui()` 的 key,如 'chart' / 'gear' / 'filter') */
icon: string;
/** 文字 */
label: string;
/** 一句话说明(可为空) */
hint: string;
/**
* 点了做什么。传空串 = **这项还没做**(画成灰色 + "暂未开放",点了不响应)。
* ⚠️ 用 string 而不是回调:@Param 传函数在 ArkTS 里会被"不可序列化"折腾,
* 这里只需要一个"标识",由父组件的 @Event 统一分发。
*/
action: string;
}
@ComponentV2
export struct FbModuleSettingsSheet {
/** 标题(如「观影设置」) */
@Param title: string = '设置';
/** 菜单条目 */
@Param items: FbSettingItem[] = [];
/** 关闭 */
@Event onClose: () => void = (): void => {
};
/** 点了某条(`action` 是条目的标识) */
@Event onPick: (action: string) => void = (a: string): void => {
};
build() {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row() {
Text(this.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
Text('完成')
.fontSize(FbMetric.fontSmall)
.fontColor('#4A7C82')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
ForEach(this.items, (it: FbSettingItem) => {
this.settingRow(it)
}, (it: FbSettingItem): string => 'st_' + it.action + '_' + it.label)
}
.width('100%')
.padding({ top: 10 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
.width('100%')
.constraintSize({ maxHeight: '70%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.alignItems(HorizontalAlign.Start)
}
/**
* 一条菜单项:**图标 + 文字**(照网页版:`每条文字前都要有图标`)。
*
* ⚠️ 未实现的条目(`action === ''`)画灰、且**不响应点击** ——
* "点了没反应"比"明确告诉你还没做"更糟(用户会以为卡死)。
*/
@Builder
settingRow(it: FbSettingItem) {
Row({ space: 12 }) {
Image(FbIcon.ui(it.icon))
.width(17)
.height(17)
.renderMode(ImageRenderMode.Template)
.fillColor(it.action === '' ? FbColors.faint : FbColors.muted)
Column({ space: 1 }) {
Text(it.label)
.fontSize(FbMetric.fontBody)
.fontColor(it.action === '' ? FbColors.faint : FbColors.ink)
if (it.hint !== '') {
Text(it.hint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (it.action === '') {
Text('暂未开放')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
} else {
Text('›')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.faint)
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.borderRadius(FbMetric.radiusS)
.margin({ bottom: 6 })
.backgroundColor(it.action === '' ? Color.Transparent : FbColors.inputBg)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
// ⚠️ 未实现的项**点了不响应**(不是"关闭浮层")——
// 否则用户以为"点了进去但没东西"
if (it.action !== '') {
this.onPick(it.action);
}
})
}
}
@@ -0,0 +1,506 @@
/**
* 家书 FamilyBook · 观影详情浮层(鸿蒙端)
*
* 点观影卡片后弹出,展示:海报大图、片名、时间、影院(含地址)、
* 剧情简介、评分、同看的人、照片。
*
* 🔴 为什么用**页面内浮层(Stack 覆盖)**而不是系统弹窗:
* ① 系统弹窗(`AlertDialog` / `CustomDialog`)受"多弹窗堆叠""后台不许弹"
* 等一堆约束,还要处理生命周期;
* ② 浮层就是一个普通组件,**状态完全由父页面掌控**(`@Param` 一个
* `visible` 就行),要加动画/手势/局部刷新都自由。
* 本项目 Web 端 v43 也是这个思路(`?embed=1` 浮层而不是整页跳转)。
*
* ⚠️ 详情数据是**异步拉的**(网络),所以这个组件要处理三种态:
* 加载中 / 成功 / 失败。别只画成功态 —— 断网时用户会看到"空白浮层"。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo';
import { fbImageUrl } from '../data/FbHttp';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbAvatar, FbRating } from './Widgets';
/**
* 照片缩略图的宽高比。
*
* 🔴 **实测值**:线上照片是 **270×377**(`270/377 ≈ 0.7162`)。
* 别改成 1(正方形)—— 竖版照片会被裁掉上下大半,用户一眼就看出不对。
* ⚠️ 服务端目前上传的图都是这个尺寸(4 张实测一致),
* 若将来允许不同比例,应改成"每张读自己的尺寸"而不是统一比例。
*/
const FB_PHOTO_RATIO: number = 270 / 377;
@ComponentV2
export struct FbMovieDetailSheet {
/** 要展示的详情(null = 还在加载) */
@Param detail: FbMovieDetail | null = null;
/** 加载中标志(与 detail 配合:加载中时 detail 也可能是 null) */
@Param loading: boolean = false;
/** 加载失败信息(空串 = 没失败) */
@Param error: string = '';
/** 列表项带过来的评分(详情接口不给,见 FbMovieRepo.detail 的说明) */
@Param listRating: number = 0;
@Param listReviewCount: number = 0;
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
// ---- 全屏大图查看器 ----
/** 查看器是否打开 */
@Local viewerOpen: boolean = false;
/** 查看器当前显示第几张 */
@Local viewerIndex: number = 0;
/** 查看器里的图片列表(打开时快照,避免与详情刷新打架) */
@Local viewerList: string[] = [];
/**
* 详情里所有可看的图:**票根在前 + 现场照片**。
*
* ⚠️ 与 Web 端口径一致(`templates/movies/show.php` 把票根和照片
* "合成一块展示、同一套灯箱",注释里写明了不再分类)。
* 端上也别分类 —— 用户关心的是"这次观影的照片",不是它的来源。
*/
private allPhotos(d: FbMovieDetail): string[] {
const out: string[] = [];
if (d.ticketKey !== '') {
out.push(d.ticketKey);
}
for (const k of d.photos) {
out.push(k);
}
return out;
}
/** 打开全屏查看器(点缩略图时) */
private openViewer(key: string): void {
const list: string[] = this.allPhotos(this.detail !== null
? this.detail : this.emptyForViewer());
const idx: number = list.indexOf(key);
if (idx < 0) {
return;
}
this.viewerList = list;
this.viewerIndex = idx;
this.viewerOpen = true;
}
/** 查看器用的空详情(列表为空时 openViewer 不会走到,仅类型需要) */
private emptyForViewer(): FbMovieDetail {
return {
ok: false, error: '', id: 0, title: '', startsAt: '', endsAt: '', note: '',
info: '', posterKey: '', ticketKey: '', cinemaName: '', cinemaPlace: '',
cinemaAddress: '', cinemaColor: '', myRating: 0, myReview: '',
reviewCount: 0, reviewAvg: 0, attendees: [], photos: []
};
}
/** 上一张 / 下一张(**循环**:到末张再往后回到第一张) */
private viewerStep(delta: number): void {
const n: number = this.viewerList.length;
if (n === 0) {
return;
}
// ⚠️ 必须取模归一到 [0,n):直接加会让 index 越界 / 变成负数
this.viewerIndex = ((this.viewerIndex + delta) % n + n) % n;
}
/**
* 时间显示:'YYYY-MM-DD HH:mm'(去掉秒)。
* ⚠️ 之前这里写错过一次(把小时当分钟取),时用 substring(11,13)。
*/
private shortTime(v: string): string {
if (v.length < 16) {
return v;
}
return v.substring(0, 16);
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
// 🔴 用 Stack 把"浮层内容"和"全屏查看器"叠起来:
// 查看器要盖住整个浮层(含它自己的圆角/把手),所以必须是
// **同一个 Stack 里的后声明节点**,不能放进浮层内部的 Column
// (那会被浮层的圆角裁剪、也会被内层 Scroll 的滚动带走)。
Stack({ alignContent: Alignment.Center }) {
Column({ space: 0 }) {
this.sheetHeader()
this.sheetBody()
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
// ⚠️ 同样用 `Default`(不写)。`Block` 会挡住浮层内部的子节点
// (照片缩略图、关闭按钮都点不动)。
// `Default` 已足够:浮层在遮罩之后声明 ⇒ 是兄弟节点、会被优先命中。
// ---- 全屏大图查看器 ----
if (this.viewerOpen) {
this.viewer()
}
}
.width('100%')
// ⚠️ 高度随内容自适应(浮层是"底部抽屉"形态)。
// 🔴 但**查看器打开时必须撑满**:查看器要盖住整屏,
// 如果这里还是内容高度,查看器会被裁在一小块里,
// 而且**它没盖住的地方点击会穿透到外层的遮罩** ——
// 表现就是"点查看器的 ✕,结果把详情浮层也一起关了"
// ( 2026-10-12 实测到的 bug)。
.height(this.viewerOpen ? '100%' : undefined)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
/** 浮层顶部:把手 + 关闭 */
@Builder
sheetHeader() {
Row() {
// 把手(视觉提示"可以关掉")
Column()
.width(36)
.height(4)
.backgroundColor(FbColors.line2)
.borderRadius(2)
Blank()
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 8, bottom: 4 })
}
/** 浮层主体(三种态) */
@Builder
sheetBody() {
Column({ space: 0 }) {
if (this.error !== '') {
// 失败:明确告诉用户,别留白
Column({ space: 8 }) {
Text('详情没能加载出来')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text(this.error)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(3)
.textAlign(TextAlign.Center)
}
.width('100%')
.padding({ top: 40, bottom: 40 })
.justifyContent(FlexAlign.Center)
} else if (this.loading || this.detail === null) {
Row() {
Text('正在加载详情…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 40, bottom: 40 })
.justifyContent(FlexAlign.Center)
} else {
this.body(this.detail)
}
}
.width('100%')
}
/**
* 全屏大图查看器。
*
* 交互(对齐 Web 端灯箱):
* · 点背景关闭;
* · 左右两个按钮翻页(**循环**);
* · 底部显示"第 n / N 张"。
*
* ⚠️ 图片用 `ImageFit.Contain` 而不是 `Cover`:
* 查看器的目的是"看清整张图",裁掉边缘就失去意义了。
* (缩略图用 Cover 保持整齐,大图用 Contain 保证完整 —— 两者目的不同。)
*/
@Builder
viewer() {
// 🔴 必须用一个容器把查看器的所有零件**包在一层里**。
// `@Builder` 里平铺多个节点时,它们会被依次挂到**父容器**(浮层的
// Stack)上,各自独立参与布局 —— 结果是遮罩/图/控制条/关闭按钮
// 全都变成浮层 Stack 的兄弟节点,位置靠 position/margin 硬凑,
// 点击区域互相错位(2026-10-12 实测:点 ✕ 会连带关掉详情浮层)。
// ⇒ 外层一个 Stack 包住,内部用 alignContent 定位,才是正确写法。
Stack({ alignContent: Alignment.Center }) {
// ① 全屏背景(点它关闭)
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000E6')
.onClick((): void => {
this.viewerOpen = false;
})
// ② 大图
// ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图",
// 裁掉边缘就失去意义(缩略图才用 Cover)。
Image(fbImageUrl(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
.width('100%')
.height('76%')
.objectFit(ImageFit.Contain)
// ③ 底部控制条(靠下对齐)
Row({ space: 18 }) {
Text('‹ 上一张')
.fontSize(FbMetric.fontSmall)
.fontColor('#FFFFFFCC')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.viewerStep(-1);
})
Text((this.viewerIndex + 1).toString() + ' / ' + this.viewerList.length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFF99')
Text('下一张 ›')
.fontSize(FbMetric.fontSmall)
.fontColor('#FFFFFFCC')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.viewerStep(1);
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
// ⚠️ 用 `align` 把它压到 Stack 底部,而不是靠 margin 推
.align(Alignment.Bottom)
.margin({ bottom: 40 })
// ④ 右上角关闭(比"点背景"更明确)
// ⚠️ 必须留在 Stack 里当子节点(不能平铺出去)——
// 平铺会跑到浮层 Stack 上,position 相对的是**浮层**而不是查看器。
Column({ space: 2 }) {
Text('✕')
.fontSize(FbMetric.fontTitle)
.fontColor('#FFFFFFCC')
Text('关闭')
.fontSize(9)
.fontColor('#FFFFFF88')
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.align(Alignment.TopEnd)
.onClick((): void => {
this.viewerOpen = false;
})
}
.width('100%')
.height('100%')
// 🔴 事件拦截取 **`Default`(默认值,不写就行)**,**不能用 `Block`**。
// 🔴 SDK 原文(`component/enums.d.ts`)说得很清楚:
// · `Default` —— 自身和**子节点**响应,阻挡**兄弟节点**,不影响祖先;
// · `Block` —— 自身响应,**阻挡子节点**、兄弟、祖先;
// · `Transparent` —— 自身和子节点响应,**不阻挡**兄弟和祖先。
// 需求是"查看器挡住下层遮罩(兄弟),但里面的翻页/关闭按钮要能点(子节点)"
// ⇒ 正是 `Default`。
// ⚠️ 我一开始用了 `Block`,结果**子节点(缩略图/翻页按钮)全部点不动**
// —— 2026-10-12 实测:点缩略图毫无反应,查 SDK 注释才发现 Block 会挡子节点。
//
// ⚠️ 这里**不额外加 onClick**:加了会让"点查看器任意处"都关掉查看器,
// 而遮罩层(子节点之一)已经负责"点空白关闭",两者会打架。
// 靠 `Default` + 遮罩子节点即可:遮罩铺满、点它关闭;
// 翻页/关闭按钮在遮罩**之后**声明,层级更高、优先响应。
}
/** 成功态的主体 */
@Builder
body(d: FbMovieDetail) {
Scroll() {
Column({ space: 10 }) {
// ---- 海报(大图,2:3)----
if (d.posterKey !== '') {
Image(fbImageUrl(d.posterKey, FB_TOKEN_STORE.get()))
.width(140)
.aspectRatio(0.75)
.objectFit(ImageFit.Cover)
.borderRadius(FbMetric.radiusS)
.alt($r('app.media.fb_ic_movies'))
}
// ---- 片名 ----
Text(d.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.textAlign(TextAlign.Center)
// ---- 时间 ----
Text(this.shortTime(d.startsAt) +
(d.endsAt !== '' ? ' — ' + this.shortTime(d.endsAt).substring(11) : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
// ---- 评分(来自列表项,见 FbMovieRepo.detail 说明)----
if (this.listReviewCount > 0) {
Row({ space: 6 }) {
FbRating({ score: this.listRating, max: 10 })
Text(this.listRating.toFixed(1).toString() + ' 分 · ' +
this.listReviewCount.toString() + ' 条评价')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(VerticalAlign.Center)
}
// ---- 影院 ----
if (d.cinemaName !== '') {
Column({ space: 4 }) {
Row({ space: 6 }) {
Column()
.width(6)
.height(6)
.backgroundColor(d.cinemaColor !== '' ? d.cinemaColor : FbColors.muted)
.borderRadius(3)
Text(d.cinemaName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
}
.alignItems(VerticalAlign.Center)
if (d.cinemaAddress !== '') {
Text(d.cinemaAddress)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
// ---- 同看的人 ----
if (d.attendees.length > 0) {
Column({ space: 6 }) {
Text('同看的人')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 10 }) {
ForEach(d.attendees, (a: FbMovieAttendeeRow) => {
Column({ space: 3 }) {
FbAvatar({
name: a.myNickname !== '' ? a.myNickname : a.name,
// ⚠️ 用接口给的头像色(`avatar_color`);没有才退回灰。
// 别写死一个灰 —— 那样所有人的头像都是同一个颜色。
color: a.avatarColor !== undefined && a.avatarColor !== ''
? a.avatarColor : '#A84A28',
dia: 34
})
Text(a.myNickname !== '' ? a.myNickname : a.name)
.fontSize(10)
.fontColor(FbColors.muted)
.maxLines(1)
}
}, (a: FbMovieAttendeeRow): string => 'att_' + a.id.toString())
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
// ---- 剧情简介 ----
if (d.info !== '') {
Column({ space: 6 }) {
Text('简介')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(d.info)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
// ⚠️ 不设 maxLines:简介本来就长,让用户滚着看全文
.textAlign(TextAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
// ---- 备注 ----
if (d.note !== '') {
Column({ space: 6 }) {
Text('备注')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(d.note)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
// ---- 照片(票根 + 现场照片合成一块,与 Web 端一致)----
if (this.allPhotos(d).length > 0) {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text('观影照片')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(this.allPhotos(d).length.toString() + ' 张')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(VerticalAlign.Center)
Grid() {
ForEach(this.allPhotos(d), (k: string) => {
GridItem() {
// 🔴 缩略图必须按**照片真实比例**做格子。
// 实测线上照片是 **270×377(竖版,比例 ≈0.716)**,
// 而第一版用的是**正方形格子**(GridItem 没有固定高度,
// 由 Grid 行高决定)⇒ 竖版照片被 `Cover` 裁掉上下大半,
// "看起来格式不对"。用户 2026-10-12 反馈的就是这个。
Column() {
Image(fbImageUrl(k, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
}
.width('100%')
// ⚠️ 用 `aspectRatio` 指定比例,而不是靠父容器推:
// 在 Grid 里 `height('100%')` 拿不到确定的高度
// (行高是内容决定的),必须显式给比例。
.aspectRatio(FB_PHOTO_RATIO)
.clip(true)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.openViewer(k);
})
}
}, (k: string): string => 'ph_' + k)
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(6)
.rowsGap(6)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 4, bottom: 28 })
.alignItems(HorizontalAlign.Center)
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,481 @@
/**
* 家书 FamilyBook · 人员详情浮层(鸿蒙端)
*
* 点名册里的一个人 → 弹出他的完整资料(头像 / 姓名 / 称谓 / 单位职务 /
* 手机 / 生日(阳历+农历)/ 邮箱 / 地址 / 座右铭 / 标签 / 备注)。
*
* 🔴 为什么做成"浮层"而不是新页面:
* 与观影的详情浮层同一个理由(见 `FbMovieDetailSheet` 的文件头):
* · 用户在名册里"随手点开看看",看完就关 —— 跳页面会丢失列表的滚动位置,
* 回来还得重新找;
* · 浮层是**页面内的覆盖层**,不动导航栈,关掉即回到原处。
* ⚠️ 但它**不是**系统弹窗(`bindSheet`/`AlertDialog`)——
* 那些会带系统边框与动画,且塞不进我们要的自定义布局。
*
* 数据来源(🔴 v52 重写,用户 2026-10-15):
* 用户要求「**完全脱离电脑端**,所有信息都显示出来」⇒ 详情不再"用列表那份凑合":
* · 列表(`/api/v1/roster`)只给画一栏要用的字段;
* · 详情**单独拉 `GET /api/v1/people/<id>`** —— 它多带
* **工作经历 / 补充联系方式(按类型)/ 私人备注 / 结构化地址五级 /
* 身份证(打码) / 头像 URL / 能不能编辑**。
* ⚠️ 旧注释里写"`/people/<id>` 字段并不比 roster 多" —— 那是当时的事实,
* v52 起**已经不成立**(服务端这次把详情接口补全了)。
* ⚠️ 断开网络时退回"用列表里那份"(`person` 本来就是列表那份),
* 只是缺那几项详情专属字段,界面不会空。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
import { FbAvatar, FbTag } from './Widgets';
import { fbImageUrl } from '../data/FbHttp';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
/**
* 贴底卡片的手势区缓冲(vp)。
*
* 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)——
* 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段,
* 否则最后一排按钮会被"从下往上滑"误触。
*/
const FB_SHEET_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbPersonSheet {
/** 要看的人(null = 没打开) */
@Param person: FbRosterPerson | null = null;
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
/**
* 「编辑」回调(用户要求:悬浮卡片上要有一个"编辑")。
*
* ⚠️ 这里**只发事件、不自己做编辑** —— 编辑是另一个浮层(`FbPersonEditSheet`),
* 由页面决定"关掉详情、打开编辑"。浮层之间不互相引用,
* 免得出现"详情嵌编辑、编辑又嵌详情"的循环。
*/
@Event onEdit: () => void = (): void => {
};
/** 一行的图标资源 */
private iconOf(name: string): Resource {
switch (name) {
case 'phone': return $r('app.media.fb_ic_user');
case 'org': return $r('app.media.fb_ic_people');
case 'birth': return $r('app.media.fb_ic_days');
case 'mail': return $r('app.media.fb_ic_notes');
case 'addr': return $r('app.media.fb_ic_resources');
case 'card': return $r('app.media.fb_ic_notes');
default: return $r('app.media.fb_ic_dot');
}
}
/**
* 生日合并成一行:`阳历 11-16(农历 十月初五)`。
*
* ⚠️ 阳历/农历**分开两个字段**,但并成一行更好读(两行都只有月日、很短)。
* 只有农历没阳历时也能显示。
*/
private birthdayLine(): string {
if (this.person === null) {
return '';
}
const solar: string = this.person.birthdaySolar;
const lunar: string = this.person.birthdayLunar;
if (solar !== '' && lunar !== '') {
return solar + '(农历 ' + lunar + ')';
}
if (solar !== '') {
return solar;
}
if (lunar !== '') {
return '农历 ' + lunar;
}
return '';
}
/**
* 多行文本块(标题 + 正文)。
*
* ⚠️ 为什么不用 `infoRow`:工作经历/地址/备注这类**多行等宽信息**
* 配一个小图标反而挤,标题一行、正文一段更清楚。
*/
@Builder
blockText(label: string, body: string) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(body)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.lineHeight(21)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 12 })
}
/** 工作经历:每段一行(单位|职务 · 职称(起止)) */
@Builder
jobsBlock() {
Column({ space: 6 }) {
Text('工作经历')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
if (this.person !== null) {
ForEach(this.person.jobs, (j: FbJobRow, idx: number) => {
Column({ space: 2 }) {
Text(this.jobText(j))
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
if (j.note !== '') {
Text(j.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}, (j: FbJobRow, idx: number): string => 'job_' + idx.toString())
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 12 })
}
/**
* 除主号之外的其它电话(逗号拼接)。
*
* ⚠️ `phones[0]` 是主号,已经在「手机号」那一行单独显示过 ⇒ 这里从下标 1 开始,
* 否则同一个号码会显示两遍。
* ⚠️ 服务端已保证"主号在前、去重",这里不再做去重(保持简单)。
*/
private otherPhones(): string {
if (this.person === null) {
return '';
}
const out: string[] = [];
for (let i = 1; i < this.person.phones.length; i++) {
out.push(this.person.phones[i]);
}
return out.join('、');
}
/**
* 头像图 URL(**空 = 用色块字回落**)。
*
* 🔴🔴 v52 修的真 bug(用户 2026-10-15:「点击详情,弹出的卡片里面**为什么没有头像**呢?」):
* 列表里画了一整天的头像,**详情浮层却没传 `imageUrl`** ——
* `FbAvatar` 的 `imageUrl` 缺省是空串,于是**所有人在详情里都退化成色块字**
* (有头像的人也显示不出来)。这是纯粹的漏传参数,不是数据问题。
* ⚠️ `avatar_url`(服务端下发的那个)是**相对路径**,`Image()` 直接用会
* 打到 App 自己的包名上 ⇒ 必须用 `fbImageUrl(key, token)` 拼成
* 带令牌的绝对地址(与列表同一套,见 `fbImageUrl` 的说明)。
*/
private avatarUrl(): string {
if (this.person === null) {
return '';
}
return fbImageUrl(this.person.avatar, FB_TOKEN_STORE.get());
}
/** 拼多行联系方式的显示串(`label: value`,label 空就只显示 value) */
private contactText(rows: FbContactRow[]): string {
const out: string[] = [];
for (const r of rows) {
out.push(r.label !== '' ? (r.label + ':' + r.value) : r.value);
}
return out.join('\n');
}
/** 地址一整行(国家 省 市 区 + 详细;空段自动跳过) */
private fullAddress(): string {
if (this.person === null) {
return '';
}
const parts: string[] = [
this.person.addrCountry, this.person.addrProvince,
this.person.addrCity, this.person.addrDistrict
];
const out: string[] = [];
for (const s of parts) {
if (s !== '') {
out.push(s);
}
}
if (this.person.address !== '') {
out.push(this.person.address);
}
return out.join(' ');
}
/** 一段工作经历的显示串(`单位 · 职务/职称(起止)`) */
private jobText(j: FbJobRow): string {
const head: string[] = [];
if (j.org !== '') {
head.push(j.org);
}
// 职务与职称可能都有("副院长 / 副教授"),都可能没有
const roles: string[] = [];
if (j.title !== '') {
roles.push(j.title);
}
if (j.rankName !== '' && j.rankName !== j.title) {
roles.push(j.rankName);
}
if (roles.length > 0) {
head.push(roles.join(' · '));
}
let line: string = head.join('|');
const span: string = this.jobSpan(j);
if (span !== '') {
line += '(' + span + ')';
}
return line;
}
/** 一段工作经历的起止(`2020-09 至今` / `2015-07 ~ 2020-08`) */
private jobSpan(j: FbJobRow): string {
const start: string = j.startYm;
const end: string = j.endYm !== '' ? j.endYm : '至今';
if (start === '' && j.endYm === '') {
return '';
}
if (start === '') {
return end;
}
return start + ' ~ ' + end;
}
/**
* 信息行(图标 + 标签 + 值)。
*
* ⚠️ `@Builder` 里**不加返回值**、参数要显式类型 —— 见项目里 ArkTS 约束。
*/
@Builder
infoRow(icon: string, label: string, value: string) {
Row({ space: 10 }) {
Image(this.iconOf(icon))
.width(15)
.height(15)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Column({ space: 2 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Top)
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
Column({ space: 0 }) {
// ---- 顶部:头像 + 姓名 + 称谓 ----
// ⚠️ 这一块**不参与滚动**(它跟着卡片固定在顶部):
// 用户往下滑看资料时,"这是谁"始终可见,不用滚回去确认。
Row({ space: 14 }) {
FbAvatar({
name: this.person !== null ? this.person.shown : '',
color: this.person !== null ? this.person.avatarColor : '#A84A28',
dia: 60,
fontSizeVp: 24,
// 🔴 v52 补上(之前漏传 ⇒ 详情里永远看不到头像,见 avatarUrl 的说明)
imageUrl: this.avatarUrl()
})
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(this.person !== null ? this.person.shown : '')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
if (this.person !== null && this.person.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
}
.alignItems(VerticalAlign.Center)
// 本名(昵称与不同名时才有意义)
if (this.person !== null && this.person.name !== this.person.shown) {
Text('本名 ' + this.person.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
// 称谓(服务端按家族图谱推导)
if (this.person !== null && this.person.label !== '') {
FbTag({ text: this.person.label, tone: 'muted' })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 标签 ----
// ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的
// 信息,跟着资料一起滚走反而不方便。详情里的"长内容"是资料行(见下面)。
if (this.person !== null && this.person.tags.length > 0) {
Row({ space: 6 }) {
ForEach(this.person.tags, (t: string) => {
FbTag({ text: t, tone: 'blue' })
}, (t: string): string => 'pt_' + t)
}
.width('100%')
.margin({ top: 12 })
}
Divider()
.color(FbColors.line)
.margin({ top: 14, bottom: 4 })
// 🔴 **资料区可滚动**(用户 2026-10-15:「显示详细信息,并且可以往下滑」)。
//
// 为什么必须给它一个 `Scroll`:
// 卡片整体限了 `maxHeight: 78%`,而资料行(手机/单位/职务/生日阴阳历/
// 邮箱/地址/座右铭/备注)加起来很容易超过这个高度 ——
// 没有 `Scroll` 时**超出的部分直接被裁掉、看不到也够不着**。
// ⚠️ 用 `layoutWeight(1)` 让 Scroll 吃掉"头部与底部按钮之间的剩余高度",
// 而不是给固定高度 —— 内容长短不同时都能正好铺满。
// ⚠️ 底部按钮**留在 Scroll 外面**:它们是"动作",不该跟着内容滚走
// (要滑到底才能点"编辑"是很糟的体验)。
Scroll() {
Column({ space: 0 }) {
// ---- 资料 ----
// ⚠️ 每条都**只在有值时显示** —— 空行会让浮层显得"很多没填",
// 而实际上大部分人的手机/邮箱/地址本来就是空的。
if (this.person !== null && this.person.phone !== '') {
this.infoRow('phone', '手机号', this.person.phone)
}
// 🔴 补充电话(服务端下发的 `phones`,主号已在上面单独显示)
if (this.person !== null && this.person.phones.length > 1) {
this.infoRow('phone', '其他电话', this.otherPhones())
}
if (this.person !== null && this.person.org !== '') {
this.infoRow('org', '单位', this.person.org)
}
if (this.person !== null && this.person.title !== '') {
this.infoRow('org', '职务', this.person.title)
}
// 家中排行(只在有值时显示;1 = 老大)
if (this.person !== null && this.person.birthRank !== '') {
this.infoRow('org', '家中排行', '第 ' + this.person.birthRank + ' 个')
}
// 生日:阳历 + 农历(农历是服务端算的)—— 合并成一行显示更好读
if (this.person !== null && this.birthdayLine() !== '') {
this.infoRow('birth', '生日', this.birthdayLine())
}
if (this.person !== null && this.person.email !== '') {
this.infoRow('mail', '邮箱', this.person.email)
}
// 地址(结构化五级 + 详细;旧数据只有 `addrCity` 也能显示)
if (this.person !== null && this.fullAddress() !== '') {
this.infoRow('addr', '地址', this.fullAddress())
}
// 身份证(打码)—— 只显示,编辑时可改
if (this.person !== null && this.person.idCardMasked !== '') {
this.infoRow('card', '身份证', this.person.idCardMasked)
}
// ---- 补充联系方式(vCard 对齐:邮箱/地址/网址,可多行)----
if (this.person !== null && this.person.contacts.email.length > 0) {
this.blockText('其它邮箱', this.contactText(this.person.contacts.email))
}
if (this.person !== null && this.person.contacts.address.length > 0) {
this.blockText('其它地址', this.contactText(this.person.contacts.address))
}
if (this.person !== null && this.person.contacts.url.length > 0) {
this.blockText('网址', this.contactText(this.person.contacts.url))
}
// ---- 工作经历(可多段)----
if (this.person !== null && this.person.jobs.length > 0) {
this.jobsBlock()
}
// ---- 座右铭 / 公共备注 / 我的私人备注 ----
if (this.person !== null && this.person.slogan !== '') {
this.blockText('座右铭', '「' + this.person.slogan + '」')
}
if (this.person !== null && this.person.note !== '') {
this.blockText('备注', this.person.note)
}
// 🔴 「我的私人备注」与公共"备注"**是两块**(只有我看得到)
if (this.person !== null && this.person.myNote !== '') {
this.blockText('我的备注(仅自己可见)', this.person.myNote)
}
// 底部一点余量:最后一行不贴着按钮
Row().height(8)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// ---- 底部按钮:编辑 / 关闭 ----
Row({ space: 10 }) {
Text('编辑')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor('#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onEdit();
})
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.margin({ top: 18 })
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边(92%)、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
// 最多占屏幕 72%:上方留出可见列表,暗示"这是浮层、下面还有内容"
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,58 @@
/**
* 家书 FamilyBook · 概述卡里的「一格数字」
*
* 🔴🔴 v54b(用户 2026-10-17)—— 为什么必须是一个**组件**而不是 `@Builder`:
*
* 用户原话:「概述卡高度正常了,但是里面的**日程、在做、待办的数量是错误的**。」
*
* 真机实测(`uitest dumpLayout` + 直读端上缓存):
* · 接口返回 `stat:{today_events:1, doing:3, todo:2, overdue:0}` —— **正确**;
* · 端上 `module_cache` 里存的那份 payload **也是这份正确数据**;
* · 可界面显示的是 `0 / 0 / 0`。
* 而**同一份数据**里「今天的日程 1 条」显示是对的 —— 区别在于
* 那一块走的是**组件**(`FbDayEventPanel`),而数字走的是
* `FbStatTriple` 内部的 `@Builder cell(label, value, alarm)`。
*
* 🔴 根因:SDK 规则 `r/quick-rules/05-state.md` → 「@Builder 参数传递」:
* > **两个及以上参数不触发刷新**(多参数、按值与按引用混用均不刷新)
* ⇒ `cell()` 那份子树只按**初始值**渲染了一次(那时 `stat` 还是默认的 0),
* 后来数据回来、`build()` 重跑,但**这个多值 @Builder 的子树不重建**。
*
* ⇒ 修法:抽成 `@ComponentV2` + `@Param`(**@Param 参与观察**)。
* 本项目已在同一类问题上修过 5 次,这是第 6 次:
* `FbTagChip` → `FbCalOptionRow` → `FbDayEventPanel` → `FbEventChip` → `FbTagPickChip`。
*
* ⚠️ **不要**改回 `@Builder`(哪怕只有一个参数也别用:见本组件底部那条)。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbStatCell {
/** 数字(**@Param 参与观察** —— 父组件改了它,本格就重建) */
@Param value: number = 0;
/** 下面的标签(`今天日程` / `在做` / `待办` / `逾期`) */
@Param label: string = '';
/**
* 这一格是"警示项"(逾期)。
*
* ⚠️ 与网页版 `hb-stats .hbs.is-late` 同一取舍:**只有真的 > 0 时才红**。
* 0 件逾期是常态,天天标红会让"逾期"这件事失去分量。
*/
@Param alarm: boolean = false;
build() {
Column({ space: 2 }) {
Text(this.value.toString())
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(this.alarm && this.value > 0 ? FbColors.red : FbColors.ink)
Text(this.label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
}
@@ -0,0 +1,59 @@
/**
* 家书 FamilyBook · 标签筛选片(鸿蒙端)
*
* 🔴🔴 **为什么必须做成"组件"而不是 `@Builder`**(这是真机踩出来的坑):
* 用户 2026-10-15:「点'大学同学',其实'大学同学'这个标签并没有发生改变,
* 其他标签也是一样的。」
*
* 根因:标签片原来是 `FbPeoplePage.tagChip(label, active, count)` 这样的
* **`@Builder` + 简单类型参数**。而在 ArkTS 里:
* `@Builder` 的**值类型参数**(string / number / boolean / 数组/对象字面量)
* **不参与状态观察** —— 父组件里 `active` 的计算结果变了,
* 已经建好的那份 Builder 子树**不会重建**(只有对象引用变化才会)。
* ⇒ 点标签后 `tagFilter` 确实变了、列表也筛了,
* 但**标签片自己的选中样式(蓝底白字)不变** = 用户看到的"标签没变化"。
*
* 解法:做成 `@ComponentV2` 组件,用 `@Param` 接收 ——
* `@Param` **是参与观察的**,父组件传值变了就会重建这一片。
*
* ⚠️ 这也是本项目第一次把"列表里的可选中片"从 Builder 改成组件;
* 其它地方(如看板筛选条 `boardChip`)若也有同样症状,同样处理。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbTagChip {
/** 显示文字(标签名;「全部」传 '全部') */
@Param label: string = '';
/** 是否选中(**必须由父组件按 tagFilter 算好传进来**) */
@Param active: boolean = false;
/**
* 人数;**传 -1 表示不显示数字**(「全部」用它 ——
* 全部的人数等于名册总人数,写出来反而占地方且没信息量)。
*/
@Param count: number = -1;
/** 被点击 */
@Event onPick: () => void = (): void => {
};
build() {
Row({ space: 4 }) {
Text(this.label)
.fontSize(FbMetric.fontTiny)
.fontColor(this.active ? Color.White : FbColors.muted)
if (this.count >= 0) {
Text(this.count.toString())
.fontSize(10)
.fontColor(this.active ? '#FFFFFFCC' : FbColors.faint)
}
}
.padding({ left: 11, right: 11, top: 6, bottom: 6 })
.backgroundColor(this.active ? '#4A7C82' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onPick();
})
}
}
@@ -0,0 +1,52 @@
/**
* 家书 FamilyBook · 标签多选片(鸿蒙端)
*
* 🔴 用户 2026-10-16:「名册里面编辑的时候,或者添加的时候,
* 人的标签**应该是选择的**吧?应该让人**选择多个**,
* 而不是直接在这里输入文本。」
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`:
* **`@Builder` 的值类型参数不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`)——
* 点一下 `active` 变了,但**选中样式那份子树不重建** ⇒ 看着"点不了"。
* 本项目已在 `FbTagChip` / `FbCalOptionRow` / `FbEventChip` / `FbDayEventPanel`
* 上各踩过一次(这是第 5 次同类)⇒ **有选中态的片一律做成组件**。
*
* ⚠️ 与名册页那个 `FbTagChip` 的区别:那个是**筛选**(单选、带人数),
* 这个是**编辑多选**(没有人数、可取消)。两个语义不同,故不复用。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbTagPickChip {
/** 标签文字 */
@Param label: string = '';
/** 是否已选(**由父组件算好传进来**) */
@Param active: boolean = false;
/** 点一下切换选中 */
@Event onToggle: () => void = (): void => {
};
build() {
Row({ space: 4 }) {
if (this.active) {
Text('✓')
.fontSize(FbMetric.fontTiny)
.fontColor(Color.White)
}
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.active ? Color.White : FbColors.muted)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.active ? '#4A7C82' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
// ⚠️ 间距放组件内部:wrap 容器里的子项必须自带 margin,否则两行会贴在一起
.margin({ right: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onToggle();
})
}
}
+234 -51
View File
@@ -8,33 +8,69 @@
*
* 🔴 形态照**鸿蒙 6 官方设计规范**的「悬浮导航(Float Navigation)」:
* · 脱离底部边界,以「**四周留白 + 圆角卡片**」形态**悬浮在内容之上**;
* · 参考实现参数:`height(56) + margin({left:16,right:16,bottom:16}) + borderRadius(28)`;
* · 材质用系统提供的沉浸材质(`uiMaterial.ImmersiveMaterial`,
* `ImmersiveStyle.ULTRA_THIN` 对应设计规范里的"超薄"档),
* 它自带模糊/边框/阴影 —— ⚠️ 用了它**不要**再叠加 backgroundBlurStyle,
* 否则会双重模糊、发灰。
* · 与系统手势条保持间距,减少误触(所以就 `bottom:16` 而不是贴底)。
* · 参考实现参数:`height(56) + 四周留白 + borderRadius(28)`;
* · 与系统手势条保持间距,减少误触(所以 `bottom:16` 而不是贴底)。
*
* 🔴 两处按用户实机反馈调过(2026-10-12):
* · **宽度收窄**:「太宽了,不要顶到两边」⇒ 左右留白 40vp(比内容区块的 16vp 更收)。
* ⚠️ 收窄靠 `margin`,**千万别再写 `.width('100%')`** —— 它会让 margin 失效、变满宽。
* · **降低透明度**:「透明度太高,下面的内容糊在一起看不清」⇒
* 底色改 92% 白(`EBFFFFFF`)+ `backgroundBlurStyle(COMPONENT_THIN)`。
*
* 🔴 为什么用 `backgroundBlurStyle` 而不用 `systemMaterial`(鸿蒙 6 的沉浸材质):
* `systemMaterial` / `uiMaterial` 是 **SDK 26.0.0** 起的新 API,
* 而本工程 `compatibleSdkVersion = 6.1.1(24)`(编译器会警告版本不匹配)
* ⇒ 在 API 24 设备上有失效风险。`backgroundBlurStyle` 是 **API 9** 就有的,
* 视觉几乎等价、且没有版本风险。**将来把兼容版本提到 26 再换回沉浸材质。**
*
* ⚠️ 为什么用 `Stack(alignContent: Bottom)` 覆盖在内容上,而不是在 Column 里占一行:
* 用户明确说"不要固定在下边,要浮着"。占一行就是"固定的底部栏"(老样式);
* 用 Stack 覆盖 ⇒ 内容从它下面穿过、它是浮在上面的。这是这个组件唯一正确的摆法。
*
* ⚠️ "隐藏时不影响观感":内容底部要留出 `DOCK_RESERVE` 的空白,
* ⚠️ 内容底部要留出 `FB_DOCK_RESERVE` 的空白,
* 否则最后一块内容会被悬浮栏压住(悬浮 ≠ 可以挡内容)。
*/
import { uiMaterial } from '@kit.ArkUI';
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
import { FbAppState, fbAppState } from '../common/Store';
import { FbModuleDef, fbModuleOf } from '../model/Modules';
/** 内容区为悬浮栏预留的高度(vp):栏高 56 + 下边距 16 + 一点余量 */
export const FB_DOCK_RESERVE: number = 88;
/** 快捷栏高度(vp)—— 取设计规范的 56 */
const DOCK_H: number = 56;
/** 四周留白(vp)—— 设计规范给的是 16 */
const DOCK_PAD: number = 16;
/**
* 下边距(vp)。
*
* 🔴 从 16 加到 **24**(2026-10-13 改"全沉浸"时)。
* 原因:系统**手势条已被隐藏**(用户要求"下面没有任何的条")——
* 手势区没有任何视觉提示了,悬浮栏贴得太近时,
* "从下往上滑唤回内容"这个手势很容易先按到它上面。
* 多加 8vp 拉开距离,是给**手势区**留的缓冲,不是视觉上的调整。
*/
const DOCK_PAD: number = 24;
/**
* 左右留白(vp)。
*
* 🔴 用户 2026-10-12:「宽度有点太宽了,不要顶到两边……
* 我们那个白色的那种背景框,它不都是两边都有空隙的吗?所以这个宽度也往里面收一收。」
* ⇒ 收得比内容区块的留边(16vp)更窄:40vp。
* 这样悬浮栏明显比内容窄一圈,「浮着的一块」的感觉更强,
* 也符合"页面里的卡片都不贴边"这套语言。
*/
const DOCK_SIDE: number = 40;
/** 内容区为悬浮栏预留的高度(vp):栏高 + 下边距 + 一点余量 */
export const FB_DOCK_RESERVE: number = DOCK_H + DOCK_PAD + 16;
/**
* 收起时向下平移的距离(vp)——**必须让整个胶囊完全移出可视区**。
*
* 🔴 供 `Index.ets` 做"随滚动滑走"的位移动画用(用户 2026-10-13 要的 IT 之家那套)。
* ⚠️ 导出来复用而不是在 `Index` 里写死一个数字:
* 它与栏高、下边距是**同一个几何量**,分散在两处必然漂移 ——
* 一旦下移量小于"高 + 边距",收起后底下会**露出半截胶囊边**。
* +12 是余量(阴影半径 18、偏移 6,留一点避免阴影边缘仍可见)。
*/
export const FB_DOCK_HIDE_Y: number = DOCK_H + DOCK_PAD + 12;
/**
* 悬浮快捷栏。
@@ -49,63 +85,143 @@ export struct FbFloatDock {
* 快捷模块 key 列表。
* 🔴 用户点名要这四个("应该比较常用"):日程、名册、任务、笔记。
* 顺序按用户说的来。
* ⚠️ v54b:这四项现在是**两两分居 logo 两侧**(日程 名册 [家书] 任务 笔记),
* 见下面 build 的左右两段。
*/
@Param keys: string[] = ['calendar', 'people', 'tasks', 'notes'];
build() {
// 🔴 用**两层**解决"收窄":外层 Row 负责左右留白,内层 Row 是胶囊本体。
//
// 为什么不用更直接的两个写法(都实测失败过):
// ① `.width('100%')` + `.margin({left,right})` 并存 ⇒ width 先占满、
// margin 失效 ⇒ 栏体顶到屏幕两侧(实测 x=0..1079 满宽);
// ② 去掉 width、只留 margin ⇒ **仍不够**:子项用了 `.layoutWeight(1)`
// 四等分,含 layoutWeight 子项的容器会撑满父级可用宽度,把 margin 顶开
// (实测留白只有 17vp,而不是设的 40vp)。
// ③ `calc(100% - 80vp)` 也不行 —— SDK 明确写着
// "**width** and **height** do not support **calc()** dynamic calculations"。
// ⇒ 外层 padding 是唯一稳稳生效的办法:外层通栏、只做左右内缩,
// 内层胶囊再用 layoutWeight(1) 吃满剩余宽度。语义清晰、不受子项布局影响。
Row() {
ForEach(this.keys, (k: string) => {
// 当前模块在快捷栏里高亮,让用户知道"我在哪"
this.dockItem(k, k === this.app.activeModule);
}, (k: string): string => 'dock_' + k)
Row() {
// ---- 左半:日程 / 名册 ----
ForEach(this.leftKeys(), (k: string) => {
FbDockItem({
icon: this.iconNameOf(k),
label: this.labelOf(k),
active: k === this.app.activeModule,
accent: this.accentOf(k),
onTap: (): void => {
this.app.switchModule(k);
}
})
}, (k: string): string => 'dock_l_' + k)
// ---- 正中间:**应用图标**(点它回首页)----
// 🔴 用户 2026-10-17:「下面悬浮的 4 个图标**中间**,放一个**我们应用的图标**,
// 就是那个 **logo**,点击之后**是首页**。」
// ⚠️ 用真实 logo 图(`fb_logo`,与顶栏同一张),**不要**换成 home 图标 ——
// 用户说的是"我们应用的图标/logo",那是品牌标识,不是"首页"这个功能图标。
// ⚠️ 也不加"首页"两个字:中间那格比两侧窄,加字会挤(它的语义由 logo 本身承担)。
this.homeButton()
// ---- 右半:任务 / 笔记 ----
ForEach(this.rightKeys(), (k: string) => {
FbDockItem({
icon: this.iconNameOf(k),
label: this.labelOf(k),
active: k === this.app.activeModule,
accent: this.accentOf(k),
onTap: (): void => {
this.app.switchModule(k);
}
})
}, (k: string): string => 'dock_r_' + k)
}
// 内层胶囊:吃满外层扣掉内缩后的宽度
.layoutWeight(1)
.height(DOCK_H)
.padding({ left: 6, right: 6 })
.borderRadius(DOCK_H / 2) // 全圆角胶囊形(28 = 56/2)
.alignItems(VerticalAlign.Center)
// ---- 悬浮底:白色 + 毛玻璃 ----
//
// 🔴 底色:**92% 白**。
// 用户反馈「透明度太高,下面的内容和标签糊在一起,看不清了」
// ⇒ 先把「读得清」这条底线守住:底色几乎实心,只留 8% 通透。
.backgroundColor('EBFFFFFF')
//
// 🔴 毛玻璃用 `backgroundBlurStyle(COMPONENT_THIN)`,**不用 `systemMaterial`**:
// · `systemMaterial` / `uiMaterial` 是 **SDK 26.0.0** 起的新 API,
// 而本工程 `compatibleSdkVersion = 6.1.1(24)` ⇒ 编译器会警告
// "current compatible SDK version is 6.1.1(24)",在 API 24 设备上有失效风险;
// · `backgroundBlurStyle` 是 **API 9** 就有的老 API,兼容性完全没问题,
// 且同样能做出"半透 + 模糊"的悬浮观感。
// ⇒ 在"视觉几乎等价"的前提下,选**没有版本风险**的那个。
// (将来工程把 compatibleSdkVersion 提到 26 再换回沉浸材质。)
.backgroundBlurStyle(BlurStyle.COMPONENT_THIN)
.border({ width: 1, color: FbColors.line2 })
// 浮在内容之上:给一点投影增强"悬浮"层次
.shadow({ radius: 18, color: '#1A2C231D', offsetY: 6 })
}
.width('100%')
.height(DOCK_H)
// 四周留白 ⇒ 悬浮感(不贴屏幕边)
.margin({ left: DOCK_PAD, right: DOCK_PAD, bottom: DOCK_PAD })
.padding({ left: 6, right: 6 })
.borderRadius(DOCK_H / 2) // 全圆角胶囊形(28 = 56/2)
.alignItems(VerticalAlign.Center)
// 🔴 悬浮材质(鸿蒙 6 设计规范的"超薄沉浸"档)。
// ⚠️ 这里**只**用 systemMaterial,不加 backgroundColor/backgroundBlurStyle ——
// 材质自带模糊与描边,叠加会双重模糊。
// ⚠️ 若设备/版本不支持该 API,会在运行时被忽略(不崩),
// 所以下面再给一层极浅的描边兜底,保证"有边界的悬浮卡片"这个观感不丢。
.systemMaterial(new uiMaterial.ImmersiveMaterial({
style: uiMaterial.ImmersiveStyle.ULTRA_THIN
}))
.border({ width: 1, color: FbColors.line2 })
// 浮在内容之上:给一点投影增强"悬浮"层次
.shadow({ radius: 18, color: '#1A2C231D', offsetY: 6 })
// 🔴 左右内缩 40vp(比内容区块的 16vp 更收):
// 用户要求「宽度有点太宽了,不要顶到两边……也往里面收一收」。
.padding({ left: DOCK_SIDE, right: DOCK_SIDE, bottom: DOCK_PAD })
}
/** 一个快捷项:图标 + 文字(竖排,紧凑) */
/**
* 中间那颗 logo(点它回首页)。
*
* ⚠️ 宽度给 `layoutWeight` 之外还要**略窄一点**:两侧各有 2 个带文字的项,
* 中间这格只有图、没有字,等宽会让它显得很空。
* 用固定宽 + `justifyContent(Center)`,视觉上就是个"居中的品牌钉"。
*/
@Builder
dockItem(key: string, active: boolean) {
homeButton() {
Column({ space: 2 }) {
Image(FbIcon.ui(this.iconNameOf(key)))
.width(20)
.height(20)
.renderMode(ImageRenderMode.Template)
// ⚠️ fillColor 必须是同一类型:选中时用模块色(string),
// 未选中时用 `$r` 资源也可以,但**三目里两侧类型必须一致**
// (Resource 与 string 混用会报 "Type 'string | Resource' is not assignable to type 'string'")。
// 这里统一走 string 色值,省掉类型打架。
.fillColor(active ? this.accentOf(key) : '#7E6B5A')
Text(this.labelOf(key))
Image($r('app.media.fb_logo'))
.width(26)
.height(26)
.borderRadius(7)
// 描边:logo 是「米色底 + 砖红图案」,放在白底胶囊上底板会"化掉"
//(与顶栏那颗 logo 同一个理由,见 TopBar 的注释)
.border({ width: 1, color: FbColors.line })
// 当前就在首页 ⇒ 给一行小字标出来(与两侧项的"图标+文字"节奏也对齐)
Text('首页')
.fontSize(FbMetric.fontTiny)
.fontColor(active ? this.accentOf(key) : '#7E6B5A')
.fontColor(this.app.activeModule === 'home' ? '#A84A28' : '#4A3F35')
.maxLines(1)
}
.layoutWeight(1) // 四等分
.width(52)
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
// 切模块(switchModule 内部会顺手收起抽屉)
this.app.switchModule(key);
.onClick((): void => {
this.app.switchModule('home');
})
}
/** 左半段的 key(前一半;四项时 = 前 2 个) */
private leftKeys(): string[] {
const n: number = Math.floor(this.keys.length / 2);
const out: string[] = [];
for (let i = 0; i < n; i++) {
out.push(this.keys[i]);
}
return out;
}
/** 右半段的 key(后一半) */
private rightKeys(): string[] {
const n: number = Math.floor(this.keys.length / 2);
const out: string[] = [];
for (let i = n; i < this.keys.length; i++) {
out.push(this.keys[i]);
}
return out;
}
/** 模块名(取注册中心,避免各处写死) */
private labelOf(key: string): string {
const m: FbModuleDef | undefined = fbModuleOf(key);
@@ -142,4 +258,71 @@ export struct FbFloatDock {
default: return 'home';
}
}
}
/**
* 悬浮栏里的一个快捷项(图标 + 文字,竖排,紧凑)。
*
* 🔴🔴 v54b(用户 2026-10-17)—— 为什么必须是**组件**:
* 用户原话:「日程、名册、任务、笔记这几个图标,应该点击后,在它们自己的页面中,
* **是变颜色的**,颜色就是它们自己的设定颜色,**图标和文字,都变颜色**。」
* —— 也就是说:点了「日程」进日程页后,那颗图标与文字应当变成**日程的琥珀色**。
*
* ⚠️ 原来这里是 `@Builder dockItem(key, active)` —— **两个参数**,
* SDK 规则 `r/quick-rules/05-state.md`:
* > **两个及以上参数不触发刷新**
* ⇒ 切模块后 `this.app.activeModule` 变了、`build()` 重跑了,
* 但**这个多值 @Builder 的子树不重建** ⇒ 高亮**永远停在初始那一项**上。
* 这与本轮"概述卡数字永远是 0"是**同一个根因**(本项目第 6、7 次)。
*
* ⇒ 抽成 `@ComponentV2` + `@Param`(@Param 参与观察)。
* ⚠️ 别改回 `@Builder`,哪怕"只有一个参数"也别用(见 FbStatCell 的说明)。
*/
@ComponentV2
export struct FbDockItem {
/** 图标名(`FbIcon.ui()` 的 key) */
@Param icon: string = 'home';
/** 下面那行文字(模块名) */
@Param label: string = '';
/**
* 是不是当前所在模块。
* 🔴 由父组件**算好传进来**(父组件读 `this.app.activeModule`)——
* 组件自己拿不到父的状态,这正是"@Param 会被观察"的价值。
*/
@Param active: boolean = false;
/** 该模块的**设定色**(当前模块时,图标与文字都用它) */
@Param accent: string = '#A84A28';
/** 点击回调(切模块) */
@Event onTap: () => void = (): void => {
};
build() {
Column({ space: 2 }) {
Image(FbIcon.ui(this.icon))
.width(20)
.height(20)
.renderMode(ImageRenderMode.Template)
// ⚠️ fillColor 必须同一类型:选中时用模块色(string),未选中也用 string。
// 三目里两侧类型不一致(Resource 与 string 混用)会报
// "Type 'string | Resource' is not assignable to type 'string'"。
//
// 🔴 未选中色从 muted(#7E6B5A) 加深到 #4A3F35:
// 原先在白底上的对比度只有约 2.3:1(远低于 WCAG 的 4.5:1),
// 加上材质半透,用户就觉得"和下面的内容糊在一起、看不清"。
// 🔴 选中色 = **该模块自己的设定色**(用户 2026-10-17 明确要求),
// 不是统一的品牌红 —— 这样"我在日程"和"我在任务"一眼能分清。
.fillColor(this.active ? this.accent : '#4A3F35')
Text(this.label)
.fontSize(FbMetric.fontTiny)
.fontColor(this.active ? this.accent : '#4A3F35')
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.maxLines(1)
}
.layoutWeight(1) // 等分
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick((): void => {
this.onTap();
})
}
}
+39 -9
View File
@@ -28,14 +28,23 @@ export struct FbTopBar {
@BuilderParam actions: () => void;
build() {
Row({ space: 10 }) {
// ---- 应用图标(贴最左;**点它开抽屉**)----
// 🔴 顶栏横向镜像 —— 「质感握姿」(用户 2026-10-16 第 4 条):
// 左手握时家书图标在**左**,右手握时它跑到**右**,方便单手点它开抽屉。
// ⚠️ 用 `Flex(direction: RowReverse)` 整体镜像,而不是逐个改 padding/margin ——
// 后者要同时动 4 处、还会把"标题靠左"这类布局搞乱。
// ⚠️ 但**图标不能真的反着画**(`RowReverse` 只改主轴顺序、不翻转图形本身),
// 所以镜像后位置对、图形仍正。
Flex({
direction: this.app.drawerSide === 'right' ? FlexDirection.RowReverse : FlexDirection.Row,
alignItems: ItemAlign.Center
}) {
// ---- 应用图标(**点它开抽屉**;左右握姿时自动换边)----
// ⚠️ 必须给描边:logo 是「米色底(74%) + 砖红图案(24%)」的实心方图,
// 放在白色/米色上时底板与背景几乎同色,只剩砖红图案发飘、看不清。
// 一层极浅的描边就能把底板"托"出来(本机范本 SyncCalendar 也给了描边)。
//
// 🔴 点击事件直接挂在 Image 上,**不套 Button** ——
// Button 自带最小尺寸与内边距,会把图标挤离左边线(用户要"图标靠左")。
// Button 自带最小尺寸与内边距,会把图标挤离边线(用户要"图标靠边")。
Image($r('app.media.fb_logo'))
.width(30)
.height(30)
@@ -43,7 +52,7 @@ export struct FbTopBar {
.border({ width: 1, color: FbColors.line })
.flexShrink(0)
.onClick(() => {
// 点图标 ⇒ 开抽屉(沿用上次的方向 drawerSide,默认左边)
// 点图标 ⇒ 开抽屉(方向由**握姿**定,见 common/FbGrip.ets)
this.app.drawerOpen = true;
})
@@ -60,6 +69,7 @@ export struct FbTopBar {
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10, right: 10 })
.layoutWeight(1)
// ---- 右:各模块自己的操作 ----
@@ -100,14 +110,34 @@ export struct FbTopBar {
dia: 32,
fontSizeVp: 14
})
.margin({ left: 2 })
}
.width('100%')
.height(FbMetric.topBarH)
// 🔴 高度 = **状态栏高度 + 顶栏本身高度**(用户 2026-10-13 要"全沉浸")。
//
// 为什么必须自己加这一段:
// 应用开了 `setWindowLayoutFullScreen(true)`(见 EntryAbility)——
// 窗口**不再为状态栏让位**,内容会真的画到状态栏底下。
// ⇒ 顶栏若不自己让出这一截,"家书"标题会被时间/信号图标压住。
//
// ⚠️ 让位用 **padding.top**(不是 margin):这样**白底会一并覆盖状态栏区域**,
// 静止时看起来通知栏与顶栏是一整块;而向上滑动时,
// 这一整块(含白底)随内容滚走,下面的内容直接顶到屏幕最上沿 ——
// 这就是用户要的"内容从手机边缘滑上去"的观感。
// ⚠️ `statusBarH` 是 `@Trace` 的,量出来(异步)后顶栏会**自动重排**;
// 量不到时为 0,标题贴到最上面(仍可读),不会把内容推到屏幕中间。
.height(FbMetric.topBarH + this.app.statusBarH)
// 左内边距=pagePad(与下面各区块的左边线对齐);右侧 10(图标可略贴边)
.padding({ left: FbMetric.pagePad, right: 10, top: 4, bottom: 4 })
.alignItems(VerticalAlign.Center)
// 顶栏底色 = 白色(与下面的个人信息卡同色)
.padding({
left: FbMetric.pagePad,
right: 10,
top: this.app.statusBarH + 4,
bottom: 4
})
// 顶栏底色 = 白色(与下面的个人信息卡同色)。
// ⚠️ 它会覆盖上面 `padding.top` 让出来的状态栏区域 —— 这是**有意为之**:
// 静止时通知栏与顶栏连成一整块白(用户 2026-10-12 定的"融为一体"),
// 滚动时再一起滑走。**不要**改成"顶栏不设背景色、透出页面底色",
// 那样静止时会变成"米色通知栏 + 白色顶栏"上下拼接(踩过这个坑)。
.backgroundColor(FbColors.surface)
// 底线:与下方内容(同是白色卡)之间给一条极浅的分隔,避免"两块白糊在一起"
.border({ width: { bottom: 1 }, color: FbColors.line2 })
+55 -47
View File
@@ -176,7 +176,7 @@ export struct FbSectionTitle {
*/
@ComponentV2
export struct FbAvatar {
/** 显示名(取首字) */
/** 显示名(取首字;也是**没有图片时**的回落) */
@Param name: string = '';
/** 底色(由业务侧算好传进来) */
@Param color: string = '#A84A28';
@@ -184,6 +184,17 @@ export struct FbAvatar {
@Param dia: number = 36;
/** 字号(vp);不传则按直径推算 */
@Param fontSizeVp: number = 0;
/**
* 头像图 URL(**空 = 用色块字回落**)。
*
* 🔴 为什么要支持真图(用户 2026-10-13:"名册的头像怎么都没有?"):
* 之前这里只有色块字,看起来像"没有头像"。
* 实测名册 800 人里只有 19 人传过头像 ⇒ **两者都要有**:
* 有图的显示图、没图的回落到色块字(这是绝大多数)。
* ⚠️ URL 由调用方用 `fbImageUrl(key, token)` 拼好传进来 ——
* `Image()` 不支持自定义请求头,令牌只能走查询串(见 `fbImageUrl` 的说明)。
*/
@Param imageUrl: string = '';
/** 取名字的第一个字(含中文名的姓) */
private initial(): string {
@@ -195,15 +206,29 @@ export struct FbAvatar {
}
build() {
Text(this.initial())
.width(this.dia)
.height(this.dia)
.borderRadius(this.dia / 2)
.backgroundColor(this.color)
.fontColor(Color.White)
.fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42))
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.Center)
// ⚠️ 用 if/else 而不是"图层叠":两种形态的**尺寸来源**不同
// (Image 有 intrinsic size,Text 靠 width/height)——
// 叠起来容易出现"图把字盖住但字还在占位"的错位。
if (this.imageUrl !== '') {
Image(this.imageUrl)
.width(this.dia)
.height(this.dia)
.borderRadius(this.dia / 2)
// 加载失败/慢时先显示底色,避免"一片空白"
.backgroundColor(this.color)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_logo'))
} else {
Text(this.initial())
.width(this.dia)
.height(this.dia)
.borderRadius(this.dia / 2)
.backgroundColor(this.color)
.fontColor(Color.White)
.fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42))
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.Center)
}
}
}
@@ -403,43 +428,26 @@ export struct FbDotLine {
}
}
/** 一条「三格数字」统计条(首页近七天:日程 / 待办 / 逾期) */
@ComponentV2
export struct FbStatTriple {
@Param labelA: string = '';
@Param valueA: string = '';
@Param labelB: string = '';
@Param valueB: string = '';
@Param labelC: string = '';
@Param valueC: string = '';
/** C 项是否用警示红(逾期数) */
@Param cAlarm: boolean = false;
@Builder
cell(label: string, value: string, alarm: boolean) {
Column({ space: 2 }) {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(alarm ? FbColors.red : FbColors.ink)
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
build() {
Row() {
this.cell(this.labelA, this.valueA, false)
this.cell(this.labelB, this.valueB, false)
this.cell(this.labelC, this.valueC, this.cAlarm)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
}
/**
* 一条「N 格数字」统计条 —— 🔴🔴 **已删除(v54b,2026-10-17)**。
*
* 原来这里有个 `FbStatTriple`(三格:日程 / 待办 / 逾期),内部用
* `@Builder cell(label, value, alarm)` 画每一格。
*
* 用户 2026-10-17 报「概述卡……**日程、在做、待办的数量是错误的**」,
* 真机实测:接口与端上缓存都是 `{1,3,2,0}`(正确),界面却是 `0/0/0`。
* 根因:SDK 规则 `r/quick-rules/05-state.md` →
* > **@Builder 参数传递**:「**两个及以上参数不触发刷新**」
* ⇒ `cell()` 那份子树只按**初始值**渲染一次(那时还是默认的 0),
* 数据回来、`build()` 重跑了,但**这个多值 @Builder 的子树不重建**。
*
* ⇒ **删除而不是修补**,理由:
* ① 它当时**已无人引用**(首页改成四格 + `FbStatCell` 之后);
* ② 留着它,以后有人"顺手用一下"就会原样复发(本项目已踩过 6 次这个坑);
* ③ 它的三格形态也不再匹配需求(现在要四格)。
* ⇒ 新的写法用 **`components/FbStatCell.ets`**(`@ComponentV2` + `@Param`,
* **@Param 参与观察**),需要几格就摆几个。**不要再恢复 `@Builder` 版本。**
*/
/**
* 进度条(读书进度、旅程完成度…)。