/** * 家书 FamilyBook · 共享 UI 组件(鸿蒙端) * * 这些都是**无状态或轻状态**的展示件,对应 Web 端 `assets/css/app.css` 里 * 那几个反复出现的类:`.card` / `.page-head` / `.empty` / `.hint` / `.node-badge` … * * ⚠️ 全部用 @ComponentV2 + @Param(只读入参),不在子组件里改父组件状态。 * ⚠️ 所有 struct 加 `Fb` 前缀(arkts-no-decl-merging:同 hap 内顶层名不得重复)。 */ import { FbColors, FbIcon, FbMetric, fbTint, FbTint } from '../common/Theme'; /** * 圆点 / 色块(时间轴的点、日历的日程小点、看板的色标…)。 * * ⚠️ 为什么不用 `Circle()`:它的 `.fill()` 是 **API 26.0.0** 才有的重载, * 本工程 compatibleSdkVersion 是 6.1.1(24),编译器会告警 * 「This API is supported since SDK version 26.0.0」,真机上可能不生效。 * 改用「方块 + 圆角 = 圆」的写法:`Column().width().height().borderRadius(dia/2)` * 只用通用属性,任何版本都成立,而且圆形效果完全等价。 * * @Param dia 直径(vp) * @Param fillC 填充色(ResourceColor:hex 字符串或 $r 语义色) * @Param ringC 描边色(不传则不描边) */ @ComponentV2 export struct FbDot { @Param dia: number = 8; @Param fillC: ResourceColor = '#A84A28'; @Param ringC?: ResourceColor = undefined; build() { Stack({ alignContent: Alignment.Center }) { // 底:填充 Column() .width(this.dia) .height(this.dia) .borderRadius(this.dia / 2) .backgroundColor(this.fillC) // 描边(空心点用:内层挖一个背景色圆,等效于 1.4vp 环形) if (this.ringC !== undefined) { Column() .width(this.dia - 2.8) .height(this.dia - 2.8) .borderRadius((this.dia - 2.8) / 2) .backgroundColor(FbColors.surface) } } .width(this.dia) .height(this.dia) } } /** * 空心状态圈(任务卡的完成圈:未完成时空心、完成时实心 + 勾)。 * * 同样避开 Circle().fill()/stroke(),用嵌套方块实现。 */ @ComponentV2 export struct FbCheckCircle { /** 直径 */ @Param dia: number = 16; /** 是否已完成(实心 + 勾) */ @Param done: boolean = false; /** 是否进行中(实心小点) */ @Param doing: boolean = false; /** 主色 */ @Param color: string = '#A8574F'; build() { Stack({ alignContent: Alignment.Center }) { // 外圈:完成/进行中是实心主色,否则用浅灰实心(代表描边) Column() .width(this.dia) .height(this.dia) .borderRadius(this.dia / 2) .backgroundColor(this.done || this.doing ? this.color : '#A8988A') // 内层:未勾选时用卡片底「挖空」,形成空心环 if (!this.done && !this.doing) { Column() .width(this.dia - 2.8) .height(this.dia - 2.8) .borderRadius((this.dia - 2.8) / 2) .backgroundColor(FbColors.surface) } // 已完成:打勾图标 if (this.done) { Image($r('app.media.fb_ic_check')) .width(this.dia * 0.66) .height(this.dia * 0.66) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(FbTint.white)) } else if (this.doing) { // 进行中:中心一个白点 Column() .width(this.dia * 0.42) .height(this.dia * 0.42) .borderRadius(this.dia * 0.21) .backgroundColor(Color.White) } } .width(this.dia) .height(this.dia) } } /** 空态:一块居中的提示卡(Web 端 `.card.empty`) */ @ComponentV2 export struct FbEmptyBox { /** 主标题 */ @Param title: string = '这里还是空的'; /** 副说明 */ @Param hint: string = ''; /** 可选的插图图标(默认圆点) */ @Param icon: Resource = $r('app.media.fb_ic_dot'); build() { Column({ space: 8 }) { Image(this.icon) .width(34) .height(34) .opacity(0.35) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(FbTint.faint)) Text(this.title) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) if (this.hint.length > 0) { Text(this.hint) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.muted) .textAlign(TextAlign.Center) .lineHeight(20) } } .width('100%') .padding({ top: 40, bottom: 40, left: 24, right: 24 }) .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) } } /** 区块小标题(Web 端 `.cinema-h2`:一行标题 + 可选的右侧说明) */ @ComponentV2 export struct FbSectionTitle { @Param title: string = ''; @Param extra: string = ''; /** * 🔴 v84(用户 2026-10-23):「日程,没有新建日程按钮,名册,也没有新增成员 * 这个按钮。……首页上的其他卡片也应该有这些基本功能。」 * * `addText` 非空 ⇒ 在 extra 右边给一颗**小号文字按钮**(空 = 不画,行为与原来完全一致)。 * * ⚠️ 为什么是"可选参数 + 默认空"而不是新加一个组件: * 本组件被首页 4 个区块、日程页、任务页等多处引用, * 改签名会波及所有调用点;`@Param` 带默认值则**老调用点一行都不用改**。 * ⚠️ 为什么按钮文案用文字而不是 `+` 图标: * 首页区块标题这一行只有 20vp 高,放图标按钮手指点不准(实测点击热区 * 会小于 40vp);文字按钮横向量足,且与网页端 `datatable` 的「新增」观感一致。 * ⚠️ 点击热区用 `padding` 撑到 **44vp 高**(不是 fontTiny 那么点大)—— * `hitTestBehavior` 默认是 `Default`,padding 区**算在热区内**。 */ @Param addText: string = ''; /** 点「新增」;`addText` 为空时永不触发 */ @Event onAdd: () => void = (): void => { }; /** * 区块的**模块主题色**(#60)。 * * 🔴 用户 2026-11-05:「"今天的日程",既然是日程,标题颜色就应该用日程的 * 主题色。11条后边"新建"的地方变成一个按钮,按钮颜色也是主题色。」 * 「下面的"近期任务"改法跟上面一样,还有"最近纪念日"也不说了,全都是一样的。」 * * ⇒ 标题与「新建」按钮都用这个色。 * * ⚠️ **空串 = 保持原来的灰色**(老调用点不受影响): * 本组件被首页 4 个区块 + 日程页 + 任务页 + 资源页等多处引用, * 改签名会波及所有调用点;`@Param` 带默认值则老调用点一行都不用改。 * ⚠️ 调用方**不要硬编码色值**,从模块设定取(用户可能在"模块设置"里改色)。 */ @Param accent: string = ''; /** 标题实际用的颜色:有 accent 用之,否则退回原来的 muted 灰 */ private titleColor(): string { return this.accent !== '' ? this.accent : '#8A7A6D'; } build() { Row() { Text(this.title) .fontSize(FbMetric.fontSmall) /* #60:有主题色时加粗一档 —— 光换色在浅色背景上辨识度不够 */ .fontWeight(this.accent !== '' ? FontWeight.Bold : FontWeight.Medium) .fontColor(this.titleColor()) .letterSpacing(0.6) if (this.extra.length > 0) { Blank() Text(this.extra) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } if (this.addText.length > 0) { /* extra 存在时用 6vp 间距隔开;extra 为空时靠 Blank 顶到右边 */ if (this.extra.length === 0) { Blank() } /* 🔴 #60(用户 2026-11-05):「11条后边"新建"的地方变成一个按钮, 按钮颜色也是主题色。」 改前:纯文字 + brand 色 ⇒ 看着像链接,不醒目。 改后:**胶囊按钮** —— 底色 = 模块主题色(无 accent 时退回 brand), 文字白色,圆角全圆。 ⚠️ 点击热区:胶囊本身 + `padding` 撑到约 44vp 高。 原来靠 `padding({top:12,bottom:12})` 撑热区,现在胶囊有实体高度, 热区天然够大,不必再叠大 padding。 ⚠️ `FbColors` 里**没有** `accent` 字段(编译会报 "Property 'accent' does not exist")⇒ 用本组件的 `this.accent`。 */ Text(this.addText) .fontSize(FbMetric.fontTiny) .fontColor('#FFFFFF') .fontWeight(FontWeight.Medium) /* 🔴 #60(用户 2026-11-05):「右边11条,这个和那个"新建"挨得太近了。」 原因:`extra` 后面直接接胶囊,Row 无 space、胶囊无 margin ⇒ 两者贴死。 加 12vp 左外边距(不是 8)—— 胶囊有实体底色,视觉间距会比纯文字 显得更紧,8vp 看着仍然像一坨;12vp 才有清晰的分隔感。 */ .margin({ left: 12 }) .padding({ left: 10, right: 10, top: 5, bottom: 5 }) .backgroundColor(this.accent !== '' ? this.accent : '#A84A28') .borderRadius(999) .onClick((): void => { this.onAdd(); }) } } .width('100%') .padding({ left: 4, right: 4, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) } } /** * 头像(Web 端 `nav_avatar.php`:姓名首字 + 由名字哈希出的底色)。 * * 端上不做「上传头像」—— 先把「首字 + 色块」这套做出来, * 接接口后再叠真实图片。 */ @ComponentV2 export struct FbAvatar { /** 显示名(取首字;也是**没有图片时**的回落) */ @Param name: string = ''; /** 底色(由业务侧算好传进来) */ @Param color: string = '#A84A28'; /** 直径(vp) */ @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 { const t: string = this.name.trim(); if (t.length === 0) { return '?'; } return t.substring(0, 1); } build() { /* 🔴🔴 v62(用户 2026-10-19):「**如果一个人没头像,在列表是有一个文字的 头像的,那么点击后弹框,也应该有。**」 ⇒ 列表有文字头像(因为 `imageUrl` 是空 ⇒ 走 Text 分支), 而详情/简要卡是**纯色块**。 真因:这里原来是 `if/else` —— 只要 `imageUrl` 非空就**只画 Image**, 而那张图**加载失败时 `Image` 什么都不显示**(只露出 `.backgroundColor(color)` 的纯色块)⇒ 看起来就是"一个光板,没有文字头像" (`alt()` 只在**加载中**起作用,失败后是空白)。 ⚠️ 而"有 imageUrl 的人"其实不多(实测 800 人里 19 人), 但**空 key 与坏 key 的观感差别很大**:前者有字,后者是光板。 ⇒ 改成**叠加**:底色 + 文字头像**常驻**,图片有就叠在上面 (图片一旦加载成功就完全盖住文字,失败则自动露出文字头像)。 ⚠️ 之前注释说"图层叠会错位"——那是因为当时想靠 Image 的 intrinsic size 撑尺寸。 现在**两层都显式给 `dia`**,尺寸来源唯一,不存在错位。 ⚠️ 用 Stack 而不是 `backgroundImage`:`backgroundImage` 不接受网络地址, 而头像走的是 `media.php?k=...`(见 `fbImageUrl` 的说明)。 */ Stack({ alignContent: Alignment.Center }) { // ① 底色 + 文字头像(**永远在**,是"图的兜底") 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 (this.imageUrl !== '') { Image(this.imageUrl) .width(this.dia) .height(this.dia) .borderRadius(this.dia / 2) .objectFit(ImageFit.Cover) } } .width(this.dia) .height(this.dia) } } /** * 「同看的人」这类人员标签(Web 端 `person_chip`:小头像 + 称呼)。 * * ⚠️ 会出现在 内部的行内场景,所以整块不可点、纯展示。 */ @ComponentV2 export struct FbPersonChip { @Param name: string = ''; @Param color: string = '#A84A28'; /** 是否显示文字(只放头像时传 false) */ @Param withText: boolean = true; /** * 头像图 URL(空 = 用色块字回落)。 * * 🔴 #77i(用户 2026-11-05):「评论那个地方的头像怎么又没了?」 * * 真因:`FbAvatar` **本来就支持** `imageUrl`(它自己的注释里写着 * 「名册 800 人里只有 19 人传过头像 ⇒ 两者都要有:有图显示图、没图回落色块字」), * 但 `FbPersonChip` **没有把这个参数透传下去** —— * 它内部只写了 `FbAvatar({ name, color, dia, fontSizeVp })`。 * ⇒ 凡是用 `FbPersonChip` 的地方,头像永远是首字色块,真图显示不出来。 * * ⚠️ 这是我上一轮"改用规范组件"时替换掉的:原来手写的 * `FbAvatar({ ..., imageUrl: ... })` 是**能显示图的**, * 换成 `FbPersonChip` 后反而丢了图 —— 用规范组件没错, * 但规范组件本身缺了这层透传,得先补它。 * ⚠️ URL 由调用方用 `fbImageSrc(key, token)` 拼好传进来(与 `FbAvatar` 同口径)。 */ @Param imageUrl: string = ''; build() { Row({ space: 4 }) { FbAvatar({ name: this.name, color: this.color, dia: 18, fontSizeVp: 10, imageUrl: this.imageUrl }) if (this.withText) { Text(this.name) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } } .padding({ left: 4, right: 8, top: 2, bottom: 2 }) .borderRadius(FbMetric.radiusPill) .backgroundColor(FbColors.inputBg) } } /** 小徽标(Web 端 `.node-badge` / `.badge`:管理员、访客、标签…) */ @ComponentV2 export struct FbTag { @Param text: string = ''; @Param tone: string = 'muted'; private fgColor(): string { if (this.tone === 'brand') { return '#A84A28'; } if (this.tone === 'red') { return '#C0533F'; } if (this.tone === 'green') { return '#4E7C3F'; } if (this.tone === 'blue') { return '#2F6FB0'; } return '#7E6B5A'; } private bgColor(): string { if (this.tone === 'brand') { return '#14A84A28'; } if (this.tone === 'red') { return '#14C0533F'; } if (this.tone === 'green') { return '#146F9E5C'; } if (this.tone === 'blue') { return '#142F6FB0'; } return '#0F2C231D'; } build() { Text(this.text) .fontSize(FbMetric.fontTiny) .fontColor(this.fgColor()) .backgroundColor(this.bgColor()) .padding({ left: 7, right: 7, top: 2, bottom: 2 }) .borderRadius(FbMetric.radiusPill) .maxLines(1) } } /** * 页头的「设置 / 更多」按钮(Web 端 `.btn.btn-icon`)。 * * ⚠️ 必须是 `Button() { Image() }` 形式:`Button('label') { ... }` 同时给 * label 和子组件会报 10905202。 */ @ComponentV2 export struct FbIconButton { @Param icon: Resource = $r('app.media.fb_ic_gear'); /** 图标色:ResourceColor 同时接受 Resource($r 语义色)与 '#RRGGBB' 字符串 */ /** * 图标颜色。**必须是 hex 字符串**(不是 `Resource`)—— * `colorFilter(fbTint(...))` 只吃 hex(颜色矩阵的入参)。 * ⚠️ 传 `FbColors.*`(Resource)会编译报错 `ResourceColor is not assignable to string`, * 那是**故意的**:逼调用方用 `FbTint.*` 这组与 color.json 同值的常量。 */ @Param tint: string = FbTint.muted; /** 直径 */ @Param dia: number = 36; @Event onTap: () => void = () => { }; build() { Button({ type: ButtonType.Circle }) { Image(this.icon) .width(18) .height(18) /* ⚠️ 本工程图标是 PNG ⇒ 必须 colorFilter(fillColor 只对 SVG 生效)。 */ .colorFilter(fbTint(this.tint)) } .width(this.dia) .height(this.dia) .backgroundColor(FbColors.inputBg) .onClick(() => { this.onTap(); }) } } /** * 主操作按钮(Web 端 `.btn`:模块色底 + 白字)。 * * ⚠️ 同样用 `Button() { Row() { … } }` 形式,label 与子组件二选一。 */ @ComponentV2 export struct FbPrimaryButton { @Param label: string = ''; /** 底色(一般传模块主题色的 hex 字符串,也可传 Resource 语义色) */ @Param bg: ResourceColor = '#A84A28'; /** 前置图标(不传则只显示文字) */ @Param icon?: Resource = undefined; /** 是否撑满宽度 */ @Param block: boolean = false; @Event onTap: () => void = () => { }; build() { Button({ type: ButtonType.ROUNDED_RECTANGLE }) { Row({ space: 6 }) { if (this.icon !== undefined) { Image(this.icon) .width(16) .height(16) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(FbTint.white)) } Text(this.label) .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .fontWeight(FontWeight.Medium) } .justifyContent(FlexAlign.Center) } .height(36) .padding({ left: 14, right: 14 }) .backgroundColor(this.bg) .borderRadius(FbMetric.radiusS) .stateEffect(true) .width(this.block ? '100%' : undefined) .onClick(() => { this.onTap(); }) } } /** * 一行「标签 + 值」的信息条(名册详情、观影信息、财务条目…都用它)。 * * 对应 Web 端各页面里那种 `圆点 + 文字` 的行。 */ @ComponentV2 export struct FbDotLine { /** 圆点色(不传则不画点) */ @Param dotColor: string = ''; /** 左侧主文字 */ @Param label: string = ''; /** 右侧值 */ @Param tail: string = ''; /** 主文字淡化成次要色(用于「没有内容」的占位行) */ @Param muted: boolean = false; build() { Row({ space: 8 }) { if (this.dotColor.length > 0) { FbDot({ dia: 7, fillC: this.dotColor }) } Text(this.label) .fontSize(FbMetric.fontSmall) .fontColor(this.muted ? FbColors.faint : FbColors.ink) .layoutWeight(1) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (this.tail.length > 0) { Text(this.tail) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(1) } } .width('100%') .alignItems(VerticalAlign.Center) } } /** * 一条「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` 版本。** */ /** * 进度条(读书进度、旅程完成度…)。 * * ⚠️ 用 Progress 的 Linear 类型;`ProgressType` 枚举是 Linear / Ring / Eclipse… */ @ComponentV2 export struct FbProgressLine { /** 0–100 */ @Param percent: number = 0; @Param color: string = '#A84A28'; build() { Progress({ value: this.percent, total: 100, type: ProgressType.Linear }) .width('100%') .height(4) .color(this.color) .backgroundColor(FbColors.inputBg) } } /** 评分(观影 /·/10 分、读书 5 星)—— 用文字而非图形,避免臆造评分组件 */ @ComponentV2 export struct FbRating { /** 分值 */ @Param score: number = 0; /** 满分(10 或 5) */ @Param max: number = 10; build() { Row({ space: 2 }) { Text('★') .fontSize(FbMetric.fontTiny) .fontColor('#C08A2E') Text(this.max === 5 ? this.score.toFixed(1) : this.score.toFixed(1)) .fontSize(FbMetric.fontTiny) .fontWeight(FontWeight.Medium) .fontColor('#C08A2E') Text('/' + this.max.toString()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } } }