diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index cf0e599..d8054c4 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -1033,6 +1033,11 @@ export struct FbPersonEditSheet { .padding(18) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) + /* 🔴 v59c:与 `FbPersonSheet` / `FbPersonQuickSheet` 同一套"三层分离" + (用户 2026-10-18:「白色和下面列表的白色融合在一起,看不出是弹框」)。 + ⚠️ 三个浮层必须**同手段**,否则从简要卡 → 编辑切过去会突然"变平"。 */ + .shadow({ radius: 24, color: '#00000033', offsetX: 0, offsetY: 6 }) + .border({ width: 1, color: '#00000014' }) .alignItems(HorizontalAlign.Start) } .width('100%') diff --git a/entry/src/main/ets/components/FbPersonQuickSheet.ets b/entry/src/main/ets/components/FbPersonQuickSheet.ets new file mode 100644 index 0000000..93bbb60 --- /dev/null +++ b/entry/src/main/ets/components/FbPersonQuickSheet.ets @@ -0,0 +1,316 @@ +/** + * 家书 FamilyBook · 名册「简要卡片」(鸿蒙端) + * + * 🔴🔴 用户 2026-10-18 原话: + * 「点击列表,不打电话了,容易误触。而是弹出一个卡片,卡片中是头像、 + * 名称、电话号码、邮箱等简要信息,下面三个按钮,详情、编辑和删除。」 + * + * 为什么要单独一个组件(而不是在名册页里再写一个 `@Builder`): + * · 它是**一张自己的卡**(与"完整详情"是两级),有自己的按钮组与布局; + * · 名册页那个文件已经 1300+ 行,再往里塞会失控; + * · 将来"从日程里点参会人"也可能要弹同一张卡 ⇒ 组件化才复用得上。 + * + * 🔴 关于「弹框与下面列表都是白的、看不出是弹框」(用户同一条反馈里提的): + * 本卡片的解法是**三层分离**(缺一层就会"糊在一起"): + * ① 宿主给**深色遮罩**(见调用点,`#00000066`)—— 把下面的列表压暗; + * ② 卡片自己**加阴影**(`shadow`)—— 与遮罩拉开"浮起来"的层次; + * ③ 卡片底色用**纸白**、并给一圈**极浅描边** —— 即使在同色背景上, + * 边缘也有一条可见的界线(只靠阴影在浅底上是不够的)。 + * ⚠️ 只做①(很多地方只做遮罩)在"背景也是大面积白"时效果最弱: + * 白卡压在"被压暗的白"上,边界仍然发飘 —— 用户说的就是这种观感。 + * + * ⚠️ 「删除」是**破坏性操作**,必须二次确认(见调用点的 `AlertDialog`)。 + * 卡片这里**只负责发事件**,不自己做确认也不自己调接口 —— + * 与 `FbPersonSheet` 的做法一致(浮层之间不互相引用,免得成环)。 + */ + +import { FbColors, FbMetric, fbTint, FbTint } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; +import { FbRosterPerson } from '../data/FbPersonRepo'; +import { FbAvatar, FbTag } from './Widgets'; +import { fbMediaUrl } from '../data/FbHttp'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; + +/** 卡片阴影半径(与详情浮层同一档观感) */ +const CARD_SHADOW: number = 24; + +/** + * 「删除」用的红 —— 与 `resources/base/element/color.json` 的 `red` **同值**。 + * + * ⚠️ 不能直接用 `FbColors.red`(那是 `Resource`):本文件要拿它拼 + * `RED + '1A'`(透明度后缀)做底色,`Resource` 拼不了字符串。 + * ⇒ 凡是要参与**颜色运算**的地方都用 hex(本项目已有 `FbTint` 这组先例)。 + * ⚠️ color.json 改色时这里要同步改(只有这一个值,风险可控)。 + */ +const RED: string = '#C0533F'; + +@ComponentV2 +export struct FbPersonQuickSheet { + /** 要看的人(null = 没打开) */ + @Param person: FbRosterPerson | null = null; + + /** 关闭(点「关闭」或点遮罩) */ + @Event onClose: () => void = (): void => { + }; + + /** + * 「详情」:进完整详情浮层。 + * + * ⚠️ 本组件**不自己切浮层**:切法是"关掉简要卡、打开详情卡", + * 那是页面的编排(它还管着遮罩/返回关系)。见调用点。 + */ + @Event onDetail: () => void = (): void => { + }; + + /** 「编辑」:进编辑浮层(同上,由页面编排) */ + @Event onEdit: () => void = (): void => { + }; + + /** + * 「删除」:只发事件。 + * ⚠️ 二次确认与调接口都在页面侧 —— 那里才知道"删完怎么刷新列表"。 + */ + @Event onDelete: () => void = (): void => { + }; + + /** 名册模块的主题色(按钮配色与整个名册页同一个色系) */ + private brand(): string { + const m: FbModuleDef | undefined = fbModuleOf('people'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + + /** 姓名(昵称优先,与列表一致) */ + private title(): string { + return this.person !== null + ? (this.person.shown !== '' ? this.person.shown : this.person.name) + : ''; + } + + /** 头像地址(**必须是完整 URL**,见 fbMediaUrl 的说明) */ + private avatarSrc(): string { + if (this.person === null) { + return ''; + } + return fbMediaUrl(this.person.avatar, FB_TOKEN_STORE.get()); + } + + /** + * 主电话(`phones` 由服务端保证「主号排第一」)。 + * ⚠️ 用 `phones` 而不是 `phone`:后者只是 `phones[0]` 的便捷别名, + * 而这里要显示**这一条能不能点**,显式用数组语义更清楚。 + */ + private mainPhone(): string { + if (this.person === null) { + return ''; + } + const arr: string[] = this.person.phones; + return arr.length > 0 ? arr[0] : ''; + } + + /** 其余电话(第 2 个起,逗号连接;没有就给空串) */ + private otherPhones(): string { + if (this.person === null) { + return ''; + } + const arr: string[] = this.person.phones; + if (arr.length <= 1) { + return ''; + } + const rest: string[] = []; + for (let i = 1; i < arr.length; i++) { + rest.push(arr[i]); + } + return rest.join(' / '); + } + + /** 生日显示串(阳历优先,其次农历;都没有给空串) */ + private birthdayText(): string { + if (this.person === null) { + return ''; + } + if (this.person.birthdaySolar !== '') { + return this.person.birthdaySolar; + } + return this.person.birthdayLunar; + } + + /** 单位 + 职务合成一行(都没有给空串) */ + private jobText(): string { + if (this.person === null) { + return ''; + } + const org: string = this.person.org; + const title: string = this.person.title; + if (org !== '' && title !== '') { + return org + ' · ' + title; + } + return org !== '' ? org : title; + } + + /** 一行「图标 + 标签 + 值」 */ + @Builder + infoRow(icon: Resource, label: string, value: string) { + Row({ space: 10 }) { + Image(icon) + .width(16) + .height(16) + /* ⚠️ 本工程图标是 PNG ⇒ 必须 colorFilter(fillColor 只对 SVG 生效) */ + .colorFilter(fbTint(FbTint.faint)) + Text(label) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .width(52) + Text(value) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + /** + * 一个动作按钮。 + * + * ⚠️ 三个按钮等宽平分(`layoutWeight(1)`)—— 用户要"下面三个按钮", + * 等宽是最不容易出错、也最像"选择"的排法。 + */ + /** + * 一个动作按钮。 + * + * ⚠️ `fg`/`bg` 必须是 **hex 字符串**(不是 `Resource`): + * 这里要拿它做**颜色运算**(`bg + '1A'` 加透明度后缀), + * `Resource` 参与不了字符串拼接。红色那个因此用与 + * `color.json` 的 `red` **同值**的字面量(见 `RED`)。 + */ + @Builder + actionBtn(label: string, fg: string, bg: string, fire: () => void) { + Text(label) + .fontSize(FbMetric.fontSmall) + .fontColor(fg) + .fontWeight(FontWeight.Medium) + .textAlign(TextAlign.Center) + .height(38) + .layoutWeight(1) + .backgroundColor(bg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + fire(); + }) + } + + build() { + 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' }) + } + } + .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(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + // ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)---- + Column({ space: 10 }) { + if (this.mainPhone() !== '') { + this.infoRow($r('app.media.fb_ic_user'), '电话', 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()) + } + } + .width('100%') + + // ---- 三个动作:详情 / 编辑 / 删除 ---- + // + // ⚠️ 「删除」用红色(语义色),其余两个是主题色的深浅两档: + // 同一组按钮里,"哪个是破坏性的"必须一眼看出来。 + Row({ space: 10 }) { + this.actionBtn('详情', FbTint.white, this.brand(), (): void => { + this.onDetail(); + }) + this.actionBtn('编辑', this.brand(), this.brand() + '1A', (): void => { + this.onEdit(); + }) + this.actionBtn('删除', RED, RED + '1A', (): void => { + this.onDelete(); + }) + } + .width('100%') + + // ---- 关闭 ---- + Text('关闭') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .width('100%') + .height(38) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onClose(); + }) + } + /* 🔴 卡片本体:见文件头「三层分离」的说明。 + ⚠️ `width('92%')` + 四角圆角 + 居中 —— 与 `FbPersonSheet` 同一套观感 + (用户 2026-10-16 定:"圆角浮窗式、不占满宽、浮在屏幕中间")。 */ + .width('92%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 18 }) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + /* ② 阴影:把卡片从"被压暗的列表"上抬起来 */ + .shadow({ radius: CARD_SHADOW, color: '#00000033', offsetX: 0, offsetY: 6 }) + /* ③ 极浅描边:同色背景上仍有一条可见界线(只靠阴影在浅底上不够) */ + .border({ width: 1, color: '#00000014' }) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + /* ⚠️ 透明命中:让点击"卡片以外的区域"能穿透到宿主的遮罩上(点遮罩关卡片)。 + 不设的话这一层会把空白处也吃掉,点空白关不掉(很别扭)。 */ + .hitTestBehavior(HitTestMode.Transparent) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbPersonSheet.ets b/entry/src/main/ets/components/FbPersonSheet.ets index 7ee0864..e1d3ea1 100644 --- a/entry/src/main/ets/components/FbPersonSheet.ets +++ b/entry/src/main/ets/components/FbPersonSheet.ets @@ -501,6 +501,19 @@ export struct FbPersonSheet { }) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) + /* 🔴 v59c(用户 2026-10-18):「这个卡片弹出来之后是白色的,和下面列表 + * 中的白色都融合在一起,根本看不出是弹框。」 + * + * 原因:原来只靠宿主那层 `#00000066` 遮罩区分 —— 而本应用**列表本身 + * 就是大面积白底**,白卡压在"被压暗的白"上,边界仍然发飘。 + * ⇒ 补两手(与 `FbPersonQuickSheet` 完全同一套,两级浮层观感一致): + * ① 阴影:把卡片从背景上**抬起来**(有投影才有"浮在空中"的暗示); + * ② 极浅描边:同色背景上仍然有一条**可见的界线**。 + * ⚠️ 只加阴影是不够的:浅色底上的投影很淡,真机上常常看不出来; + * 描边才是"稳"的那一手(它不依赖光照假设)。 + */ + .shadow({ radius: 24, color: '#00000033', offsetX: 0, offsetY: 6 }) + .border({ width: 1, color: '#00000014' }) .alignItems(HorizontalAlign.Start) } /* 🔴 embedded(宿主自己就是 sheet)时**不能再铺满 + 居中**: diff --git a/entry/src/main/ets/components/TopBar.ets b/entry/src/main/ets/components/TopBar.ets index 5e92a74..e84bc8c 100644 --- a/entry/src/main/ets/components/TopBar.ets +++ b/entry/src/main/ets/components/TopBar.ets @@ -26,6 +26,7 @@ import { FbPersonSheet } from './FbPersonSheet'; // `FB_TOKEN_STORE` 在 **FbTokenStore.ets** 里(不是在 FbDeviceStore)。 import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { fbMediaUrl } from '../data/FbHttp'; import { syncAll } from '../data/FbSyncAll'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; @@ -221,9 +222,21 @@ export struct FbTopBar { color: this.app.session.me.avatarColor, dia: 32, fontSizeVp: 14, - imageUrl: this.app.session.me.avatarUrl !== undefined - ? this.app.session.me.avatarUrl - : '' + /* 🔴🔴 v59c(用户 2026-10-18):「右上角的个人头像没显示。」 + * + * 真根因:`/me` 与 `/login` 返回的 `avatar_url` 是 + * **相对地址**(`media.php?k=avatars%2Fxxx.jpg` —— 不带域名)—— + * 那是给浏览器用的(同源 + cookie),而 `Image()` 要完整 URL + * ⇒ 直接加载失败。真机日志:`GetAsset failed: media.php?k=...` + * + * ⇒ 用 `fbMediaUrl()` 归一化成本机可访问的完整地址 + * (它同时认"绝对地址 / media.php 相对式 / 裸 key"三种形态)。 + * + * ⚠️ 不要用 `fbImageSrc()`(那是给**附件 key** 用的,走本地缓存): + * 这里拿到的是 URL,不是 key —— 传进去会把整条 URL 当 key 编码, + * 变成必然 404 的地址。 + */ + imageUrl: fbMediaUrl(this.myAvatarRaw(), FB_TOKEN_STORE.get()) }) } .width(40) @@ -307,6 +320,19 @@ export struct FbTopBar { }); } + /** + * 我的原始头像地址(服务端给的那个,可能是相对式)。 + * + * ⚠️ 单独抽成方法而不是在组件参数里写三目:`@Builder`/组件参数的括号里 + * 塞多行表达式会让 ArkTS 编译器解析错乱(本项目踩过,报的却是无关的 + * `comma operator` / `standalone this`)。 + */ + private myAvatarRaw(): string { + return this.app.session.me.avatarUrl !== undefined + ? this.app.session.me.avatarUrl + : ''; + } + /** * 打开「我」的详情浮层(点右上角头像触发)。 * diff --git a/entry/src/main/ets/data/FbHttp.ets b/entry/src/main/ets/data/FbHttp.ets index 13953c8..61912e8 100644 --- a/entry/src/main/ets/data/FbHttp.ets +++ b/entry/src/main/ets/data/FbHttp.ets @@ -45,6 +45,53 @@ export function fbApiBase(): string { return FB_SERVER_STORE.get(); } +/** + * 把**服务端给的媒体地址**变成 `Image()` 能直接加载的完整 URL。 + * + * 🔴🔴 为什么需要它(2026-10-18 真机 bug:右上角头像不显示): + * 服务端 `avatar_url()` / `media_url()` 返回的是**相对地址** + * (`media.php?k=avatars%2Fxxx.jpg` —— 不带域名、不带令牌), + * 那是给**浏览器**用的(同源、cookie 自动带上)。 + * 而 `Image()` 需要一个**完整可访问**的 URL ⇒ 拿到相对地址直接加载失败。 + * 真机日志就是这么写的: + * `GetAsset failed: media.php?k=avatars%2F20261002_e32c8647d2f105ce.jpg` + * + * ⚠️ 三种输入都要认(服务端不同接口给的形态不一样): + * ① 已经带 `http(s)://` ⇒ 原样返回(`avatar_url` 对历史外链数据就是这么处理的); + * ② 形如 `media.php?k=` ⇒ 补域名(**保留它自带的 k**,别再 encode 一次, + * 否则 key 会被二次编码成 `avatars%252F...`,服务端解出来是错的路径); + * ③ 裸 key(`avatars/xxx.jpg`)⇒ 走 `fbImageUrl()` 按标准形式拼。 + * ⚠️ 传空串给回空串 —— `FbAvatar` 以此判断"回落色块字",**不要**给它拼一个默认图, + * 否则所有没头像的人都会去请求一张不存在的图。 + * + * @param raw 服务端给的地址(相对/绝对/裸 key) + * @param token API 令牌(追加到 query,媒体代理要靠它鉴权) + */ +export function fbMediaUrl(raw: string, token: string): string { + const s: string = raw.trim(); + if (s === '') { + return ''; + } + // ① 绝对地址原样用 + if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) { + return s; + } + const base: string = fbApiBase(); + if (base === '') { + return ''; + } + // ② 已经是 media.php 形式 ⇒ 只补域名(k 已经编码过了,别动它) + if (s.indexOf('media.php') === 0 || s.indexOf('/media.php') === 0) { + let url: string = base + '/' + (s.charAt(0) === '/' ? s.substring(1) : s); + if (token !== '') { + url += '&token=' + encodeURIComponent(token); + } + return url; + } + // ③ 裸 key ⇒ 标准拼法(走 media.php?k=...) + return fbImageUrl(s, token); +} + /** * 拼一个可直接给 `Image()` 用的图片地址。 * diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets index be36032..4aa8711 100644 --- a/entry/src/main/ets/pages/people/FbPeoplePage.ets +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -15,16 +15,19 @@ import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbKinGraph, FbKinPerson, FbRosterItem } from '../../model/Types'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../../model/Modules'; import { parseKin } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FbAvatar, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FbPersonSheet } from '../../components/FbPersonSheet'; +import { FbPersonQuickSheet } from '../../components/FbPersonQuickSheet'; import { FbPersonEditSheet } from '../../components/FbPersonEditSheet'; import { FbKinGraphView } from '../../components/FbKinGraph'; import { FbTagChip } from '../../components/FbTagChip'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { FbDb } from '../../data/FbDb'; import { FbPersonRepo, FbRosterPerson, FbRosterSyncResult, FbTagCount } from '../../data/FbPersonRepo'; +import { FbApiResult } from '../../data/FbHttp'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { call } from '@kit.TelephonyKit'; @@ -226,6 +229,29 @@ export struct FbPeoplePage { */ @Local editPerson: FbRosterPerson | null = null; + /** + * 「简要卡片」里的人(点列表整行弹出)。 + * + * 🔴 v59c(用户 2026-10-18):「点击列表,不打电话了,容易误触。而是弹出 + * 一个卡片,卡片中是头像、名称、电话号码、邮箱等简要信息,下面三个按钮, + * 详情、编辑和删除。」 + * + * ⚠️ 与 `detailPerson`(详情浮层)**分开两个状态**,不要合并: + * 两者是"概览 → 深入"的两级,且**在同一个 Stack 里叠**(详见渲染处), + * 合并成一个变量就分不清"现在该显示哪一级"(从详情返回时要回简要卡)。 + */ + @Local quickPerson: FbRosterPerson | null = null; + + /** + * 「简要卡片」用哪份数据。 + * + * 优先用**已拉到的详情那份**(`detailPerson`,字段更全、含工作经历与补充电话); + * 没有就退回列表那份(`quickPerson`)。 + * ⚠️ 这是为了"从详情返回简要卡"时状态一致 —— 详见 `quickSource()` 的实现与 + * `onBackFromDetail()`。 + */ + @Local quickFromDetail: FbRosterPerson | null = null; + /** * 🔴 浮层开合时**让底部悬浮栏让位**(用户 2026-10-15 实测的真 bug)。 * @@ -423,6 +449,73 @@ export struct FbPeoplePage { } } + /** + * 「删除」——**破坏性操作,必须先二次确认**。 + * + * 🔴 v59c(用户 2026-10-18):简要卡上加了「删除」按钮。 + * 这是本项目第一个"从浮层直接删人"的入口(原来只能进编辑浮层再删), + * 所以确认这一步**不能省**: + * · 一次误触就真的把人删了(服务端是物理删除,没有回收站); + * · 而且这个人可能还在观影/日程里被引用 —— 后果不可逆。 + * ⇒ 弹系统 `AlertDialog`(比自绘确认框稳,也不占额外的浮层层级)。 + * + * ⚠️ 文案里带上**姓名**:"删除「老杨」?"比"确定删除吗?"安全得多 —— + * 用户能立刻看出"是不是点错了人"(这正是误触最需要的信息)。 + */ + private confirmDelete(): void { + const who: FbRosterPerson | null = this.quickSource(); + if (who === null) { + return; + } + const name: string = who.shown !== '' ? who.shown : who.name; + this.getUIContext().showAlertDialog({ + title: '删除联系人', + message: '确定把「' + name + '」从名册里删除吗?此操作无法撤销。', + autoCancel: true, + primaryButton: { + value: '取消', + action: (): void => { + } + }, + secondaryButton: { + value: '删除', + fontColor: FbColors.red, + action: (): void => { + this.doDelete(who); + } + } + }); + } + + /** 真正执行删除(确认之后) */ + private async doDelete(who: FbRosterPerson): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.syncNote = '未登录,无法删除'; + return; + } + try { + const res: FbApiResult = await this.repo.remove(who.id, token); + if (!res.ok) { + hilog.warn(DOMAIN, TAG, '删除联系人失败:%{public}s', res.error); + this.syncNote = '删除失败:' + res.error; + return; + } + // 关闭浮层 + 重新拉名册(那人整条会从本地库消失) + this.quickPerson = null; + this.quickFromDetail = null; + this.detailPerson = null; + this.editPerson = null; + this.syncNote = '已删除 ' + (who.shown !== '' ? who.shown : who.name); + await this.syncInBackground(); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.error(DOMAIN, TAG, '删除联系人抛异常:%{public}s', + e.message !== undefined ? e.message : JSON.stringify(err)); + this.syncNote = '删除失败:' + (e.message !== undefined ? e.message : '网络异常'); + } + } + /** * 拉家谱(`/api/v1/kin`)—— 先缓存、再网络。 * @@ -461,7 +554,14 @@ export struct FbPeoplePage { FbPersonSheet({ person: this.detailPerson, onClose: (): void => { + /* 🔴 v59c:详情是**从简要卡进来的** ⇒ 关掉后回到简要卡, + 而不是一路退回列表(用户会觉得"我刚才那层被吞掉了")。 + 见 `quickFromDetail` 的说明。 */ + const back: FbRosterPerson | null = this.detailPerson; this.detailPerson = null; + if (back !== null && this.quickPerson !== null) { + this.quickFromDetail = back; + } }, onEdit: (): void => { // 关详情、开编辑(把同一个人带过去) @@ -471,6 +571,49 @@ export struct FbPeoplePage { }) } + // ---- 简要卡片(点列表整行弹出)---- + // + // 🔴 v59c(用户 2026-10-18):点行不再拨号,改弹这张卡。 + // ⚠️ 与详情浮层**用同一个遮罩色**(`#00000066`)—— + // 两级浮层的"压暗程度"必须一致,否则用户会觉得"换了一层世界"。 + if (this.quickPerson !== null && this.detailPerson === null) { + Column() + .width('100%') + .height('100%') + .backgroundColor('#00000066') + .onClick((): void => { + this.quickPerson = null; + this.quickFromDetail = null; + }) + + FbPersonQuickSheet({ + person: this.quickSource(), + onClose: (): void => { + this.quickPerson = null; + this.quickFromDetail = null; + }, + onDetail: (): void => { + // 关简要卡、开详情(把同一个人带过去) + const who: FbRosterPerson | null = this.quickSource(); + this.quickPerson = null; + this.quickFromDetail = null; + if (who !== null) { + this.openDetail(who.id); + } + }, + onEdit: (): void => { + const who: FbRosterPerson | null = this.quickSource(); + this.quickFromDetail = null; + if (who !== null) { + this.editPerson = who; + } + }, + onDelete: (): void => { + this.confirmDelete(); + } + }) + } + // ---- 编辑浮层 ---- if (this.editPerson !== null) { Column() @@ -616,9 +759,19 @@ export struct FbPeoplePage { peopleHeader() { Column() { // ---- 分段切换(名册 / 家谱)---- + // + // 🔴 v59c(用户 2026-10-18):「名册、家谱这两个标签,用的不是主题色。」 + // ⇒ 原来两个都硬编码 `'#A84A28'`(**品牌砖红**),而名册的主题色是 + // `FB_MODULES` 里那个 `#4A7C82`(青)—— 页面里其它地方 + // (如列表项那个"详情"按钮的底色/字色)用的都是主题色粉调, + // 于是这一处显得"跟整个页面不是一个色系"。 + // ⚠️ 用 `fbThemeVars(...).brand` 走统一算法,而不是直接把 `#4A7C82` + // 抄进来:`fbThemeVars` 会把色相拉到一个**可读的饱和度/亮度区间** + // (见其实现),直接抄模块色在选中态当底、白字压上去会偏亮发飘。 + // ⚠️ 两处都必须传同一个值 —— 之前正是"两处各写一遍字面量"才漂的。 Row({ space: 4 }) { - this.segBtn('名册', 'roster', '#A84A28') - this.segBtn('家谱', 'kin', '#A84A28') + this.segBtn('名册', 'roster') + this.segBtn('家谱', 'kin') } .padding(3) .backgroundColor(FbColors.inputBg) @@ -882,6 +1035,15 @@ export struct FbPeoplePage { .fontColor(FbColors.ink) .fontWeight(FontWeight.Medium) .maxLines(1) + /* 🔴🔴 内容必须**允许收缩**(`flexShrink`),否则会把卡片撑破 —— + * 这就是用户 2026-10-18 报的「左侧有空隙、右侧紧贴屏幕」。 + * 原理:Row 里的子项默认 `min-width: auto`(= 按内容最小宽度), + * 长名字 / 多个标签会把这一行顶得比卡片可用宽度更宽; + * 卡片本身有 `margin(left/right: 16)`,但**布局盒被内容撑出去**, + * 于是右边越界贴到屏幕缘(左边因为 margin 先占位,所以还有空隙)。 + * ⇒ 给两个可能变长的 Row 都开 `flexShrink(1)` + 给 Text 让路。 + * (本项目第 N 次踩这个"flex 子项不收缩"的坑,见 pitfalls。) */ + .flexShrink(1) if (p.isAdmin) { FbTag({ text: '管理员', tone: 'brand' }) } @@ -890,6 +1052,7 @@ export struct FbPeoplePage { } } .alignItems(VerticalAlign.Center) + .width('100%') Row({ space: 8 }) { if (p.tags.length > 0) { @@ -907,20 +1070,17 @@ export struct FbPeoplePage { } .alignItems(HorizontalAlign.Start) .layoutWeight(1) + /* ⚠️ 这一列是弹性项,必须允许收缩 —— 否则它的最小宽度(由内容决定) + 会把整行顶宽、把卡片撑破(右侧紧贴屏幕,用户 2026-10-18 报的那个)。 */ + .flexShrink(1) - // 「详情」按钮(用户要求:右侧加一个按钮写着"详情") - // ⚠️ 为什么不是那个灰箭头:箭头**只是装饰**(用户说的"灰色小横线"就是它), - // 而"点行"这个动作被用户指定成**拨号**了 —— - // 所以详情必须有个**显式的按钮**,否则没有任何入口。 - Text('详情') - .fontSize(FbMetric.fontTiny) - .fontColor('#4A7C82') - .padding({ left: 12, right: 12, top: 6, bottom: 6 }) - .backgroundColor('#4A7C8215') - .borderRadius(FbMetric.radiusS) - .onClick((): void => { - this.openDetail(p.id); - }) + /* 🔴 v59c(用户 2026-10-18):「列表页面中,也不要详情按钮了。」 + * ⇒ 原来这里有个显式的「详情」按钮(v51 加的,当时"点行=拨号" + * 占用了整行点击,详情只能另给入口)。 + * 现在**点行改成弹简要卡片**(卡片里有详情/编辑/删除三个按钮) + * ⇒ 整行点击已经是入口,这个按钮就是冗余的,且它正是把卡片 + * 右侧撑破的元凶之一(固定宽度 + 不收缩)。 + */ } .width('100%') .padding({ left: 14, right: 14, top: 12, bottom: 12 }) @@ -940,39 +1100,50 @@ export struct FbPeoplePage { .borderRadius(FbMetric.radiusCard) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 10 }) .alignItems(VerticalAlign.Center) - // 🔴 点整行 = **拨打这个人的电话**(用户 2026-10-13 / 10-15 明确要求)。 - // 规则(2026-10-15 用户确认):**有主号打主号**; - // 一个号码都没有时改成"打开详情"(点了没反应比什么都让人困惑)。 - // - // ⚠️ 为什么不用"挑最近打过的号": - // HarmonyOS **没有"读通话记录列表"的公开 API**(只有个 - // `hasMatchedCallLog`——判断某号码 6 小时内打过没,还要 `CHECK_CALL_LOG` - // 敏感权限),**无法按时间排出"最近那个"**。已与用户确认走"主号优先"。 - // ⚠️ `phones` 由服务端保证「主号排第一」(见 FbRosterPerson.phones)。 - // ⚠️ 用 `p.phones` 而不是 `p.phone`:后者只是 `phones[0]` 的便捷别名, - // 这里显式用数组,语义更清楚(也方便将来做"多号选择")。 + /* 🔴🔴 v59c(用户 2026-10-18):「点击列表,不打电话了,容易误触。 + * 而是弹出一个卡片,卡片中是头像、名称、电话号码、邮箱等简要信息, + * 下面三个按钮,详情、编辑和删除。」 + * + * ⇒ 点整行**不再拨号**(拨号容易误触:列表里手指一滑就拨出去了), + * 改成弹「简要卡片」(见 `quickSheet` 那一组状态与 `quickPanel`)。 + * 电话号码在卡片里也是可点的(那时是**确认过**的点击,不算误触)。 + */ .onClick((): void => { - if (p.phones.length > 0) { - this.dial(p.phones[0], p.name); - } else { - this.openDetail(p.id); - } + this.openQuick(p); }) } /** - * 打开某人的详情浮层(点「详情」按钮、或对没手机号的人点整行)。 + * 打开某人的「简要卡片」(点列表整行触发)。 * - * 🔴 v52(用户 2026-10-15):**先秒开、再补全**。 - * 本地库里那份只有"列表要用的字段",而"全功能"要求详情能看到 - * **工作经历 / 补充电话 / 私人备注 / 各级地址** —— 这些只在 - * `GET /people/` 里有。 - * ⇒ ① 立即用本地那份把浮层打开(不等网络,点开就有内容); - * ② 同时发一个详情请求,回来就**原地替换**(字段更多、更准)。 - * ⚠️ 断网/失败时保持 ① 的显示,不弹错误 —— - * "详情少几项"比"点了没反应"好得多。 - * ⚠️ 用 `this.detailToken` 防止"快速连点两个人,前一个的响应把后一个覆盖"。 + * 🔴 v59c(用户 2026-10-18):「点击列表,不打电话了,容易误触。而是弹出 + * 一个卡片,卡片中是头像、名称、电话号码、邮箱等简要信息,下面三个按钮, + * 详情、编辑和删除。」 + * + * ⚠️ 与 `openDetail` 是**两级**: + * 简要卡(概览 + 三个动作)→ 点「详情」才进完整浮层。 + * 从列表那份(`rawById`)直接起 —— 它已经有头像/姓名/电话/邮箱/标签, + * 够画这张卡(**不必等网络**,点开就有东西)。 */ + private openQuick(p: FbRosterItem): void { + const raw: FbRosterPerson | undefined = this.rawById.get(p.id); + if (raw === undefined) { + return; + } + this.quickFromDetail = null; + this.quickPerson = raw; + } + + /** + * 简要卡要用哪份数据(详情那份优先)。 + * + * ⚠️ 抽成方法而不是在 `@Builder` 参数里写三目:括号里塞多行表达式会让 + * ArkTS 编译器解析错乱(本项目踩过,报的却是无关的 `comma operator`)。 + */ + private quickSource(): FbRosterPerson | null { + return this.quickFromDetail !== null ? this.quickFromDetail : this.quickPerson; + } + /** * 名册里**所有标签名**(给编辑浮层的"点选"用)。 * @@ -987,6 +1158,19 @@ export struct FbPeoplePage { return out; } + /** + * 打开某人的**详情浮层**(简要卡里点「详情」)。 + * + * 🔴 v52(用户 2026-10-15):**先秒开、再补全**。 + * 本地库里那份只有"列表要用的字段",而"全功能"要求详情能看到 + * **工作经历 / 补充电话 / 私人备注 / 各级地址** —— 这些只在 + * `GET /people/` 里有。 + * ⇒ ① 立即用本地那份把浮层打开(不等网络,点开就有内容); + * ② 同时发一个详情请求,回来就**原地替换**(字段更多、更准)。 + * ⚠️ 断网/失败时保持 ① 的显示,不弹错误 —— + * "详情少几项"比"点了没反应"好得多。 + * ⚠️ 用 `this.detailToken` 防止"快速连点两个人,前一个的响应把后一个覆盖"。 + */ private openDetail(id: number): void { const raw: FbRosterPerson | undefined = this.rawById.get(id); if (raw === undefined) { @@ -1277,8 +1461,18 @@ export struct FbPeoplePage { // 共用小件 // ============================================================ + /** + * 分段切换的一个按钮(名册 / 家谱)。 + * + * 🔴 v59c(用户 2026-10-18):「名册、家谱这两个标签,用的不是主题色。」 + * ⇒ 颜色**不再由调用方传字面量**,改成方法内部自己取名册模块的主题色。 + * 之前两个调用点各写一遍 `'#A84A28'`(品牌砖红,与名册的青色主题不符), + * 而且"两处各写一遍"本身就是漂移源(改一处忘一处)。 + * ⚠️ 取色走 `fbThemeVars(名册模块色).brand`(统一算法), + * 不是直接抄 `FB_MODULES` 里那个 `#4A7C82` —— 见调用点上方那段说明。 + */ @Builder - segBtn(label: string, key: string, color: string) { + segBtn(label: string, key: string) { Text(label) .fontSize(FbMetric.fontSmall) .fontWeight(this.subView === key ? FontWeight.Medium : FontWeight.Normal) @@ -1286,13 +1480,19 @@ export struct FbPeoplePage { .textAlign(TextAlign.Center) .height(30) .layoutWeight(1) - .backgroundColor(this.subView === key ? color : Color.Transparent) + .backgroundColor(this.subView === key ? this.themeBrand() : Color.Transparent) .borderRadius(FbMetric.radiusPill) .onClick(() => { this.subView = key; }) } + /** 名册模块的主题色(选中态实底)。取色规则见 `segBtn` 的说明。 */ + private themeBrand(): string { + const m: FbModuleDef | undefined = fbModuleOf('people'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + /** * 当前该显示的人(**标签筛选 + 关键词搜索**之后的结果)。 *