v0.8.2 头像点进我的详情 / 未读数接真实数据 / 名册圆角卡(容器要换成页面底色)

用户 2026-10-18 夜三条:

①「右上角,显示的应该是登录者的头像,点击头像进入我的详情页面。」
   头像**本来就是登录者本人的**(v59 已接 session.me.avatarUrl);
   真正缺的是**点击** —— TopBar 里那个 FbAvatar **根本没有 onClick**,
   只有一句写着"点开账号菜单"的注释,而"账号菜单"这个东西在代码里并不存在。
   ⇒ 接 openMyDetail() → bindSheet 弹 FbPersonSheet(复用名册那个人详情组件)。
   🔴 数据必须走 repo.byId(meId)(本次为它新增 byId),不能从名册列表那批里找:
      **"我"常常不在自己的名册里**(名册是"我挑出来的那一小撮"),
      从列表里查会**静默落空**、点了没反应 —— 最难查的那种失败。
   ⚠️ 三种状态都说清楚:加载中 / 拿到了 / 拿不到,绝不留空白浮层。

②「提醒按钮,明明没提醒,怎么还显示个 2?」
   🔴 真根因:`@Trace unread: number = 2;` 是**写死的占位初值**,
      而全工程只有一处给它赋值(通知页「全部已读」清 0)。
      没进过通知页的人**永远看到 2**;进了通知页又"永远 0"(把真实未读也盖掉)
      —— 两个方向都错。
   ⇒ ① 初值改 0(拿到真值前必须是"没有未读",不能凭空多出 2);
     ② 首页把 /home 的真实 unread 写进全局状态。
        (服务端 Notification::unreadCount,与通知页同源;/home 本来就返回它、
          FbParse 也早就解析了 —— 只是**没人往全局状态里写**。)

③「名册页面,不好看,太丑了,应该是每个人都是圆角矩形,往中间收一点,
   距离左右都有点距离,每个人中间都有一点空隙。」
   🔴🔴 真凶**不是卡片形状**(v51 就做了 borderRadius + margin)——
      而是**列表容器也是纯白** ⇒ 白卡压白底 ⇒ 圆角与左右收边**全看不见**,
      看上去就是一整块通栏白。这解释了"改过一轮还是丑":上次改的是卡片,
      问题在"背景没有对比"。
   ⇒ 容器 + 外层兜底改 FbColors.paper(页面底色),卡片保持白;
     圆角 radiusS(10) → 新增 radiusCard(14)(列表项专用档:16 显胖、10 显方);
     左右各 pagePad(16)、卡间 10(原 8 偏挤)、行内边距 11→12。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 347/0(本批新增 18 条,
另**收紧 2 条**旧判据 —— M 组"圆角卡"那条原来锚"逐字相邻两行"、且**测错了对象**:
没查容器背景,所以对"白卡压白底"这个真实缺陷毫无把关能力);
_fb_v59b_falsify.py 8 项注入 ⇒ 8/0。

⚠️ 本轮反向验证头一轮漏了 3 条(都是"判据太宽"),已逐条收紧并记入 pitfalls:
  · 删掉头像 onClick 后仍绿 —— openMyDetail 这个标识符在**方法定义**里也出现;
  · 容器改回白底后仍绿 —— 本页有**两处** FbColors.paper(容器 / 兜底);
  · /home unread 改成写死 2 后仍绿 —— Notification::unreadCount 在 PHP 里也有两处。
This commit is contained in:
2026-10-18 20:58:40 +08:00
parent 77b494a274
commit 2b0401052c
6 changed files with 257 additions and 26 deletions
+14 -2
View File
@@ -42,8 +42,20 @@ export class FbAppState {
/** 当前会话(接口接上之前先用演示数据;`loggedIn=false` 时应跳登录页) */
@Trace session: FbSession = FB_DEMO_SESSION;
/** 未读通知数(顶栏铃铛角标) */
@Trace unread: number = 2;
/**
* 未读通知数(顶栏铃铛角标)。
*
* 🔴🔴 v59b(用户 2026-10-18):「提醒按钮,明明没提醒,怎么还显示个 2?」
* 这个 **写死的 2** 就是答案:它原来只是个占位初值,而全工程没有任何地方
* 用真实数据填过它(只有通知页的「全部已读」会清成 0)
* ⇒ 没进过通知页的人**永远看到 2**。
*
* ⚠️ 改成 **0**:未读数在拿到真实值之前必须是"没有未读",
* 而不是"假装有 2 条"。真实值由 `/home` 的 `unread` 写入
* (见 FbHomePage 里那段说明),进通知页后由那边继续维护。
* 宁可短暂少显示,也不能凭空多出一个用户会去追问的数字。
*/
@Trace unread: number = 0;
/**
* 状态栏高度(vp)。
+9
View File
@@ -58,6 +58,15 @@ export class FbMetric {
static readonly radius: number = 16;
/** 小圆角(内嵌块 / 输入框) */
static readonly radiusS: number = 10;
/**
* 列表项圆角(**一行一张卡**那种,如名册的人员行)。
*
* 🔴 为什么单独一档(2026-10-18 用户「名册太丑」):
* 列表项**一行一张卡**、上下密排;用 16(区块卡的圆角)会显得"胖"、
* 相邻两卡的圆角挤在一起很吵;用 10(内嵌小块的圆角)在 12vp 的行内边距下
* 又显得方、像"被裁过的白条"。14 是这两个之间的甜点。
*/
static readonly radiusCard: number = 14;
/** 胶囊圆角 */
static readonly radiusPill: number = 999;
+154 -5
View File
@@ -21,7 +21,13 @@ import { FbAppState, fbAppState } from '../common/Store';
import { FbSyncState, SYNC_DONE, SYNC_FAIL, SYNC_RUNNING, fbSyncState } from '../common/FbSyncStore';
import { fbModuleOf } from '../model/Modules';
import { FbAvatar } from './Widgets';
import { FbPersonSheet } from './FbPersonSheet';
// ⚠️ `inst()` 拿单例(本工程所有 repo 都是这个约定,见 FbPersonRepo 文件头);
// `FB_TOKEN_STORE` 在 **FbTokenStore.ets** 里(不是在 FbDeviceStore)。
import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { syncAll } from '../data/FbSyncAll';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
@ComponentV2
@@ -41,6 +47,15 @@ export struct FbTopBar {
/** 同步面板是否打开 */
@Local syncSheet: boolean = false;
/** 「我的详情」浮层是否打开(点头像触发) */
@Local myDetailSheet: boolean = false;
/** 「我的详情」里的人(null = 还没取到) */
@Local myDetail: FbRosterPerson | null = null;
/** 「我的详情」是否在加载 */
@Local myDetailLoading: boolean = false;
/** 「我的详情」的兜底提示(拿不到资料时显示) */
@Local myDetailNote: string = '';
build() {
// 🔴 顶栏横向镜像 —— 「质感握姿」(用户 2026-10-16 第 4 条):
// 左手握时家书图标在**左**,右手握时它跑到**右**,方便单手点它开抽屉。
@@ -177,12 +192,22 @@ export struct FbTopBar {
// 与左边的同步图标、右边的头像各拉开一段(用户:太近了)
.margin({ left: FbMetric.gapTopActions, right: FbMetric.gapTopActions })
// 头像(点开账号菜单)
// 头像 —— 点它打开**我自己的详情**
//
// 🔴 v59(用户 2026-10-18):「右上角的头像应该是真实的。」
// ⇒ 传 `imageUrl`(真实头像图;服务端 `avatar_url()` 给的)。
// 没传过头像的人拿到空串 ⇒ `FbAvatar` 自带色块字回落,
// 所以这里不需要额外判断("两者都要有"是 FbAvatar 的既有设计)。
// 🔴🔴 v59b(用户 2026-10-18):「右上角,显示的应该是登录者的头像,
// **点击头像进入我的详情页面**。」
//
// ⚠️ 原来这里**根本没有 onClick**(只有一句写着"点开账号菜单"的注释)——
// 用户点了没任何反应,而代码里连一个"账号菜单"都不存在。
// ⇒ 现在接的是**详情浮层**(与名册里点某个人同一个组件、同一套数据源)。
//
// 🔴 头像**已经是登录者本人的**(`session.me.avatarUrl` 由 `/login` 与
// `/me` 两处给,见 FbLogin/FbSession)—— 用户这条要求上一轮就已满足,
// 真正缺的是"点了能进去"。
//
// ⚠️ 数据取自**本地库**(`FbPersonRepo.inst().byId`),不是名册列表里那份:
// "我"常常**不在自己的名册**里(名册是"我挑出来的那一小撮"),
// 从名册那批里找会静默落空 ⇒ 点了没反应(最难查的那种失败)。
FbAvatar({
name: this.app.session.me.name,
color: this.app.session.me.avatarColor,
@@ -192,6 +217,32 @@ export struct FbTopBar {
? this.app.session.me.avatarUrl
: ''
})
// ⚠️ `FbAvatar` 的布局尺寸在画布内(`Image` 自己撑圆),外层要显式
// 给一个可点区域 —— 32 的头像直接当热区在真机上偏小(手指容易点空)。
.width(40)
.height(40)
.margin({ left: FbMetric.gapTopActions })
.onClick((): void => {
this.openMyDetail();
})
// 「我的详情」浮层 —— 复用名册那个人详情组件(同一个组件、同一套字段)
//
// ⚠️ 用 `bindSheet` 而不是自己在 `build` 里叠一层遮罩:
// 这条顶栏在**每个模块页**都会渲染(`topBarSlot`),
// 自己叠遮罩要处理"盖住悬浮栏 / 盖住抽屉 / 手势返回"一堆事;
// bindSheet 是系统能力,这些它都管(同步面板也是这么做的)。
//
// ⚠️ `onDisappear` 必须复位 `myDetailSheet`(点遮罩/下滑关闭都会走它),
// 否则下次点头像**打不开**(状态位还是 true 但面板已经不在)。
.bindSheet(this.myDetailSheet, this.myDetailPanel(), {
height: SheetSize.FIT_CONTENT,
dragBar: true,
showClose: false,
backgroundColor: FbColors.surface,
onDisappear: (): void => {
this.myDetailSheet = false;
}
})
}
.width('100%')
// 🔴 高度 = **状态栏高度 + 顶栏本身高度**(用户 2026-10-13 要"全沉浸")。
@@ -245,6 +296,104 @@ export struct FbTopBar {
});
}
/**
* 打开「我」的详情浮层(点右上角头像触发)。
*
* 🔴 v59b(用户 2026-10-18):「点击头像进入我的详情页面。」
*
* 为什么**先打开浮层、再补数据**(而不是等网络回来才开):
* 本地优先是本应用的立身之本 —— 点一下要**立刻**有东西出来,
* 断网时也必须能看。本地库那份已经够画详情的前几段,
* 网络回来后再原地补全(`fetchDetail`)。
*
* ⚠️ 失败时**不弹错误**:`me.id` 拿不到(未登录/数据还没同步下来)就只弹一句
* "资料还没同步下来",而不是让用户对着一个空白浮层猜。
*/
private async openMyDetail(): Promise<void> {
const meId: number = this.app.session.me.id;
if (meId <= 0) {
this.myDetailNote = '还没拿到你的资料,请先同步一次';
this.myDetailSheet = true;
return;
}
/* ⚠️ 先开浮层(`myDetailSheet = true`)再 await:
否则网络慢时用户点了一下**几十毫秒内毫无反应**,会以为按钮坏了。
`loading` 由浮层自己显示,观感上"点开就有东西"。 */
this.myDetailLoading = true;
this.myDetail = null;
this.myDetailSheet = true;
try {
const local: FbRosterPerson | null = await FbPersonRepo.inst().byId(meId);
this.myDetail = local;
this.myDetailLoading = false;
// 本地有 ⇒ 顺手用网络那份补全(口径与名册里点某人完全一致)
const token: string = FB_TOKEN_STORE.get();
if (token !== '' && local !== null) {
const full: FbRosterPerson | null = await FbPersonRepo.inst().fetchDetail(meId, token);
// ⚠️ 浮层可能已经被关掉、或期间换了人 ⇒ 只在"还开着同一个人"时才覆盖
if (full !== null && this.myDetailSheet
&& this.myDetail !== null && this.myDetail.id === meId) {
this.myDetail = full;
}
}
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(0x0000, 'FamilyBook', '取我的详情失败:%{public}s',
e.message !== undefined ? e.message : JSON.stringify(err));
this.myDetailLoading = false;
}
}
/**
* 「我的详情」面板(点头像弹出)。
*
* ⚠️ 三种状态都要有话说(用户 2026-10-18 报过"点了没反应"这类问题):
* · 加载中 ⇒ 「正在读取…」(不要让浮层空着)
* · 拿到了 ⇒ 复用 `FbPersonSheet`(姓名/称呼/电话/生日/单位/标签…)
* · 拿不到 ⇒ 显式一句 `myDetailNote`(而不是空白)
*/
@Builder
myDetailPanel() {
Column() {
if (this.myDetailLoading) {
Row({ space: 8 }) {
LoadingProgress().width(18).height(18).color(FbColors.brand)
Text('正在读取我的资料…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 28, bottom: 28 })
} else if (this.myDetail !== null) {
FbPersonSheet({
person: this.myDetail,
onClose: (): void => {
this.myDetailSheet = false;
},
// ⚠️ 这里**不给编辑入口**:编辑要跳名册页那套编辑浮层(含标签/电话增删),
// 而顶栏在**每个模块页**都存在 —— 在这里接编辑会变成
// "在日程页里编辑家庭成员的资料",职责与页面上下文都不对。
// 要看/改详细资料,从名册进(那是它的主场)。
onEdit: (): void => {
this.myDetailSheet = false;
}
})
} else {
Text(this.myDetailNote !== ''
? this.myDetailNote
: '暂时拿不到你的资料,下拉同步一次再试')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 28, bottom: 28, left: 24, right: 24 })
}
}
.width('100%')
.constraintSize({ maxHeight: '82%' })
}
/**
* 同步面板(点顶栏图标弹出)。
*
+31
View File
@@ -509,6 +509,37 @@ export class FbPersonRepo {
return out;
}
/**
* 按 id 取**一个人**(名册里那份,非详情接口)。
*
* 🔴 v59(用户 2026-10-18):「点击头像进入我的详情页面。」
* 头像点开的是**当前登录者自己**的详情,而"我"**不一定在自己的名册里** ——
* 名册筛的是"我挑出来的那一小撮",本人常常不在其中。
* 不加这个查询,"点头像 → 详情"就会**静默没有任何反应**(`rawById` 里查不到)。
*
* ⚠️ 与 `list()` 的差别只有两点:不 JOIN `person_roster`(不要求"在我名册里"),
* 以及带 `kind='member'`(排除观影那边可能写进来的非成员行)。
* 字段清单必须与 `list()` **完全一致**,否则 `rowToPerson` 会读出 undefined
* (它按列名取值)⇒ 到头来没人看得懂为什么"详情里少了几行"。
*/
async byId(id: number): Promise<FbRosterPerson | null> {
if (id <= 0) {
return null;
}
const sql: string =
'SELECT p.id, p.name, p.nickname, p.gender, p.birthday, p.avatar, p.avatar_color,' +
' p.note, p.email, p.org, p.title, p.slogan, p.addr_city,' +
' 0 AS sort_order, NULL AS phones,' +
' (SELECT GROUP_CONCAT(t.tag, "\\u0001") FROM person_tags t' +
' WHERE t.person_id = p.id) AS tags' +
' FROM persons p WHERE p.id = ? LIMIT 1';
const rows: FbRow[] = await this.db.queryRows(sql, [id]);
if (rows.length === 0) {
return null;
}
return this.rowToPerson(rows[0]);
}
/**
* 读名册的元信息(人 + 标签云 + 我的 id)。
*
@@ -133,6 +133,21 @@ export struct FbHomePage {
this.statOverdue = d.stat.overdue;
this.memories = d.memories;
this.recentDays = d.recentDays;
/* 🔴🔴 v59b(用户 2026-10-18):「提醒按钮,明明没提醒,怎么还显示个 2?」
*
* 真根因:`app.unread` **从来没接过数据** —— 它只是 `FbAppState` 里的
* 一个写死的初值 `@Trace unread: number = 2;`,而全工程**只有一处**
* 给它赋值(通知页的「全部已读」把它改成 0)。
* ⇒ 于是"从没进过通知页"的用户**永远看到 2**;进了通知页又变成
* "永远是 0"(把真实未读也盖掉了)。两个方向都错。
*
* `/home` 本来就返回权威的 `unread`(服务端 `Notification::unreadCount`,
* 与通知页同一个来源)—— 这里把它同步进全局状态,铃铛角标就真了。
*
* ⚠️ 首页是启动后必到的页面(默认模块),所以这一个赋值点就够
* "进应用看到真数字";进通知页后由那边继续维护(标记已读会变)。
*/
this.app.unread = d.unread;
},
(note: string): void => {
this.syncNote = note;
@@ -802,15 +802,21 @@ export struct FbPeoplePage {
// 见 `build()` 的说明。
.layoutWeight(1)
.width('100%')
// 🔴 **白底铺满整屏、不内缩、不圆角**(用户 2026-10-15 反馈"最下面还有一条
// 有背景色的东西,不是从最底下滑上来的")。
//
// 之前是"左右 margin 内缩 + 圆角"的**卡片形态** ⇒
// ① 列表底部与屏幕底之间留出页面底色;
// ② 左右两条底色也会在滚到底时露出来。
// ⇒ 白底**通栏到底**,不再做卡片。
// ⚠️ 别再用 `margin`/`borderRadius`/`clip` —— 那三个就是"卡片感"的来源。
.backgroundColor(FbColors.surface)
/* 🔴🔴 v59b(用户 2026-10-18):「名册页面,不好看,太丑了,应该是
* 每个人都是圆角矩形,往中间收一点,距离左右都有点距离,
* 每个人中间都有一点空隙。这样是很多个圆角矩形平铺下来的。」
*
* 🔴 真凶:卡片**早就**是圆角矩形了(v51 就做了 `borderRadius` +
* `margin`),但**容器也是纯白** ⇒ **白卡压在白底上**,
* 圆角和左右收边**全都看不见** —— 看上去就是一整块通栏白。
* (这解释了为什么"改过一轮还是丑":改的是卡片的形状,
* 而问题在于它没有可对比的背景。)
*
* ⇒ 容器改成**页面底色(宣纸米色)**,白卡才"浮"得出来;
* 外层那个兜底白(见 build 末尾)也一并改掉,否则滚到底
* 会从米色突然切回白色。
*/
.backgroundColor(FbColors.paper)
// 🔴🔴 **这里绝对不能再加 `padding({bottom})`**
// (用户 2026-10-15:「它的内容是从悬浮框上面滚出来的」)。
//
@@ -831,9 +837,10 @@ export struct FbPeoplePage {
}
.width('100%')
.layoutWeight(1)
// 🔴 外层也铺白底:内容不足一屏时,列表白底铺不到屏幕底,
// 这一层就是"兜底的白" —— 不能让页面底色从底部露出来。
.backgroundColor(FbColors.surface)
/* 兜底底色 = **页面底色**(原来是纯白)。
⚠️ 必须与列表容器同色:列表内容不足一屏时,露出来的就是这一层;
两层不同色会在底部切出一条突兀的色带(v51 踩过同类问题)。 */
.backgroundColor(FbColors.paper)
}
/**
@@ -916,14 +923,22 @@ export struct FbPeoplePage {
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 11, bottom: 11 })
// 🔴 用户 2026-10-16:「人员列表项:每一个人员是不是都可以做成一个
// **圆角矩形**的形式?」⇒ 每条**独立一张圆角卡**(白底 + 间距),
// 不再靠 Divider 连成一大块。
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
/* 🔴 v59b(用户 2026-10-18):「每个人都是圆角矩形,往中间收一点,
* 距离左右都有点距离,每个人中间都有一点空隙。这样是很多个
* 圆角矩形平铺下来的。」
*
* ⚠️ 形状其实**早就是**圆角卡了(v51),用户说"丑"的真凶在容器 ——
* 列表容器也是纯白 ⇒ 白卡压白底 ⇒ 圆角/收边**全看不见**。
* 已把容器改成页面底色(见 rosterView 的 `backgroundColor`),
* 白卡这才"浮"得出来。
* ⚠️ 这里的三个数也一起放大了一档(user 明确要"收一点 + 有空隙"):
* 左右 16(= 全站 pagePad,与页头对齐)、卡间 10(原来 8,偏挤)、
* 圆角 14(原来 10,在 12 的行内边距下 10 显得方)。
*/
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
// ⚠️ 间距放这里(@Builder 不能链式)—— 左右留边 + 卡间空隙
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.borderRadius(FbMetric.radiusCard)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 10 })
.alignItems(VerticalAlign.Center)
// 🔴 点整行 = **拨打这个人的电话**(用户 2026-10-13 / 10-15 明确要求)。
// 规则(2026-10-15 用户确认):**有主号打主号**;