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:
@@ -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();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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` 版本。**
|
||||
*/
|
||||
|
||||
/**
|
||||
* 进度条(读书进度、旅程完成度…)。
|
||||
|
||||
Reference in New Issue
Block a user