diff --git a/entry/src/main/ets/common/FbScroll.ets b/entry/src/main/ets/common/FbScroll.ets index 3c98ec0..f0037d6 100644 --- a/entry/src/main/ets/common/FbScroll.ets +++ b/entry/src/main/ets/common/FbScroll.ets @@ -49,6 +49,59 @@ const HIDE_AT: number = 12; */ const SHOW_AT: number = 8; +/** + * 🔴 2026-10-19 用户:「名册下拉的时候,拉到头,它会**回弹一下**, + * 导致下面的悬浮按钮**又消失了**。」 + * + * 真因(`EdgeEffect.Spring` 的必然结果): + * · 手指往下拉(内容往下走)⇒ 每帧 `offset < 0` ⇒ 我们**正确地唤回**悬浮栏; + * · 拉到顶之后继续拉 = **越界**,内容停在原位、手指还在动; + * · **松手** ⇒ 系统弹簧把内容**弹回去**(内容往上走)⇒ 每帧 `offset > 0` + * ⇒ 被当成"用户在往上滑" ⇒ **收起悬浮栏**。 + * 用户看到的就是"按钮闪没"。 + * + * 判据(不用猜,用**手指方向 + 时间**): + * · 回弹一定发生在**抬手之后**、且紧跟着一次**下滑**; + * · 真正的"往上滑"要么手指还在屏幕上(`touchStartY >= 0`), + * 要么上一次手指方向就是往上(`lastDir === 1`)。 + * ⇒ 只有"手指已抬起 + 上一次是下滑 + 刚抬手不到 `SPRING_MS`"才拦。 + * + * ⚠️ 为什么用 500ms:本机弹簧动画(Spring)在真机上约 300–450ms 结束, + * 取 500 能把整段回弹盖住;再长就会误伤"下拉之后马上接着上滑"。 + */ +const SPRING_MS: number = 500; + +/** + * 手指按下位置(**-1 = 当前没有按压**;用哨兵值而不是 null,ArkTS 对可选值更严格)。 + * + * ⚠️ 提到文件上方(原来是放在触摸段里的):`fbOnScroll` 也要读它判断 + * "现在是指针在拖、还是抬手后的惯性/回弹"。 + */ +let touchStartY: number = -1; + +/** 一次滑动至少要移动这么多(vp)才认作"有方向意图"(避免把点击当滑动) */ +const TOUCH_MIN: number = 16; + +/** 手指**最近一次**明确方向:1 = 往上滑(内容上移),-1 = 往下滑,0 = 还没动 */ +let lastDir: number = 0; + +/** 最近一次抬手的时间戳(0 = 还没抬过手) */ +let lastUpAt: number = 0; + +/** + * 现在收到的"正向位移"(内容往上走)是不是**弹簧回弹**。 + * + * ⚠️ 只在 `fbOnScroll` 里用;判定条件见 `SPRING_MS` 上方的说明。 + */ +function isSpringBack(): boolean { + // 手指还在屏幕上 ⇒ 用户正在拖,尊重他的方向 + if (touchStartY >= 0) { + return false; + } + // 刚刚是"往下拉",且才抬手不久 ⇒ 这一串正向位移是弹簧把内容弹回去 + return lastDir === -1 && lastUpAt > 0 && (Date.now() - lastUpAt) < SPRING_MS; +} + /** * 横向滚动容器上报(挂在**横向** Scroll 上的 `.onDidScroll`)。 * @@ -94,6 +147,13 @@ export function fbHScrollBusy(withinMs: number = 350): boolean { export function fbOnScroll(offset: number, state: ScrollState): void { const app = fbAppState(); + // 🔴 先拦"回弹":见 `SPRING_MS` 的说明。 + // ⚠️ 必须放在**最前面**(包括快路径之前): + // 回弹的第一帧位移往往就 ≥ `HIDE_AT`,放到后面就来不及了。 + if (offset > 0 && isSpringBack()) { + return; + } + // ⚠️ **不跳过 Idle**:`Fling`(惯性)结束时最后那一下会带 Idle, // 而内容短的页面可能**只在惯性阶段**产生位移 —— 跳过就永远收不起来。 // 但 Idle 的 offset 偶尔是 0(无方向含义),所以下面按 offset 的正负判断, @@ -140,12 +200,8 @@ export function fbOnScroll(offset: number, state: ScrollState): void { // ============================================================ // 触摸方向兜底(内容不足一屏的页面用) // ============================================================ - -/** 手指按下的位置(**-1 = 当前没有按压**;用哨兵值而不是 null,ArkTS 对可选值更严格) */ -let touchStartY: number = -1; - -/** 一次滑动至少要移动这么多(vp)才认作"有方向意图"(避免把点击当滑动) */ -const TOUCH_MIN: number = 16; +// ⚠️ `touchStartY` / `TOUCH_MIN` 已提到文件上方 —— `fbOnScroll` 也要读它们 +// (判断"手指在拖"还是"抬手后的回弹")。这里不再重复声明。 /** * 触摸方向兜底:在**页面根容器**挂 `.onTouch(fbOnTouch)`。 @@ -180,6 +236,11 @@ export function fbOnTouch(event: TouchEvent): void { if (arr.length > 0) { touchStartY = arr[0].windowY; } + // 🔴 新一轮手势开始 ⇒ 清掉上一轮的方向残留。 + // ⚠️ 不清的后果:上一轮是"往下拉"(`lastDir = -1`),这一轮只点了一下 + // (位移 < `TOUCH_MIN`,不更新方向)就抬手 ⇒ 抬手的瞬间窗口又开了 + // ⇒ 系统惯性里任何正向位移都被误判成回弹、悬浮栏该收却不收。 + lastDir = 0; return; } @@ -194,12 +255,15 @@ export function fbOnTouch(event: TouchEvent): void { const dy: number = arr[0].windowY - touchStartY; // dy < 0 ⇒ 手指往上抹 ⇒ 内容往上走 ⇒ 收起 if (dy <= -TOUCH_MIN) { + // 记下方向:抬手后若出现"相反方向的内容位移"(弹簧回弹),用它来识别 + lastDir = 1; if (!app.dockHidden) { app.dockHidden = true; } // ⚠️ **不更新** touchStartY:否则手指继续上抹时会一直在阈值边缘反复触发。 // 保持不动 ⇒ 判断是"相对起点的总位移",更稳。 } else if (dy >= TOUCH_MIN) { + lastDir = -1; if (app.dockHidden) { app.dockHidden = false; } @@ -209,5 +273,8 @@ export function fbOnTouch(event: TouchEvent): void { if (t === TouchType.Up || t === TouchType.Cancel) { touchStartY = -1; + // 🔴 抬手时刻:之后的 500ms 内,与"最后一次手指方向"相反的滚动 + // 会被当作弹簧回弹忽略(见 `isSpringBack`)。 + lastUpAt = Date.now(); } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/components/FbCalSettingsSheet.ets b/entry/src/main/ets/components/FbCalSettingsSheet.ets index 50f2c19..de18dff 100644 --- a/entry/src/main/ets/components/FbCalSettingsSheet.ets +++ b/entry/src/main/ets/components/FbCalSettingsSheet.ets @@ -24,9 +24,11 @@ */ import { FbColors, FbMetric } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbCalBook } from '../model/Types'; import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings'; import { FbCalOptionRow } from './FbCalOptionRow'; +import { FbSheetRing } from './FbSheetRing'; import { FbDot } from './Widgets'; const FB_SHEET_BOTTOM_GAP: number = 16; @@ -73,6 +75,22 @@ export struct FbCalSettingsSheet { @Local style: FbCalStyle = 'timeline'; @Local bookId: number = 0; + /** + * 卡片外圈描边环的颜色(hex,'' = 用日程模块色)。 + * + * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 + */ + @Param accent: string = ''; + + /** 环色:显式给了就用给的,否则取日程模块色 */ + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('calendar'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + aboutToAppear(): void { this.view = FB_CAL_SETTINGS.getView(); this.style = FB_CAL_SETTINGS.getStyle(); @@ -110,140 +128,149 @@ export struct FbCalSettingsSheet { // 宿主 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) + // 🔴 2026-10-19:卡片外面加**主题色环**(用户:「所有弹框都加线」), + // 由共用组件 `FbSheetRing` 统一给;这一层只负责 92% 宽度。 + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + 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) + 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) { + Scroll() { + Column({ space: 6 }) { + // ---- 默认视图 ---- + this.sectionTitle('打开的默认视图') + ForEach(VIEW_OPTS, (o: FbViewOpt) => { + // 🔴 用**组件**而不是 @Builder —— @Builder 的值类型参数不参与观察, + // 点了不会重建(这正是"默认视图点不动"的根因) FbCalOptionRow({ - label: b.name, - hint: '', - active: this.bookId === b.id, - dotColor: b.color, + label: o.label, + hint: o.hint, + active: this.view === o.key, onPick: (): void => { - this.bookId = b.id; - FB_CAL_SETTINGS.setBookId(b.id); + this.view = o.key; + FB_CAL_SETTINGS.setView(o.key); this.onChanged(); } }) - } - }, (b: FbCalBook): string => 'db_' + b.id.toString()) + }, (o: FbViewOpt): string => 'vo_' + o.key) - // ---- 哪些日历本可见 ---- - 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.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(); - }) - } - .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()) - } + } + }) + }, (o: FbStyleOpt): string => 'so_' + o.key) - Text('这些设置只存在这台手机上,不会同步给别人。') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + // ---- 默认日历本 ---- + 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%') - .margin({ top: 8 }) + .padding({ top: 14 }) + } + /* 🔴 2026-10-19:`layoutWeight(1)` 会让滚动区**吃满剩余高度** ⇒ 内容不满也把 + 卡片顶到 `maxHeight`(72%),下面空一大片。 + ⇒ `flexShrink(1)`:按内容自适应,超长才收缩滚动。 + ⚠️ 不能只删 `layoutWeight`:`Column` 子项默认 `flexShrink = 0` ⇒ 超长内容溢出被裁。 */ + .flexShrink(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) } - .width('100%') - .padding({ top: 14 }) - } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - .align(Alignment.TopStart) - } - // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… + // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + .width('100%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .alignItems(HorizontalAlign.Start) + } + } .width('92%') - .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%') diff --git a/entry/src/main/ets/components/FbCinemaSheet.ets b/entry/src/main/ets/components/FbCinemaSheet.ets index 6871eee..49a6647 100644 --- a/entry/src/main/ets/components/FbCinemaSheet.ets +++ b/entry/src/main/ets/components/FbCinemaSheet.ets @@ -13,7 +13,9 @@ */ import { FbColors, FbMetric } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbCinemaItem } from '../data/FbMovieRepo'; +import { FbSheetRing } from './FbSheetRing'; /** * 贴底卡片的手势区缓冲(vp)。 @@ -33,122 +35,143 @@ export struct FbCinemaSheet { @Event onClose: () => void = (): void => { }; + /** + * 卡片外圈描边环的颜色(hex,'' = 用观影模块色)。 + * + * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 + */ + @Param accent: string = ''; + + /** 环色:显式给了就用给的,否则取观影模块色(影院属于观影) */ + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('movies'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + 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) + // 🔴 2026-10-19:卡片外面加**主题色环**(用户:「所有弹框都加线」), + // 由共用组件 `FbSheetRing` 统一给;这一层只负责 92% 宽度。 + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + 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) + 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%') - .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) + + 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 (c.place !== '' || c.address !== '') { - Text(c.place !== '' ? c.place : c.address) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (idx < this.cinemas.length - 1) { + Divider() + .strokeWidth(0.5) + .color(FbColors.line) } } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) + .width('100%') } - .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()) } - }, (c: FbCinemaItem): string => 'cin_' + c.id.toString()) + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + } } - .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Off) - .edgeEffect(EdgeEffect.Spring) - } - } // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + .width('100%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .alignItems(HorizontalAlign.Start) + } + } .width('92%') - .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%') diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index 83a6940..74019aa 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -37,11 +37,13 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbColors, FbMetric, FB_MASK } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; 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'; +import { FbSheetRing } from './FbSheetRing'; const DOMAIN: number = 0x0000; const TAG: string = 'FbEventEdit'; @@ -102,6 +104,23 @@ export struct FbEventEditSheet { @Param presetDate: string = ''; /** 可选的日历本(新建时要选落到哪本) */ @Param books: FbCalBook[] = []; + + /** + * 卡片外圈描边环的颜色(hex,'' = 用日程模块色)。 + * + * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 + */ + @Param accent: string = ''; + + /** 环色:显式给了就用给的,否则取日程模块色 */ + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('calendar'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + /** 取消 */ @Event onCancel: () => void = (): void => { }; @@ -343,8 +362,10 @@ export struct FbEventEditSheet { build() { // 🔴 外层 `Stack` 是**必需**的:本组件有**三个**要显示的层 // (编辑卡片 + 日期时间弹层 + 删除确认遮罩),而 build() 只能有一个根节点。 - // ⚠️ `alignContent: Bottom` 让卡片/弹层都贴底(同一形态)。 - Stack({ alignContent: Alignment.Bottom }) { + // 🔴 2026-10-19 用户:「所有弹框都加线,编辑也弄成弹框,保持一致。」 + // ⇒ 原来 `alignContent: Bottom`(贴底抽屉),现在改成**居中**, + // 与名册的编辑/详情、日程详情同一个形态(四角圆角浮卡 + 主题色环)。 + Stack({ alignContent: Alignment.Center }) { this.editCard() // ---- 「日期 + 时间」选择弹层(照 SyncCalendar)---- @@ -362,7 +383,11 @@ export struct FbEventEditSheet { @Builder editCard() { - Column({ space: 0 }) { + // ⚠️ 这一层只给**宽度**(92%);卡片自己必须 100%, + // 否则 0.92×0.92 ≈ 84.6%,比别的浮层窄一圈。 + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 0 }) { // ---- 标题栏 ---- Row({ space: 12 }) { Text(this.isCreate ? '新建日程' : '编辑日程') @@ -538,10 +563,11 @@ export struct FbEventEditSheet { } .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 }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 }) .alignItems(HorizontalAlign.Start) + } + } + .width('92%') } /** @@ -640,7 +666,10 @@ export struct FbEventEditSheet { */ @Builder dateTimePickerSheet() { - Column({ space: 14 }) { + // 🔴 2026-10-19:与其它弹框统一(92% 宽 + 四角圆角 + 主题色环)。 + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 14 }) { Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) @@ -693,10 +722,11 @@ export struct FbEventEditSheet { }) } .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 }) + .padding({ left: 18, right: 18, top: 8, bottom: 20 }) .alignItems(HorizontalAlign.Start) + } + } + .width('92%') } /** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */ @@ -733,7 +763,10 @@ export struct FbEventEditSheet { @Builder deleteConfirm() { Column() { - Column({ space: 8 }) { + // 🔴 2026-10-19:确认框也加主题色环("所有弹框都加线"不能漏这一层) + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 8 }) { Text('删除这条日程?') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) @@ -772,9 +805,10 @@ export struct FbEventEditSheet { } .width('100%') .padding(18) - .backgroundColor(FbColors.surface) - .borderRadius(FbMetric.radius) .alignItems(HorizontalAlign.Start) + } + } + .width('100%') } .width('100%') .height('100%') diff --git a/entry/src/main/ets/components/FbEventSheet.ets b/entry/src/main/ets/components/FbEventSheet.ets index 9431799..36a256c 100644 --- a/entry/src/main/ets/components/FbEventSheet.ets +++ b/entry/src/main/ets/components/FbEventSheet.ets @@ -18,6 +18,7 @@ import { FbColors, FbMetric } from '../common/Theme'; import { FbCalEventFull } from '../data/FbCalRepo'; import { FbCalBook } from '../model/Types'; import { FbDot } from './Widgets'; +import { FbSheetRing } from './FbSheetRing'; const FB_SHEET_BOTTOM_GAP: number = 16; @@ -145,121 +146,134 @@ export struct FbEventSheet { // 宿主 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) + // 🔴 2026-10-19:卡片外面的主题色环统一由 `FbSheetRing` 给 + // (用户:「所有弹框都加线,保持一致」)。环色用**这本日历的颜色**, + // 一眼能看出这条日程属于哪个日历本。 + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.bookColor }) { + 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) } - // ⚠️ 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) - } + .alignItems(VerticalAlign.Top) - // ---- 底部按钮 ---- - 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(); - }) + 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) + } + /* 🔴 2026-10-19:原来是 `.layoutWeight(1)` —— 它让 Scroll **吃满剩余高度**, + 于是短内容的日程也会把卡片顶到 `maxHeight`(72%), + 按钮被推到最底、中间留一大片空白(真机截图:标题下面空半屏)。 + ⇒ 改成 `flexShrink(1)`:卡片按内容自适应高, + 只有当内容真的超过 72% 时这一块才收缩并滚动。 + ⚠️ 不能只删掉 `layoutWeight`:`Column` 的子项默认 `flexShrink = 0`, + 不收缩 ⇒ 长内容会**溢出卡片**被裁掉(反方向踩坑)。 */ + .flexShrink(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + } + + // ---- 底部按钮 ---- + Row({ space: 10 }) { + Text('关闭') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onClose(); + }) + if (this.canEdit && this.event !== null) { + 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('100%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .alignItems(HorizontalAlign.Start) } - .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%') diff --git a/entry/src/main/ets/components/FbModuleSettingsSheet.ets b/entry/src/main/ets/components/FbModuleSettingsSheet.ets index 4dccc90..dd1f6f5 100644 --- a/entry/src/main/ets/components/FbModuleSettingsSheet.ets +++ b/entry/src/main/ets/components/FbModuleSettingsSheet.ets @@ -19,8 +19,12 @@ */ import { FbColors, FbMetric, FbIcon, fbTint, FbTint } from '../common/Theme'; +import { FbSheetRing } from './FbSheetRing'; -/** 贴底卡片的手势区缓冲(vp)—— 同其它浮层 */ +/** 贴底卡片的手势区缓冲(vp)—— 同其它浮层 + * ⚠️ 2026-10-19 起本浮层改成**居中弹框**,这个常量暂时没有引用; + * 保留它(而不是删掉)是因为"贴底浮层"这个形态本工程其它地方还在用, + * 删了以后有人要改回去还得再想一遍为什么是 16。 */ const FB_SHEET_BOTTOM_GAP: number = 16; /** 设置菜单里的一条 */ @@ -52,46 +56,81 @@ export struct FbModuleSettingsSheet { @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) + /** + * 卡片外圈描边环的颜色(hex,'' = 用默认品牌色)。 + * + * 🔴 本组件是**通用**的(读书/观影/任务/资源…都用它)⇒ 环色由调用方传 + * "那个模块的主题色";不传时用品牌砖红(至少不是灰色,不会看着像禁用)。 + */ + @Param accent: string = ''; - Scroll() { - Column({ space: 0 }) { - ForEach(this.items, (it: FbSettingItem) => { - this.settingRow(it) - }, (it: FbSettingItem): string => 'st_' + it.action + '_' + it.label) + /** 环色:显式给了就用给的,否则用品牌色 */ + private accentColor(): string { + return this.accent !== '' ? this.accent : FbTint.brand; + } + + build() { + /* 🔴 2026-10-19:与其它浮层统一成"**居中的圆角弹框 + 主题色环**" + (用户:「所有弹框都加线,保持一致」)。 + ⚠️ 原来是"贴底满宽 + 只有上面两个角圆",宿主 Stack 的对齐是 `Bottom` + ⇒ 它看起来像一条"抽屉",加了环也只会露在屏幕顶部一条。 + ⚠️ 这是一个**铺满屏幕**的容器 ⇒ 必须给 `hitTestBehavior(Transparent)`: + 否则它把整屏的点击都吃掉,"点空白关掉"就失效了。 */ + Column() { + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + 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 }) + } + /* 🔴 2026-10-19:原来是 `Scroll().layoutWeight(1)` —— 它让滚动区**吃满剩余高度**, + 于是只有 4 条菜单也会把卡片顶到 `maxHeight`(70%), + 下面留一大片空白(真机截图:菜单之后空半屏)。 + ⇒ `flexShrink(1)`:卡片按内容自适应高, + 菜单多到超过 70% 时这一块才收缩并滚动。 + ⚠️ 不能只删掉 `layoutWeight`:`Column` 的子项默认 `flexShrink = 0`, + 不收缩 ⇒ 超长菜单会**溢出卡片**被裁掉。 */ + .flexShrink(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + } + .width('100%') + .constraintSize({ maxHeight: '70%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 }) + .alignItems(HorizontalAlign.Start) } - .width('100%') - .padding({ top: 10 }) } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - .align(Alignment.TopStart) + .width('92%') } .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) + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) } /** diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index 9d8a97c..a1c9ed0 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -16,9 +16,11 @@ */ import { FbColors, FbMetric } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbAvatar, FbRating } from './Widgets'; +import { FbSheetRing } from './FbSheetRing'; import { fbImageSrc } from '../data/FbAttachQueue'; /** @@ -46,6 +48,22 @@ export struct FbMovieDetailSheet { @Event onClose: () => void = (): void => { }; + /** + * 卡片外圈描边环的颜色(hex,'' = 用观影模块色)。 + * + * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 + */ + @Param accent: string = ''; + + /** 环色:显式给了就用给的,否则取观影模块色 */ + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('movies'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + // ---- 全屏大图查看器 ---- /** 查看器是否打开 */ @Local viewerOpen: boolean = false; @@ -127,13 +145,20 @@ export struct FbMovieDetailSheet { // **同一个 Stack 里的后声明节点**,不能放进浮层内部的 Column // (那会被浮层的圆角裁剪、也会被内层 Scroll 的滚动带走)。 Stack({ alignContent: Alignment.Center }) { - Column({ space: 0 }) { - this.sheetHeader() - this.sheetBody() + /* ⚠️ 2026-10-19:外面这一层只给宽度(92%)—— + 以前卡片是 `width('100%')`(贴屏幕两边)+ 内部 Scroll 92%, + 看上去是"一整块通栏白",加环也没处可加(环会跑出屏幕)。 + 现在:卡片 92% + 主题色环,内部滚动区 100%(等价于原来的内容宽度)。 */ + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 0 }) { + this.sheetHeader() + this.sheetBody() + } + .width('100%') + } } - .width('100%') - .backgroundColor(FbColors.surface) - .borderRadius(FbMetric.radius) + .width('92%') // ⚠️ 同样用 `Default`(不写)。`Block` 会挡住浮层内部的子节点 // (照片缩略图、关闭按钮都点不动)。 // `Default` 已足够:浮层在遮罩之后声明 ⇒ 是兄弟节点、会被优先命中。 @@ -498,7 +523,9 @@ export struct FbMovieDetailSheet { // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 - .width('92%') + /* ⚠️ 100%(不是 92%):卡片本身已经是 92% 宽, + 这里再 92% 就变成 84.6%("卡片比容器窄一圈"的经典错)。 */ + .width('100%') .constraintSize({ maxHeight: '72%' }) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index 366bece..7b0ef89 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -22,7 +22,9 @@ */ import { hilog } from '@kit.PerformanceAnalysisKit'; -import { FbColors, FbMetric, FB_HAIRLINE, FB_MASK, FB_SHADOW } from '../common/Theme'; +import { FbColors, FbMetric, FB_MASK } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; +import { FbSheetRing } from './FbSheetRing'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from '../data/FbHttp'; @@ -128,6 +130,23 @@ export struct FbPersonEditSheet { * 没有"必须先建标签"的限制)⇒ 下面给了"新标签"输入框 + 加号。 */ @Param knownTags: string[] = []; + + /** + * 卡片外圈描边环的颜色(hex,'' = 用名册模块色)。 + * + * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 + */ + @Param accent: string = ''; + + /** 环色:显式给了就用给的,否则取名册模块色(与简要卡/详情卡同一个色系) */ + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('people'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + /** 取消 */ @Event onCancel: () => void = (): void => { }; @@ -519,7 +538,10 @@ export struct FbPersonEditSheet { }) } - /** 通用小开关(闰月 / 虚拟人物) */ + /** 通用小开关(闰月 / 虚拟人物) + * ⚠️ 外层间距用 `margin` 而不是父容器的 `space`:这两个开关在 `Flex(wrap)` 里, + * `Flex` 没有 `space` 参数;margin 在换行后也能保持间距。 + */ @Builder toggleChip(label: string, on: boolean, onTap: () => void) { Row({ space: 6 }) { @@ -531,6 +553,7 @@ export struct FbPersonEditSheet { .fontColor(on ? Color.White : FbColors.muted) } .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .margin({ right: 8, bottom: 6 }) .backgroundColor(on ? '#4A7C82' : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .onClick((): void => { @@ -679,8 +702,14 @@ export struct FbPersonEditSheet { build() { // 🔴 外层 `Stack` 是**必需**的:本组件有**两个**要显示的层 // (编辑卡片 + 删除确认遮罩),而 build() 只能有一个根节点。 - // ⚠️ `alignContent: Bottom` 让编辑卡片贴底(与详情卡片同一形态)。 - Stack({ alignContent: Alignment.Bottom }) { + // + // 🔴 2026-10-19 用户:「编辑,也弄成弹框,加线,保持一致。」 + // ⇒ 原来是"贴底满宽 + 只有上面两个角圆",看起来是**抽屉**而不是弹框, + // 和简要卡/详情卡并排切换时观感断层。 + // 现在与它们完全同一种形态:**92% 宽 + 四角圆角 + 屏幕居中 + 主题色环**。 + // ⚠️ 居中对键盘不友好?——不会:输入框获得焦点时系统会整体上推页面, + // 居中卡片反而不会被键盘压住(贴底卡才会)。真机验证过。 + Stack({ alignContent: Alignment.Center }) { this.editCard() // ---- 删除确认(页面内遮罩,盖在卡片之上)---- @@ -695,7 +724,11 @@ export struct FbPersonEditSheet { /** 编辑卡片本体 */ @Builder editCard() { - Column({ space: 0 }) { + // ⚠️ 外面这一层只给**宽度**(92%):卡片自己必须是 100%, + // 否则 0.92×0.92 ≈ 84.6%,会比简要卡/详情卡窄一圈。 + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 0 }) { // ---- 标题 ---- Row({ space: 12 }) { FbAvatar({ @@ -751,7 +784,14 @@ export struct FbPersonEditSheet { this.field('生日(农历)', this.fBirthdayLunar, '如 1985-10-05', (v: string): void => { this.fBirthdayLunar = v; }) - Row({ space: 8 }) { + /* 🔴 2026-10-19:原来是一个 `Row({space:8}).width('100%')` —— + 两个小开关并排时,"虚拟人物(还没弄清真实身份)"这一条**放不下**, + Row 只横向排、放不下就**直接溢出**,被卡片 `clip` 裁掉半句话 + (真机截图里就是"…还没弄清真实"戛然而止)。 + ⇒ 换成 `Flex(wrap)`:放不下就**换行**,两个都不丢。 + ⚠️ 别改成给文字加 `maxLines(1)` + 省略号 —— 那句话是**解释功能**的, + 截成"虚拟人物(还没弄清…"等于没解释。 */ + Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) { this.toggleChip('农历闰月', this.fLunarLeap, (): void => { this.fLunarLeap = !this.fLunarLeap; }) @@ -973,11 +1013,17 @@ export struct FbPersonEditSheet { } } .width('100%') + /* ⚠️ 高度上限写在**卡片自己**身上(不是环层):卡片里有 + `Scroll().layoutWeight(1)`,它要靠父容器的确定高度算"还能滚多少"。 + 挪到外面 ⇒ 内层高度变成内容驱动 ⇒ 滚动区塌成一条线。 */ .constraintSize({ maxHeight: '82%' }) - .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) - .backgroundColor(FbColors.surface) - .borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius }) + /* 居中形态**不再需要**底部手势区缓冲(FB_SHEET_BOTTOM_GAP): + 卡片不贴屏幕底,手势区碰不到它。 */ + .padding({ left: 18, right: 18, top: 18, bottom: 20 }) .alignItems(HorizontalAlign.Start) + } + } + .width('92%') } /** @@ -991,7 +1037,12 @@ export struct FbPersonEditSheet { @Builder deleteConfirm() { Column() { - Column({ space: 8 }) { + /* 🔴 2026-10-19:确认框同样加**主题色环** —— + 用户要的是"所有弹框都加线",这一层虽然小,但它也是弹框; + 漏掉它就又出现"有的有线、有的没线"的不一致。 */ + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 8 }) { Text('删除「' + (this.person !== null ? this.person.shown : '') + '」?') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) @@ -1028,17 +1079,13 @@ export struct FbPersonEditSheet { } .width('100%') .margin({ top: 10 }) + } + .width('100%') + .padding(18) + .alignItems(HorizontalAlign.Start) + } } .width('100%') - .padding(18) - .backgroundColor(FbColors.surface) - .borderRadius(FbMetric.radius) - /* 🔴 v59c:与 `FbPersonSheet` / `FbPersonQuickSheet` 同一套"三层分离" - (用户 2026-10-18:「白色和下面列表的白色融合在一起,看不出是弹框」)。 - ⚠️ 三个浮层必须**同手段**,否则从简要卡 → 编辑切过去会突然"变平"。 */ - .shadow({ radius: 24, color: FB_SHADOW, offsetX: 0, offsetY: 6 }) - .border({ width: 1, color: FB_HAIRLINE }) - .alignItems(HorizontalAlign.Start) } .width('100%') .height('100%') diff --git a/entry/src/main/ets/components/FbPersonQuickSheet.ets b/entry/src/main/ets/components/FbPersonQuickSheet.ets index d8639fe..5528125 100644 --- a/entry/src/main/ets/components/FbPersonQuickSheet.ets +++ b/entry/src/main/ets/components/FbPersonQuickSheet.ets @@ -36,24 +36,14 @@ * 这些都是**页面**的职责。浮层之间也不互相引用(免得成环)。 */ -import { FbColors, FbMetric, FB_SHADOW, fbTint, FbTint, fbAlpha } from '../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint, fbAlpha } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbRosterPerson } from '../data/FbPersonRepo'; import { FbAvatar, FbTag } from './Widgets'; +import { FbSheetRing } from './FbSheetRing'; import { fbMediaUrl } from '../data/FbHttp'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; -/** 卡片阴影半径(与详情浮层同一档观感) */ -const CARD_SHADOW: number = 24; - -/** - * 主题色描边环的宽度(vp)。 - * - * ⚠️ 2vp 是"看得见但不喧哗"的那一档:1vp 在深色遮罩上仍显得细弱, - * 3vp 以上就变成"彩色相框",抢了内容和按钮的注意力。 - */ -const RING_W: number = 2; - /** * 信息行之间的间距(vp)。 * @@ -300,115 +290,112 @@ export struct FbPersonQuickSheet { build() { Column() { /* 🔴 ② 主题色描边环(用户 2026-10-18 点名要的那一圈) - ⚠️ 它是"外层铺色 + 内边距",不是 `border` —— 见文件头说明。 */ + ⚠️ 2026-10-19 起改成**共用组件** `FbSheetRing`: + 用户要求"所有弹框都加线、保持一致" ⇒ 环宽/圆角/阴影只能有一处定义。 + 原来这里是手写的"外层铺色 + padding",两者外观一样,但两份实现必然漂移。 */ Column() { - Column({ space: 14 }) { - // ---- 头像 + 姓名 + 称谓 ---- - Row({ space: 14 }) { - FbAvatar({ - name: this.person !== null ? this.person.name : '', - color: this.person !== null ? this.person.avatarColor : '#A84A28', - dia: 54, - fontSizeVp: 22, - imageUrl: this.avatarSrc() - }) - Column({ space: 5 }) { - Row({ space: 8 }) { - Text(this.title()) - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - .maxLines(1) - .flexShrink(1) - if (this.person !== null && this.person.label !== '') { - FbTag({ text: this.person.label, tone: 'muted' }) + FbSheetRing({ radius: FbMetric.radius, ringColor: this.brand() }) { + Column({ space: 14 }) { + // ---- 头像 + 姓名 + 称谓 ---- + Row({ space: 14 }) { + FbAvatar({ + name: this.person !== null ? this.person.name : '', + color: this.person !== null ? this.person.avatarColor : '#A84A28', + dia: 54, + fontSizeVp: 22, + imageUrl: this.avatarSrc() + }) + Column({ space: 5 }) { + Row({ space: 8 }) { + Text(this.title()) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .maxLines(1) + .flexShrink(1) + if (this.person !== null && this.person.label !== '') { + FbTag({ text: this.person.label, tone: 'muted' }) + } + } + .alignItems(VerticalAlign.Center) + .width('100%') + + if (this.person !== null && this.person.name !== this.title()) { + Text('本名 ' + this.person.name) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) } } - .alignItems(VerticalAlign.Center) - .width('100%') + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) - if (this.person !== null && this.person.name !== this.title()) { - Text('本名 ' + this.person.name) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) + // ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)---- + // + // 🔴 用户 2026-10-18:「电话和单位什么的距离太近了,再拉远一点。」 + // ⇒ `INFO_GAP`(16)+ 每行自己的上下 3vp 内边距。 + Column({ space: INFO_GAP }) { + if (this.mainPhone() !== '') { + // ③ 主电话:**可点即拨**(其余电话仍只是文字,避免误触) + this.callRow(this.mainPhone()) + } + if (this.otherPhones() !== '') { + this.infoRow($r('app.media.fb_ic_user'), '其他', this.otherPhones()) + } + if (this.person !== null && this.person.email !== '') { + this.infoRow($r('app.media.fb_ic_notes'), '邮箱', + this.person !== null ? this.person.email : '') + } + if (this.jobText() !== '') { + this.infoRow($r('app.media.fb_ic_people'), '单位', this.jobText()) + } + if (this.birthdayText() !== '') { + this.infoRow($r('app.media.fb_ic_days'), '生日', this.birthdayText()) } } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - .flexShrink(1) - } - .width('100%') - .alignItems(VerticalAlign.Center) + .width('100%') - // ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)---- - // - // 🔴 用户 2026-10-18:「电话和单位什么的距离太近了,再拉远一点。」 - // ⇒ `INFO_GAP`(16)+ 每行自己的上下 3vp 内边距。 - Column({ space: INFO_GAP }) { - if (this.mainPhone() !== '') { - // ③ 主电话:**可点即拨**(其余电话仍只是文字,避免误触) - this.callRow(this.mainPhone()) - } - if (this.otherPhones() !== '') { - this.infoRow($r('app.media.fb_ic_user'), '其他', this.otherPhones()) - } - if (this.person !== null && this.person.email !== '') { - this.infoRow($r('app.media.fb_ic_notes'), '邮箱', - this.person !== null ? this.person.email : '') - } - if (this.jobText() !== '') { - this.infoRow($r('app.media.fb_ic_people'), '单位', this.jobText()) - } - if (this.birthdayText() !== '') { - this.infoRow($r('app.media.fb_ic_days'), '生日', this.birthdayText()) + // ---- 四个动作:详情 / 编辑 / 分享 / 关闭 ---- + // + // 🔴 「删除」**不在这里**(用户 2026-10-18:太容易误触)—— + // 它挪到了完整详情卡片的最下面(`FbPersonSheet` 的 `onDelete`)。 + // ⚠️ 「关闭」也做成**同等大小的按钮**(而不是一行文字链接): + // 四个动作同一个视觉权重,用户不用在"哪个是按钮"上犹豫。 + // 它是灰色(`inputBg` + 次要字色)—— 语义上"这一下什么都不改"。 + Row({ space: 8 }) { + this.actionBtn('详情', FbTint.white, this.brand(), (): void => { + this.onDetail(); + }) + this.actionBtn('编辑', this.brand(), fbAlpha(this.brand(), 0.10), (): void => { + this.onEdit(); + }) + this.actionBtn('分享', this.brand(), fbAlpha(this.brand(), 0.10), (): void => { + this.onShare(); + }) + this.actionBtn('关闭', FbTint.muted, INPUT_BG, (): void => { + this.onClose(); + }) } + .width('100%') } + /* 🔴 卡片本体:见文件头「三层分离」的说明。 + ⚠️ 宽度 **100%**(不是 92%):92% 由**外面那圈环**负责, + 这里再写 92% 就成了 0.92×0.92 ≈ 84.6%,卡片会比详情卡窄一圈。 + ⚠️ 圆角与阴影**不在这里**:它们和环是同一套几何,统一由 `FbSheetRing` 给 + (两份写必然漂移,这正是用户说的"不一致"的来源)。 */ .width('100%') - - // ---- 四个动作:详情 / 编辑 / 分享 / 关闭 ---- - // - // 🔴 「删除」**不在这里**(用户 2026-10-18:太容易误触)—— - // 它挪到了完整详情卡片的最下面(`FbPersonSheet` 的 `onDelete`)。 - // ⚠️ 「关闭」也做成**同等大小的按钮**(而不是一行文字链接): - // 四个动作同一个视觉权重,用户不用在"哪个是按钮"上犹豫。 - // 它是灰色(`inputBg` + 次要字色)—— 语义上"这一下什么都不改"。 - Row({ space: 8 }) { - this.actionBtn('详情', FbTint.white, this.brand(), (): void => { - this.onDetail(); - }) - this.actionBtn('编辑', this.brand(), fbAlpha(this.brand(), 0.10), (): void => { - this.onEdit(); - }) - this.actionBtn('分享', this.brand(), fbAlpha(this.brand(), 0.10), (): void => { - this.onShare(); - }) - this.actionBtn('关闭', FbTint.muted, INPUT_BG, (): void => { - this.onClose(); - }) - } - .width('100%') + /* 高度上限写在**卡片自己**身上(不是外面的环层)—— + 环只负责视觉,尺寸归卡片;这条口径全应用统一, + 免得"有的浮层限在外层、有的限在内层"以后又漂移。 */ + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 18 }) + .alignItems(HorizontalAlign.Start) } - /* 🔴 卡片本体:见文件头「三层分离」的说明。 - ⚠️ 宽度 **100%**(不是 92%):92% 由**外面那圈环**负责, - 这里再写 92% 就成了 0.92×0.92 ≈ 84.6%,卡片会比详情卡窄一圈。 - ⚠️ 四角圆角 + 居中 —— 与 `FbPersonSheet` 同一套观感 - (用户 2026-10-16 定:"圆角浮窗式、不占满宽、浮在屏幕中间")。 */ - .width('100%') - .constraintSize({ maxHeight: '72%' }) - .padding({ left: 18, right: 18, top: 18, bottom: 18 }) - .backgroundColor(FbColors.surface) - /* ⚠️ 内层圆角要**比外层小一档**(14 vs 16):两圈同心圆角差值 - = 环宽 2,否则环在内侧会显厚、外侧会显薄(看着像描边歪了)。 */ - .borderRadius(FbMetric.radius) - /* ③ 阴影:把卡片从"被压暗的列表"上抬起来 */ - .shadow({ radius: CARD_SHADOW, color: FB_SHADOW, offsetX: 0, offsetY: 6 }) - .alignItems(HorizontalAlign.Start) } .width('92%') - .padding(RING_W) - .backgroundColor(this.brand()) - .borderRadius(FbMetric.radius + RING_W) - .alignItems(HorizontalAlign.Center) } .width('100%') .height('100%') diff --git a/entry/src/main/ets/components/FbPersonSheet.ets b/entry/src/main/ets/components/FbPersonSheet.ets index 3d64c47..63c5294 100644 --- a/entry/src/main/ets/components/FbPersonSheet.ets +++ b/entry/src/main/ets/components/FbPersonSheet.ets @@ -24,9 +24,11 @@ * 只是缺那几项详情专属字段,界面不会空。 */ -import { FbColors, FbMetric, fbTint, FbTint, FB_HAIRLINE, FB_SHADOW, fbAlpha } from '../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint, fbAlpha } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FbAvatar, FbTag } from './Widgets'; +import { FbSheetRing } from './FbSheetRing'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { fbImageSrc } from '../data/FbAttachQueue'; @@ -114,6 +116,26 @@ export struct FbPersonSheet { */ @Param embedded: boolean = false; + /** + * 卡片外圈描边环的颜色(hex)。 + * + * 不传则用**名册模块**的主题色 —— 本组件展示的是"人的资料", + * 不管从哪个页面调起(名册 / 顶栏「我的资料」),环色都该是同一个色系。 + * + * ⚠️ 必须是字符串:`FbSheetRing` 的 `ringColor` 要直接当背景色用, + * 而 `FbColors.brand` 是 `Resource`(不能做 `@Param` 默认值参与运算)。 + */ + @Param accent: string = ''; + + /** 环色:显式给了就用给的,否则取名册模块色 */ + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('people'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + /** 一行的图标资源 */ private iconOf(name: string): Resource { switch (name) { @@ -343,249 +365,256 @@ export struct FbPersonSheet { // 宿主 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' }) + // ⚠️ 2026-10-19:**卡片外面那一圈主题色环**由共用组件 `FbSheetRing` 给 + // (用户要求"所有弹框都加线,保持一致")。 + // 这一层只负责**尺寸**(92% 宽 / 72% 高上限)—— 尺寸与外观分开, + // 环组件里就不必再堆一堆 `if (embedded)`。 + Column() { + FbSheetRing({ + radius: FbMetric.radius, + ringColor: this.accentColor(), + /* embedded 是"塞进 bindSheet"的形态:宿主自己已有容器与投影, + 再叠一层阴影会显脏(真机上看着像卡片边缘发黑)。 */ + shadowRadius: this.embedded ? 0 : 24 + }) { + 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.name !== this.person.shown) { - Text('本名 ' + this.person.name) - .fontSize(FbMetric.fontTiny) + // ---- 标签 ---- + // ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的 + // 信息,跟着资料一起滚走反而不方便。详情里的"长内容"是资料行(见下面)。 + 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(); + }) } - // 称谓(服务端按家族图谱推导) - if (this.person !== null && this.person.label !== '') { - FbTag({ text: this.person.label, tone: 'muted' }) - } - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - } - .width('100%') - .alignItems(VerticalAlign.Center) + .width('100%') + .margin({ top: 18 }) - // ---- 标签 ---- - // ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的 - // 信息,跟着资料一起滚走反而不方便。详情里的"长内容"是资料行(见下面)。 - 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) + /* ---- 危险区:删除联系人 ---- + * + * 🔴 用户 2026-10-18:「应该在详情页面的最下面加一个删除按钮。」 + * ⇒ 位置在**编辑/关闭之下**、并且与它们之间隔一条分隔线: + * 手指从上往下扫时不会顺手点到;想删必须**再往下够**一次。 + * ⚠️ 视觉上只给文字 + 图标(不做成大红块): + * 它是"偶尔用一次"的操作,大红按钮会抢走"编辑"的注意力。 + * ⚠️ 确认框在页面侧(`FbPeoplePage.confirmDelete`)—— 见 `onDelete` 的说明。 */ + if (this.canDelete) { + Divider() + .color(FbColors.line) + .margin({ top: 14 }) + + Row({ space: 6 }) { + Image($r('app.media.fb_ic_trash')) + .width(14) + .height(14) + /* ⚠️ PNG 图标必须 colorFilter(fillColor 只对 SVG 生效) */ + .colorFilter(fbTint(RED)) + Text('删除联系人') + .fontSize(FbMetric.fontSmall) + .fontColor(RED) + .fontWeight(FontWeight.Medium) + } + .width('100%') + .height(42) + .justifyContent(FlexAlign.Center) + .backgroundColor(fbAlpha(RED, 0.08)) + .borderRadius(FbMetric.radiusS) + .margin({ top: 10 }) + .onClick((): void => { + this.onDelete(); + }) + } } + // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… + // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, + // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 + // ⇒ 不再贴底满宽:左右留边(92%)、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + // + // ⚠️ 宽度由**这一层**给(不是卡片自己):卡片在环里必须是 100%, + // 否则 0.92×0.92 ≈ 84.6%,会比别的浮层窄一圈。 + // ⚠️ `embedded`(宿主自己就是 sheet)时宽度交给 sheet 决定 —— + // 再用 92% 会在 sheet 里又缩一次,看上去"卡片比容器窄一圈"。 .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-18:「应该在详情页面的最下面加一个删除按钮。」 - * ⇒ 位置在**编辑/关闭之下**、并且与它们之间隔一条分隔线: - * 手指从上往下扫时不会顺手点到;想删必须**再往下够**一次。 - * ⚠️ 视觉上只给文字 + 图标(不做成大红块): - * 它是"偶尔用一次"的操作,大红按钮会抢走"编辑"的注意力。 - * ⚠️ 确认框在页面侧(`FbPeoplePage.confirmDelete`)—— 见 `onDelete` 的说明。 */ - if (this.canDelete) { - Divider() - .color(FbColors.line) - .margin({ top: 14 }) - - Row({ space: 6 }) { - Image($r('app.media.fb_ic_trash')) - .width(14) - .height(14) - /* ⚠️ PNG 图标必须 colorFilter(fillColor 只对 SVG 生效) */ - .colorFilter(fbTint(RED)) - Text('删除联系人') - .fontSize(FbMetric.fontSmall) - .fontColor(RED) - .fontWeight(FontWeight.Medium) - } - .width('100%') - .height(42) - .justifyContent(FlexAlign.Center) - .backgroundColor(fbAlpha(RED, 0.08)) - .borderRadius(FbMetric.radiusS) - .margin({ top: 10 }) - .onClick((): void => { - this.onDelete(); + /* 最多占屏幕 72%:上方留出可见列表,暗示"这是浮层、下面还有内容"。 + ⚠️ 这个限制**必须写在卡片自己身上**(不能交给外面的环层): + 卡片内部有 `Scroll().layoutWeight(1)`,它要靠**父容器的确定高度** + 才能算出"剩下多少可以滚"。把 maxHeight 挪到外面那层, + 内层高度就变成"由内容决定"⇒ `layoutWeight` 拿到 0 ⇒ + **资料区直接塌成一条线**(编辑卡同理,别一起挪)。 */ + .constraintSize({ maxHeight: this.embedded ? '100%' : '72%' }) + // ⚠️ embedded 时**去掉底部手势区那段留白**:那 16 是给 + // "贴底遮罩 + 隐藏手势条"的场景补的(见 FB_SHEET_BOTTOM_GAP), + // 而 bindSheet 自己已经把手势区让出来了 ⇒ 再补就是白白多一截空白。 + .padding({ + left: 18, right: 18, top: 18, + bottom: this.embedded ? 20 : 20 + FB_SHEET_BOTTOM_GAP }) + .alignItems(HorizontalAlign.Start) } } - // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… - // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, - // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 - // ⇒ 不再贴底满宽:左右留边(92%)、四角圆角、垂直居中(由宿主的 Stack 对齐)。 - // - // ⚠️ `embedded`(宿主自己就是 sheet)时宽度交给 sheet 决定 —— - // 再用 92% 会在 sheet 里又缩一次,看上去"卡片比容器窄一圈"。 .width(this.embedded ? 'auto' : '92%') - // 最多占屏幕 72%:上方留出可见列表,暗示"这是浮层、下面还有内容" - .constraintSize({ maxHeight: this.embedded ? '100%' : '72%' }) - // ⚠️ embedded 时**去掉底部手势区那段留白**:那 16 是给 - // "贴底遮罩 + 隐藏手势条"的场景补的(见 FB_SHEET_BOTTOM_GAP), - // 而 bindSheet 自己已经把手势区让出来了 ⇒ 再补就是白白多一截空白。 - .padding({ - left: 18, right: 18, top: 18, - bottom: this.embedded ? 20 : 20 + FB_SHEET_BOTTOM_GAP - }) - .backgroundColor(FbColors.surface) - .borderRadius(FbMetric.radius) - /* 🔴 v59c(用户 2026-10-18):「这个卡片弹出来之后是白色的,和下面列表 - * 中的白色都融合在一起,根本看不出是弹框。」 - * - * 原因:原来只靠宿主那层 `#00000066` 遮罩区分 —— 而本应用**列表本身 - * 就是大面积白底**,白卡压在"被压暗的白"上,边界仍然发飘。 - * ⇒ 补两手(与 `FbPersonQuickSheet` 完全同一套,两级浮层观感一致): - * ① 阴影:把卡片从背景上**抬起来**(有投影才有"浮在空中"的暗示); - * ② 极浅描边:同色背景上仍然有一条**可见的界线**。 - * ⚠️ 只加阴影是不够的:浅色底上的投影很淡,真机上常常看不出来; - * 描边才是"稳"的那一手(它不依赖光照假设)。 - */ - .shadow({ radius: 24, color: FB_SHADOW, offsetX: 0, offsetY: 6 }) - .border({ width: 1, color: FB_HAIRLINE }) - .alignItems(HorizontalAlign.Start) } /* 🔴 embedded(宿主自己就是 sheet)时**不能再铺满 + 居中**: 内容是"按自适应高度"交给 sheet 的,铺 100% 高会与 diff --git a/entry/src/main/ets/components/FbSheetRing.ets b/entry/src/main/ets/components/FbSheetRing.ets new file mode 100644 index 0000000..7bc5eb4 --- /dev/null +++ b/entry/src/main/ets/components/FbSheetRing.ets @@ -0,0 +1,86 @@ +/** + * 家书 FamilyBook · 浮层卡片的「主题色描边环」统一容器(鸿蒙端) + * + * 🔴 用户 2026-10-19 原话: + * 「既然弹框加了线,那就**所有弹框都加线**,编辑,也弄成弹框,加线,保持一致。」 + * + * ⇒ 以前只有「简要卡」自己手写了一层环(外层 `Column` 铺色 + `padding` + 内层白卡)。 + * 手写一次没问题,**手写十次必然漂移**(环宽/圆角/阴影/边距一处一个样)—— + * 所以抽成这个组件:**所有浮层都套它**,环宽和圆角只在这里定义。 + * + * ⚠️ 为什么是"外层铺色 + 内边距",而不是给白卡加 `border`: + * `border` 画在**盒子内侧**,会把卡片内容区撑窄 2vp,且圆角处顺着**内圆角**走, + * 看着像"白卡描了彩边";用户要的是"**外面**加一圈" ⇒ 必须垫一层。 + * (`FbPersonQuickSheet` 第一版就踩过,注释里留着证据。) + * + * ⚠️ `clip(true)` 不是装饰:卡片内部有 `Scroll`(详情/编辑都很长), + * 不裁的话滚动内容会**越过圆角**画到环上,看着像"内容从卡片里漏出来"。 + * 如果调用方内部还要往外"溢出显示"(如全屏图片查看器),传 `clipContent: false` + * —— 并且查看器必须放在**这个组件之外**的兄弟层(现在 `FbMovieDetailSheet` 就是这么做的)。 + * + * ⚠️ 本组件只管"外观"(宽度由调用方给、内容由尾随闭包给): + * 它**不设** maxHeight / padding / 背景层次 —— 那些属于各卡片自己的版式。 + */ + +import { FbColors, FbMetric, FB_SHADOW, FbTint } from '../common/Theme'; + +/** + * 环的宽度(vp)—— **全应用唯一一处定义**。 + * + * ⚠️ 2vp 是"看得见但不喧哗"的那一档:1vp 在深色遮罩上仍显得细弱(真机上像一条发丝), + * 3vp 以上就变成"彩色相框",抢了内容和按钮的注意力。 + * ⚠️ 内层圆角 = `radius`,外层圆角 = `radius + 环宽`: + * 两圈同心圆角的差值必须正好等于环宽,否则环在内侧显厚、外侧显薄(看着像描边歪了)。 + */ +export const FB_RING_W: number = 2; + +@ComponentV2 +export struct FbSheetRing { + /** 卡片(内层)圆角;环层自动 +FB_RING_W */ + @Param radius: number = FbMetric.radius; + + /** + * 环的颜色 —— 各模块的主题色(砖红 / 青 / 靛蓝…)。 + * + * ⚠️ 必须是 **hex 字符串**(不能用 `Resource`):这里要直接当背景色用, + * 而默认值在 ArkTS 里也必须是常量表达式。 + */ + @Param ringColor: string = FbTint.brand; + + /** + * 阴影半径(vp)。0 = **不画阴影**。 + * + * ⚠️ 嵌在别的卡片里时(顶栏「我的资料」塞进 bindSheet)要传 0: + * 宿主已经有自己的容器与投影,再叠一层会显得脏。 + */ + @Param shadowRadius: number = 24; + + /** + * 是否裁剪内容到圆角。 + * + * 默认 `true`;只有在卡片内部还有"要溢出显示"的层时传 `false` + * (见文件头 `clip` 的说明)。 + */ + @Param clipContent: boolean = true; + + /** 卡片本体(尾随闭包传入) */ + @BuilderParam content: () => void; + + build() { + Column() { + Column() { + this.content() + } + .width('100%') + .backgroundColor(FbColors.surface) + .borderRadius(this.radius) + .clip(this.clipContent) + } + .width('100%') + .padding(FB_RING_W) + .backgroundColor(this.ringColor) + .borderRadius(this.radius + FB_RING_W) + .alignItems(HorizontalAlign.Center) + .shadow({ radius: this.shadowRadius, color: FB_SHADOW, offsetX: 0, offsetY: 6 }) + } +}