v62:修 P0 登录被踢 / 关闭按钮统一右上角 / 观影三处 / 影院管理补齐 / 名册三处

用户 2026-10-19 第二批八条(原话逐条对应):

═══ ⑨ P0:「现在还是不稳定,会退出,提示:这台设备已经被退出登录,请重新登录。」

真因**是我 v61 自己埋的**:「个人设置」页(`FbAccountPage`)里两处 `fbApi`
调用**取了 `token` 变量却没传进请求选项** ⇒ 没有 Authorization 头 ⇒ 服务端 401
⇒ `FbHttp` 的全局回调 `unauthorizedCb` **清掉本地令牌 + 踢回登录页**。
而本页在 `aboutToAppear` 里就调它 ⇒ **点一下右上角头像,App 就退出登录**。

⚠️ 我上一轮把同一现象归因成"残留旧令牌 / 设备会话被顶掉"(还去线上查了
   `api_tokens` 表,一切正常)—— **那个结论是错的**,等于把 bug 归给了环境。

修法(两处调用 + **两道架构防线**,缺一不可):
  ① 修两处调用,把 token 传进去;
  ② `fbApi` 内部:**本地有令牌、调用方又没传时自动补上**
     (大部分接口本来就要鉴权,补上是"纠正";`/login`、`/ping` 带了也无害);
  ③ **401 只在"确实带了令牌"时才算被踢** —— 否则"没带令牌"也会触发全局清令牌,
     那正是这次事故的机制。

═══ ④「你这个按钮到底是在弹框的右上角关闭,还是在下面关闭……全放在右上角。」

选**右上角 ✕**(底部已被 编辑/保存/删除 占满,再塞关闭会堆三四个按钮,分不清主操作)。
新增 `components/FbSheetClose.ets`:
  · `FbSheetClose`  —— 32vp 圆形 ✕(热区 40+,小图标在手机上点不中);
  · `FbSheetTitleBar` —— 左标题 + 右收尾(设置类给 `confirmText` 显示「完成」)。
9 个浮层全接;名册详情 / 日程详情 / 简要卡**底部不再有「关闭」**。
⚠️ 浅色底走 `fbAlpha`,**不能拼 `+ '14'`** —— ArkUI 八位色是 `#AARRGGBB`
   (本项目曾因写 `#00000066` 导致遮罩**从来没显示过**)。

═══ ①「观影……弹出的框左上角有一条灰色的线,不知道是什么。」

那是浮层顶部的**抽屉把手**(36×4 的 `line2` 浅灰短线)。本意是"可下拉关掉"的提示,
但 ① 它落在左上角、旁边没有文字;② 这个浮层的关闭本来就不靠下拉。
⇒ 用户认不出它 = 它没起作用,**删掉**(不改成居中 —— 那只是把灰线挪个位置)。

═══ ②「然后这个观影人的头像并没有带进来。」

同看人的 `FbAvatar` **漏传 `imageUrl`** —— 服务端 `Movie::attendees()` 本来就
`SELECT p.avatar`、端上 `parseAttendees` 也解析进了 `a.avatar`,
纯粹是**渲染时忘了用它** ⇒ 有头像的人也退化成色块。
⚠️ 与名册详情 2026-10-15 那个是同一个 bug(当时是 `FbPersonSheet` 漏传)。

═══ ③「点击这个图片呢,不能左右滑动看所有的这个图片。」

全屏查看器原来**只有底部「上一张/下一张」文字按钮**。现在整张大图左右滑翻页。
⚠️ 用 `.gesture(PanGesture(Horizontal))` 而不是 `SwipeGesture`:
   `SwipeGesture` 要"快速划动"才识别,在有滚动容器的页面容易被先吃掉
   (日程页实测:`gesture`/`priorityGesture` 都不触发)。`PanGesture` 看**累计位移**。
⚠️ 判定要求 `|dx| >= 40 && |dx| > |dy|`,否则竖划会被误判成翻页。

═══ ⑤「影院管理……增加影院呢、修改影院呢、高级搜索、统计、年度报告,这些全部都应该补充进去。」

端上原来**只有一个只读列表**(连"新增"都没有)。补成四个页签:
  · **影院**:新增 / 修改 / 删除(服务端 CRUD 早就有,端上一直没接)+ 删除二次确认;
  · **统计**:总览(看过/影院/人数/人次)+ 按影院/按年/按月的条形图;
  · **搜索**:片名 / 影院 / 起止日期(条件全空时不查,与网页 `hasCond` 同行为);
  · **年报**:按年切换(第一场/最后一场、最常去的影院、一起看最多的人、每月分布)。
⚠️ **统计/年报必须联网**(`/movies/stats|report`):口径要与网页**完全一致**
   (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分",
   少同步一次就少一部 ⇒ 用户会发现"网页 124 部、App 121 部"。
   ⇒ 断网时明说"需要联网",而不是给一个偏低的数字。
⚠️ 影院写操作后**立刻回写本地库**(`refreshCinemas`),否则刚建的那家要等下次同步
   才出现,看着像失败;并用 `INSERT OR REPLACE` 而非"清空再插"
   (中途失败会留下一个空表 = 用户的影院列表整个消失)。

═══ ⑥「名册搜索,搜索后,下面有一个白色的框,什么内容都没有,删除。」

那是列表**末尾给悬浮栏留高度的占位块**(`height(FB_DOCK_RESERVE)`),
底色是 `FbColors.surface`(**白**)而列表容器是 `paper`(**米**)——
列表内容一短(搜索只剩一两条)它就露出来,看着像"什么都没有的白框"。
⇒ 改底色与列表同色。**不能删**:删了最后一张卡片会被悬浮栏压住。

═══ ⑦「如果一个人没头像,在列表是有一个文字的头像的,那么点击后弹框,也应该有。」

`FbAvatar` 原来是 `if/else`:有 `imageUrl` 就**只画 Image**,
而图片**加载失败时 `Image` 什么都不显示**(只露出纯色底)⇒ 看着是个光板。
(`alt()` 只在**加载中**起作用,失败后是空白。)
⇒ 改成**叠加**:底色 + 文字头像**常驻**,图片有就叠在上面
(加载成功即盖住文字,失败则自动露出文字头像)。
⚠️ 之前注释说"图层叠会错位"——那是因为当时想靠 Image 的 intrinsic size 撑尺寸;
   现在两层都显式给 `dia`,尺寸来源唯一,不存在错位。

═══ ⑧「现在名册的列表太空了,应该显示更多内容,例如工作单位、职务。」

列表卡片加一行「单位|职务」(独立成行,避免与标签/生日挤)。
⚠️ 用 `persons.org` / `persons.title` 而**不是** `person_jobs`:
   列表读的是**本地库**,而本地库只存 `persons` 那一行
   (`person_jobs` 是一对多,端上没同步那张表)⇒ 服务端加了字段也到不了列表。
   线上实测 801 人里 192 人有单位、48 人有职务,列表立刻有内容。

验证:
· 编译 BUILD SUCCESSFUL
· ArkTS 守卫 536/0(新增整组 groupV62,含 P0 两道防线)
· 反向验证 51/0(新增 13 条注入,逐条确认能变红)
· Schema 列守卫 12/0
· **真机 22/0**(6 组,含照片横滑 `1/5 → 2/5`、四个页签、点头像不被踢)
This commit is contained in:
2026-10-19 15:27:31 +08:00
parent 853b0e0b20
commit 4a063114c5
16 changed files with 1905 additions and 215 deletions
@@ -29,6 +29,7 @@ import { FbCalBook } from '../model/Types';
import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings';
import { FbCalOptionRow } from './FbCalOptionRow';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetTitleBar } from './FbSheetClose';
import { FbDot } from './Widgets';
const FB_SHEET_BOTTOM_GAP: number = 16;
@@ -133,32 +134,22 @@ export struct FbCalSettingsSheet {
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 0 }) {
// ---- 标题栏 ----
// ---- 标题栏(全应用统一版式:左标题 + 右「完成」,见 FbSheetClose.ets)----
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 => {
FbSheetTitleBar({
title: '日程设置',
/* 🔴 把"当前是什么"直接写在标题下面 ——
用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾) */
subtitle: '当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style),
accent: this.accentColor(),
// ⚠️ 设置类是"改完即生效" ⇒ 用「完成」而不是 ✕
confirmText: '完成',
onClose: (): void => {
this.onClose();
})
}
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 6 }) {
File diff suppressed because it is too large Load Diff
+15 -16
View File
@@ -20,6 +20,7 @@ import { FbCalEventFull } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FbDot } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
const FB_SHEET_BOTTOM_GAP: number = 16;
@@ -227,6 +228,15 @@ export struct FbEventSheet {
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
}
/* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。
⚠️ 放在标题行的最右端,而不是下面独立一行 —— 独立一行会让卡片
顶部多出一段空白,标题反而往下掉。 */
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: ''
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
@@ -301,20 +311,9 @@ export struct FbEventSheet {
.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) {
// ---- 底部按钮(只有动作,关闭统一到右上角 ✕)----
if (this.canEdit && this.event !== null) {
Row({ space: 10 }) {
/* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」
⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。
改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */
@@ -330,9 +329,9 @@ export struct FbEventSheet {
this.onEdit();
})
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.margin({ top: 14 })
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
@@ -20,6 +20,7 @@
import { FbColors, FbMetric, FbIcon, fbTint, FbTint } from '../common/Theme';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetTitleBar } from './FbSheetClose';
/** 贴底卡片的手势区缓冲(vp)—— 同其它浮层
* ⚠️ 2026-10-19 起本浮层改成**居中弹框**,这个常量暂时没有引用;
@@ -80,23 +81,20 @@ export struct FbModuleSettingsSheet {
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 0 }) {
// ---- 标题栏 ----
// ---- 标题栏(全应用统一版式:左标题 + 右「完成」,见 FbSheetClose.ets)----
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 => {
FbSheetTitleBar({
title: this.title,
accent: this.accentColor(),
// ⚠️ 设置类是"改完即生效" ⇒ 用「完成」而不是 ✕(见 FbSheetTitleBar 的说明)
confirmText: '完成',
onClose: (): void => {
this.onClose();
})
}
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 18, right: 12, top: 14, bottom: 6 })
Scroll() {
Column({ space: 0 }) {
@@ -21,6 +21,7 @@ import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbAvatar, FbRating } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
import { fbImageSrc } from '../data/FbAttachQueue';
/**
@@ -184,27 +185,31 @@ export struct FbMovieDetailSheet {
}
/** 浮层顶部:把手 + 关闭 */
/** 浮层顶部:**只有右上角的 ✕**(关闭位置全应用统一)。 */
@Builder
sheetHeader() {
/* 🔴🔴 用户 2026-10-19:「点击一个影片之后,弹出的框**左上角有一条灰色的线**,
不知道是什么。」
⇒ 那是原来放在标题行**最左端**的"把手"(36×4 的浅灰短线,
`FbColors.line2`)。我的本意是"抽屉可以下拉关掉"的视觉提示,
但 ① 它落在左上角、旁边没有任何文字,看起来就是**一条莫名其妙的灰线**;
② 这个浮层的关闭本来就不靠下拉(有按钮)。
⇒ 直接**去掉把手**(用户明确认不出它 = 它没起到提示作用),
同时把关闭收到右上角统一的 ✕(见 `FbSheetClose.ets`)。
⚠️ 不要"改成居中的把手"来救 —— 那只会把灰线从左上角挪到中间,
用户依然不知道它是什么;这一屏真正需要的信息是"怎么关掉我",
而那个就是右上角的 ✕。 */
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 => {
FbSheetClose({
onClose: (): void => {
this.onClose();
})
}
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 8, bottom: 4 })
.padding({ left: 16, right: 16, top: 10, bottom: 2 })
}
/** 浮层主体(三种态) */
@@ -275,10 +280,41 @@ export struct FbMovieDetailSheet {
// ② 大图
// ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图",
// 裁掉边缘就失去意义(缩略图才用 Cover)。
Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
.width('100%')
.height('76%')
.objectFit(ImageFit.Contain)
// 🔴 用户 2026-10-19:「点击这个图片呢,不能左右滑动看所有的这个图片。」
// ⇒ 原来只有底部的「上一张/下一张」文字按钮,**没有滑动手势**。
// 现在整张大图可以左右滑(见下面 `parallelGesture`)。
Stack({ alignContent: Alignment.Center }) {
Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
.width('100%')
.height('76%')
.objectFit(ImageFit.Contain)
/* 🔴🔴 左右滑翻页。
⚠️ 用 **`gesture(PanGesture)`** 而不是 `SwipeGesture`:
本轮在日程页实测过 —— `SwipeGesture` 要求"**快速**划动"才识别,
在有滚动容器/复杂布局的页面里容易被别的手势先吃掉,**慢划不触发**
(`.gesture` 与 `.priorityGesture` 都试过)。而 `PanGesture` 看的是
**累计位移**(`event.offsetX`),只要够长就认,稳得多。
⚠️ 这里用 `.gesture`(普通优先级)而不是 `parallelGesture`:
查看器是**全屏覆盖层**,下面没有需要并行滚动的东西;
而且同一个节点上挂两条方向不同的 `parallelGesture` 会互相压制
(日程页周条的实测结论)。
⚠️ 判定必须要求「横向位移明显大于纵向」:竖着划(想关掉/看上下)时
不该被当成翻页。 */
.gesture(
PanGesture({ direction: PanDirection.Horizontal })
.onActionEnd((event: GestureEvent): void => {
const dx: number = event.offsetX;
const dy: number = event.offsetY;
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) {
return;
}
// 左滑(dx < 0)= 看下一张;右滑 = 上一张(与相册一致)
this.viewerStep(dx < 0 ? 1 : -1);
})
)
}
.width('100%')
.height('76%')
// ③ 底部控制条(靠下对齐)
Row({ space: 18 }) {
@@ -421,7 +457,19 @@ export struct FbMovieDetailSheet {
// 别写死一个灰 —— 那样所有人的头像都是同一个颜色。
color: a.avatarColor !== undefined && a.avatarColor !== ''
? a.avatarColor : '#A84A28',
dia: 34
dia: 34,
/* 🔴🔴 用户 2026-10-19:「然后这个观影人的头像并没有带进来。」
⇒ 同看人这里**只传了名字和底色**,漏了 `imageUrl` ——
于是**有头像的人也退化成色块字**,看起来像"头像没带进来"。
服务端 `Movie::attendees()` 本来就 `SELECT p.avatar`,
端上 `parseAttendees` 也解析进了 `a.avatar`,
纯粹是**渲染时忘了用它**。
⚠️ 与名册详情 2026-10-15 踩的那个是**同一个 bug**
(当时是 `FbPersonSheet` 漏传 `imageUrl`)——
凡是 `FbAvatar` 都要检查是否传了 `imageUrl`。
⚠️ `a.avatar` 是**裸 key**,必须走 `fbImageSrc` 拼绝对地址 +
令牌(`Image()` 不支持自定义请求头,令牌只能进查询串)。 */
imageUrl: a.avatar !== '' ? fbImageSrc(a.avatar, FB_TOKEN_STORE.get()) : ''
})
Text(a.myNickname !== '' ? a.myNickname : a.name)
.fontSize(10)
@@ -41,6 +41,7 @@ import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbRosterPerson } from '../data/FbPersonRepo';
import { FbAvatar, FbTag } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
import { fbMediaUrl } from '../data/FbHttp';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
@@ -329,9 +330,17 @@ export struct FbPersonQuickSheet {
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
/* 🔴 关闭统一到**右上角**(用户 2026-10-19,见 `FbSheetClose.ets`) */
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.brand()
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.alignItems(VerticalAlign.Top)
// ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)----
//
@@ -358,13 +367,15 @@ export struct FbPersonQuickSheet {
}
.width('100%')
// ---- 四个动作:详情 / 编辑 / 分享 / 关闭 ----
// ---- 三个动作:详情 / 编辑 / 分享 ----
//
// 🔴 「删除」**不在这里**(用户 2026-10-18:太容易误触)——
// 它挪到了完整详情卡片的最下面(`FbPersonSheet` 的 `onDelete`)。
// ⚠️ 「关闭」也做成**同等大小的按钮**(而不是一行文字链接):
// 四个动作同一个视觉权重,用户不用在"哪个是按钮"上犹豫。
// 它是灰色(`inputBg` + 次要字色)—— 语义上"这一下什么都不改"。
// 🔴 「关闭」**也不在这里了**(用户 2026-10-19):
// 「你这个按钮到底是在弹框的右上角关闭,还是在下面关闭……全放在右上角。」
// ⇒ 全应用统一成**右上角 ✕**(见 `FbSheetClose.ets` 的选择理由)。
// 底部只留"这一页真正要做的事";关闭是**每个浮层都有的同一动作**,
// 放在固定的右上角,手指不必每次重新找。
Row({ space: 8 }) {
this.actionBtn('详情', FbTint.white, this.brand(), (): void => {
this.onDetail();
@@ -375,9 +386,6 @@ export struct FbPersonQuickSheet {
this.actionBtn('分享', this.brand(), fbAlpha(this.brand(), 0.10), (): void => {
this.onShare();
})
this.actionBtn('关闭', FbTint.muted, INPUT_BG, (): void => {
this.onClose();
})
}
.width('100%')
}
+16 -12
View File
@@ -29,6 +29,7 @@ import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
import { FbAvatar, FbTag } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { fbImageSrc } from '../data/FbAttachQueue';
@@ -400,11 +401,13 @@ export struct FbPersonSheet {
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.flexShrink(1)
if (this.person !== null && this.person.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
// 本名(昵称与不同名时才有意义)
if (this.person !== null && this.person.name !== this.person.shown) {
@@ -419,9 +422,21 @@ export struct FbPersonSheet {
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
/* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。
⚠️ 与头像**同一行**:原来底部那个「关闭」按钮已删 ——
它和「编辑」并排等宽,用户反馈"分不清哪个是主操作"。
⚠️ `alignItems` 用 `Top`:头像 60 高,✕ 32 高,
居中会让它离卡片顶边太远(看着像浮在名字旁边)。 */
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accentColor()
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.alignItems(VerticalAlign.Top)
// ---- 标签 ----
// ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的
@@ -540,17 +555,6 @@ export struct FbPersonSheet {
.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 })
@@ -0,0 +1,161 @@
/**
* 家书 FamilyBook · 浮层的「关闭按钮」统一形态(鸿蒙端)
*
* 🔴 用户 2026-10-19 原话:
* 「我觉得我们应该按照统一的模式去处理这个按钮的问题,就是说你这个按钮到底是在
* 弹框的右上角关闭,还是在下面关闭。名册都是在下面的,有详情有什么的。
* 如果像放在右上角也可以,那就全放在右上角。如果要放在下面,就全放在下面。」
*
* ⇒ 用户给了两个都能接受的方案,要求**全应用只选一个**。
* 我们选「**右上角 ✕**」,理由:
* ① 底部那一条太挤 —— 名册详情底部已经有「编辑」、编辑浮层底部有「删除 + 保存」、
* 日程详情底部有「编辑日程」。再往那儿塞「关闭」会堆成三四个按钮(真机上已经出现
* "分不清哪个是主操作")。右上角是**独立位置**,不与动作按钮抢。
* ② 浮层是"盖在当前页上的临时层",右上角 ✕ 是这个形态的通用心智;
* 而底部按钮留给"这一页真正要做的事"(保存/编辑/删除)。
* ③ 尺寸一致 ⇒ 手指习惯一致:不论打开哪个浮层,关掉它都在**同一个位置**。
*
* ⚠️ 为什么抽组件而不是各浮层写一遍(与 `FbSheetRing` 同一个理由):
* 9 个浮层各写一次 ✕,字号/热区/位置/颜色必然漂移(这一轮就是来找漂移的)。
* 抽出来之后,"关闭长什么样"只在本文件里存在。
*
* ⚠️ 热区必须**不小于 40vp**:真机上 ✕ 这类小图标点中率很低,
* 用户点两下没反应就会以为"关不掉"(本项目在电话行上已踩过同一条)。
* 所以这里给的是 32×32 的圆形底 + 8vp 外边距 ⇒ 实际热区 40×40 以上。
*/
import { FbColors, FbMetric, fbAlpha } from '../common/Theme';
@ComponentV2
export struct FbSheetClose {
/**
* 关掉浮层。
*
* ⚠️ 必传:`@Param` 给不了函数默认值(ArkTS 里函数类型不能用常量表达式初始化),
* 所以这里用必填 + 各调用点都显式给。
*/
@Param onClose: () => void = (): void => {};
/**
* 图标的颜色。
*
* ⚠️ 传 hex 字符串而不是 `Resource`:调用点通常想用**本模块主题色**
* (观影棕 / 名册青),而那些色是 `fbThemeVars()` 算出来的字符串。
*/
@Param color: string = '';
/**
* 是否显示"外面那圈浅底"。
*
* 默认 `true`:白卡上直接放一个灰 ✕ 会显得"飘";一圈 `inputBg` 浅底
* 让它看起来是个**可以按的东西**(用户 2026-10-18:「看着不像能点」)。
*/
@Param filled: boolean = true;
/** 直径(vp)。默认 32;需要更醒目时可传 36 */
@Param dia: number = 32;
build() {
Text('✕')
.fontSize(Math.round(this.dia * 0.46))
.fontColor(this.color !== '' ? this.color : FbColors.muted)
.textAlign(TextAlign.Center)
.width(this.dia)
.height(this.dia)
.backgroundColor(this.filled ? FbColors.inputBg : Color.Transparent)
.borderRadius(this.dia / 2)
.onClick((): void => {
this.onClose();
})
}
}
/**
* 浮层标题栏:**左标题 + 右关闭**(全应用统一版式)。
*
* ⚠️ 为什么连标题栏也一起抽:光统一 ✕ 还不够 ——
* 标题栏的高度、标题字号、✕ 与标题的对齐方式,各浮层也是各写各的
* (影院页标题带副标题、日程设置把当前值写在标题下…),
* 结果就是"同样是右上角,位置还是差几个 vp"。
* 这个组件把"标题 + ✕"这一行的骨架固定下来,副标题作为可选参数。
*
* ⚠️ 图片查看器那种**深色全屏**场景不要用这个(它假设白底)——
* 查看器自己画一个浅色 ✕(见 `FbMovieDetailSheet.viewer` 的说明)。
*/
@ComponentV2
export struct FbSheetTitleBar {
/** 主标题 */
@Param title: string = '';
/** 副标题(可空;如「26 家 · 按名字排序」「当前:月 · 经典」) */
@Param subtitle: string = '';
/** 「完成」这类**文案按钮**替代 ✕ —— ⚠️ 只在"关掉即生效"的设置类浮层用 */
@Param confirmText: string = '';
/** 主题色(标题下的副标题、确认文字用它) */
@Param accent: string = '';
@Param onClose: () => void = (): void => {};
build() {
Row({ space: 8 }) {
Column({ space: 2 }) {
if (this.title !== '') {
Text(this.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
if (this.subtitle !== '') {
Text(this.subtitle)
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent !== '' ? this.accent : FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
/* ⚠️ 两种收尾二选一:给了 confirmText 就显示文字按钮(设置类),
否则显示统一的 ✕。**不会同时出现两个** —— 那正是这轮要消除的乱象。 */
if (this.confirmText !== '') {
Text(this.confirmText)
.fontSize(FbMetric.fontSmall)
// ⚠️ `FbColors.brand` 是 `Resource`,但这里要的是**模块主题色**
// (字符串)⇒ 必须走 accent;没传才回落到品牌色 Resource。
.fontColor(this.accent !== '' ? this.accent : FbColors.brand)
.fontWeight(FontWeight.Medium)
.height(32)
.padding({ left: 12, right: 12 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.accent !== '' ? this.accentBg() : FbColors.inputBg)
.textAlign(TextAlign.Center)
.onClick((): void => {
this.onClose();
})
} else {
FbSheetClose({
onClose: this.onClose,
color: this.accent !== '' ? this.accent : ''
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/**
* 「完成」按钮的极浅底色(主题色 8% 透明)。
*
* 🔴🔴 必须走 `fbAlpha` —— **不能**写 `accent + '14'`:
* ArkUI 的八位十六进制是 **`#AARRGGBB`(透明度在**前**)**,
* 而 CSS 习惯的 `#RRGGBBAA`(透明在**后**)在这里是**无效色**。
* 本工程已经踩过这个坑(全应用的遮罩曾因为写成 `'#00000066'` 而**从来没显示过**,
* 见 `pitfalls.md` 的"ArkUI 八位色"那一条)。
* `fbAlpha` 会产出正确的 `#AA RRGGBB`。
*/
private accentBg(): string {
return fbAlpha(this.accent, 0.08);
}
}
+29 -13
View File
@@ -206,19 +206,24 @@ export struct FbAvatar {
}
build() {
// ⚠️ 用 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 {
/* 🔴🔴 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)
@@ -228,7 +233,18 @@ export struct FbAvatar {
.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)
}
}