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:
@@ -29,6 +29,7 @@ import { FbCalBook } from '../model/Types';
|
|||||||
import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings';
|
import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings';
|
||||||
import { FbCalOptionRow } from './FbCalOptionRow';
|
import { FbCalOptionRow } from './FbCalOptionRow';
|
||||||
import { FbSheetRing } from './FbSheetRing';
|
import { FbSheetRing } from './FbSheetRing';
|
||||||
|
import { FbSheetTitleBar } from './FbSheetClose';
|
||||||
import { FbDot } from './Widgets';
|
import { FbDot } from './Widgets';
|
||||||
|
|
||||||
const FB_SHEET_BOTTOM_GAP: number = 16;
|
const FB_SHEET_BOTTOM_GAP: number = 16;
|
||||||
@@ -133,32 +134,22 @@ export struct FbCalSettingsSheet {
|
|||||||
Column() {
|
Column() {
|
||||||
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
|
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
|
||||||
Column({ space: 0 }) {
|
Column({ space: 0 }) {
|
||||||
// ---- 标题栏 ----
|
// ---- 标题栏(全应用统一版式:左标题 + 右「完成」,见 FbSheetClose.ets)----
|
||||||
Row({ space: 12 }) {
|
Row({ space: 12 }) {
|
||||||
Column({ space: 2 }) {
|
FbSheetTitleBar({
|
||||||
Text('日程设置')
|
title: '日程设置',
|
||||||
.fontSize(FbMetric.fontTitle)
|
/* 🔴 把"当前是什么"直接写在标题下面 ——
|
||||||
.fontWeight(FontWeight.Medium)
|
用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾) */
|
||||||
.fontColor(FbColors.ink)
|
subtitle: '当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style),
|
||||||
// 🔴 把"当前是什么"直接写在标题下面 ——
|
accent: this.accentColor(),
|
||||||
// 用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾)
|
// ⚠️ 设置类是"改完即生效" ⇒ 用「完成」而不是 ✕
|
||||||
Text('当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style))
|
confirmText: '完成',
|
||||||
.fontSize(FbMetric.fontTiny)
|
onClose: (): void => {
|
||||||
.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();
|
this.onClose();
|
||||||
})
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Center)
|
|
||||||
|
|
||||||
Scroll() {
|
Scroll() {
|
||||||
Column({ space: 6 }) {
|
Column({ space: 6 }) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import { FbCalEventFull } from '../data/FbCalRepo';
|
|||||||
import { FbCalBook } from '../model/Types';
|
import { FbCalBook } from '../model/Types';
|
||||||
import { FbDot } from './Widgets';
|
import { FbDot } from './Widgets';
|
||||||
import { FbSheetRing } from './FbSheetRing';
|
import { FbSheetRing } from './FbSheetRing';
|
||||||
|
import { FbSheetClose } from './FbSheetClose';
|
||||||
|
|
||||||
const FB_SHEET_BOTTOM_GAP: number = 16;
|
const FB_SHEET_BOTTOM_GAP: number = 16;
|
||||||
|
|
||||||
@@ -227,6 +228,15 @@ export struct FbEventSheet {
|
|||||||
.backgroundColor(FbColors.inputBg)
|
.backgroundColor(FbColors.inputBg)
|
||||||
.borderRadius(FbMetric.radiusPill)
|
.borderRadius(FbMetric.radiusPill)
|
||||||
}
|
}
|
||||||
|
/* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。
|
||||||
|
⚠️ 放在标题行的最右端,而不是下面独立一行 —— 独立一行会让卡片
|
||||||
|
顶部多出一段空白,标题反而往下掉。 */
|
||||||
|
FbSheetClose({
|
||||||
|
onClose: (): void => {
|
||||||
|
this.onClose();
|
||||||
|
},
|
||||||
|
color: ''
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Top)
|
.alignItems(VerticalAlign.Top)
|
||||||
@@ -301,20 +311,9 @@ export struct FbEventSheet {
|
|||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 底部按钮 ----
|
// ---- 底部按钮(只有动作,关闭统一到右上角 ✕)----
|
||||||
Row({ space: 10 }) {
|
if (this.canEdit && this.event !== null) {
|
||||||
Text('关闭')
|
Row({ space: 10 }) {
|
||||||
.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) {
|
|
||||||
/* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」
|
/* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」
|
||||||
⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。
|
⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。
|
||||||
改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */
|
改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */
|
||||||
@@ -330,9 +329,9 @@ export struct FbEventSheet {
|
|||||||
this.onEdit();
|
this.onEdit();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
.width('100%')
|
||||||
|
.margin({ top: 14 })
|
||||||
}
|
}
|
||||||
.width('100%')
|
|
||||||
.margin({ top: 14 })
|
|
||||||
}
|
}
|
||||||
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
|
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
|
||||||
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
|
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
import { FbColors, FbMetric, FbIcon, fbTint, FbTint } from '../common/Theme';
|
import { FbColors, FbMetric, FbIcon, fbTint, FbTint } from '../common/Theme';
|
||||||
import { FbSheetRing } from './FbSheetRing';
|
import { FbSheetRing } from './FbSheetRing';
|
||||||
|
import { FbSheetTitleBar } from './FbSheetClose';
|
||||||
|
|
||||||
/** 贴底卡片的手势区缓冲(vp)—— 同其它浮层
|
/** 贴底卡片的手势区缓冲(vp)—— 同其它浮层
|
||||||
* ⚠️ 2026-10-19 起本浮层改成**居中弹框**,这个常量暂时没有引用;
|
* ⚠️ 2026-10-19 起本浮层改成**居中弹框**,这个常量暂时没有引用;
|
||||||
@@ -80,23 +81,20 @@ export struct FbModuleSettingsSheet {
|
|||||||
Column() {
|
Column() {
|
||||||
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
|
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
|
||||||
Column({ space: 0 }) {
|
Column({ space: 0 }) {
|
||||||
// ---- 标题栏 ----
|
// ---- 标题栏(全应用统一版式:左标题 + 右「完成」,见 FbSheetClose.ets)----
|
||||||
Row() {
|
Row() {
|
||||||
Text(this.title)
|
FbSheetTitleBar({
|
||||||
.fontSize(FbMetric.fontTitle)
|
title: this.title,
|
||||||
.fontWeight(FontWeight.Medium)
|
accent: this.accentColor(),
|
||||||
.fontColor(FbColors.ink)
|
// ⚠️ 设置类是"改完即生效" ⇒ 用「完成」而不是 ✕(见 FbSheetTitleBar 的说明)
|
||||||
.layoutWeight(1)
|
confirmText: '完成',
|
||||||
Text('完成')
|
onClose: (): void => {
|
||||||
.fontSize(FbMetric.fontSmall)
|
|
||||||
.fontColor('#4A7C82')
|
|
||||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
|
||||||
.onClick((): void => {
|
|
||||||
this.onClose();
|
this.onClose();
|
||||||
})
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Center)
|
.padding({ left: 18, right: 12, top: 14, bottom: 6 })
|
||||||
|
|
||||||
Scroll() {
|
Scroll() {
|
||||||
Column({ space: 0 }) {
|
Column({ space: 0 }) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo';
|
|||||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||||
import { FbAvatar, FbRating } from './Widgets';
|
import { FbAvatar, FbRating } from './Widgets';
|
||||||
import { FbSheetRing } from './FbSheetRing';
|
import { FbSheetRing } from './FbSheetRing';
|
||||||
|
import { FbSheetClose } from './FbSheetClose';
|
||||||
import { fbImageSrc } from '../data/FbAttachQueue';
|
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,27 +185,31 @@ export struct FbMovieDetailSheet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 浮层顶部:把手 + 关闭 */
|
/** 浮层顶部:把手 + 关闭 */
|
||||||
|
/** 浮层顶部:**只有右上角的 ✕**(关闭位置全应用统一)。 */
|
||||||
@Builder
|
@Builder
|
||||||
sheetHeader() {
|
sheetHeader() {
|
||||||
|
/* 🔴🔴 用户 2026-10-19:「点击一个影片之后,弹出的框**左上角有一条灰色的线**,
|
||||||
|
不知道是什么。」
|
||||||
|
⇒ 那是原来放在标题行**最左端**的"把手"(36×4 的浅灰短线,
|
||||||
|
`FbColors.line2`)。我的本意是"抽屉可以下拉关掉"的视觉提示,
|
||||||
|
但 ① 它落在左上角、旁边没有任何文字,看起来就是**一条莫名其妙的灰线**;
|
||||||
|
② 这个浮层的关闭本来就不靠下拉(有按钮)。
|
||||||
|
⇒ 直接**去掉把手**(用户明确认不出它 = 它没起到提示作用),
|
||||||
|
同时把关闭收到右上角统一的 ✕(见 `FbSheetClose.ets`)。
|
||||||
|
⚠️ 不要"改成居中的把手"来救 —— 那只会把灰线从左上角挪到中间,
|
||||||
|
用户依然不知道它是什么;这一屏真正需要的信息是"怎么关掉我",
|
||||||
|
而那个就是右上角的 ✕。 */
|
||||||
Row() {
|
Row() {
|
||||||
// 把手(视觉提示"可以关掉")
|
|
||||||
Column()
|
|
||||||
.width(36)
|
|
||||||
.height(4)
|
|
||||||
.backgroundColor(FbColors.line2)
|
|
||||||
.borderRadius(2)
|
|
||||||
Blank()
|
Blank()
|
||||||
Text('关闭')
|
FbSheetClose({
|
||||||
.fontSize(FbMetric.fontSmall)
|
onClose: (): void => {
|
||||||
.fontColor(FbColors.muted)
|
|
||||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
|
||||||
.onClick((): void => {
|
|
||||||
this.onClose();
|
this.onClose();
|
||||||
})
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Center)
|
.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`:查看器的目的是"看清整张图",
|
// ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图",
|
||||||
// 裁掉边缘就失去意义(缩略图才用 Cover)。
|
// 裁掉边缘就失去意义(缩略图才用 Cover)。
|
||||||
Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
|
// 🔴 用户 2026-10-19:「点击这个图片呢,不能左右滑动看所有的这个图片。」
|
||||||
.width('100%')
|
// ⇒ 原来只有底部的「上一张/下一张」文字按钮,**没有滑动手势**。
|
||||||
.height('76%')
|
// 现在整张大图可以左右滑(见下面 `parallelGesture`)。
|
||||||
.objectFit(ImageFit.Contain)
|
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 }) {
|
Row({ space: 18 }) {
|
||||||
@@ -421,7 +457,19 @@ export struct FbMovieDetailSheet {
|
|||||||
// 别写死一个灰 —— 那样所有人的头像都是同一个颜色。
|
// 别写死一个灰 —— 那样所有人的头像都是同一个颜色。
|
||||||
color: a.avatarColor !== undefined && a.avatarColor !== ''
|
color: a.avatarColor !== undefined && a.avatarColor !== ''
|
||||||
? a.avatarColor : '#A84A28',
|
? 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)
|
Text(a.myNickname !== '' ? a.myNickname : a.name)
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
|
|||||||
import { FbRosterPerson } from '../data/FbPersonRepo';
|
import { FbRosterPerson } from '../data/FbPersonRepo';
|
||||||
import { FbAvatar, FbTag } from './Widgets';
|
import { FbAvatar, FbTag } from './Widgets';
|
||||||
import { FbSheetRing } from './FbSheetRing';
|
import { FbSheetRing } from './FbSheetRing';
|
||||||
|
import { FbSheetClose } from './FbSheetClose';
|
||||||
import { fbMediaUrl } from '../data/FbHttp';
|
import { fbMediaUrl } from '../data/FbHttp';
|
||||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||||
|
|
||||||
@@ -329,9 +330,17 @@ export struct FbPersonQuickSheet {
|
|||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.flexShrink(1)
|
.flexShrink(1)
|
||||||
|
|
||||||
|
/* 🔴 关闭统一到**右上角**(用户 2026-10-19,见 `FbSheetClose.ets`) */
|
||||||
|
FbSheetClose({
|
||||||
|
onClose: (): void => {
|
||||||
|
this.onClose();
|
||||||
|
},
|
||||||
|
color: this.brand()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Top)
|
||||||
|
|
||||||
// ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)----
|
// ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)----
|
||||||
//
|
//
|
||||||
@@ -358,13 +367,15 @@ export struct FbPersonQuickSheet {
|
|||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
|
||||||
// ---- 四个动作:详情 / 编辑 / 分享 / 关闭 ----
|
// ---- 三个动作:详情 / 编辑 / 分享 ----
|
||||||
//
|
//
|
||||||
// 🔴 「删除」**不在这里**(用户 2026-10-18:太容易误触)——
|
// 🔴 「删除」**不在这里**(用户 2026-10-18:太容易误触)——
|
||||||
// 它挪到了完整详情卡片的最下面(`FbPersonSheet` 的 `onDelete`)。
|
// 它挪到了完整详情卡片的最下面(`FbPersonSheet` 的 `onDelete`)。
|
||||||
// ⚠️ 「关闭」也做成**同等大小的按钮**(而不是一行文字链接):
|
// 🔴 「关闭」**也不在这里了**(用户 2026-10-19):
|
||||||
// 四个动作同一个视觉权重,用户不用在"哪个是按钮"上犹豫。
|
// 「你这个按钮到底是在弹框的右上角关闭,还是在下面关闭……全放在右上角。」
|
||||||
// 它是灰色(`inputBg` + 次要字色)—— 语义上"这一下什么都不改"。
|
// ⇒ 全应用统一成**右上角 ✕**(见 `FbSheetClose.ets` 的选择理由)。
|
||||||
|
// 底部只留"这一页真正要做的事";关闭是**每个浮层都有的同一动作**,
|
||||||
|
// 放在固定的右上角,手指不必每次重新找。
|
||||||
Row({ space: 8 }) {
|
Row({ space: 8 }) {
|
||||||
this.actionBtn('详情', FbTint.white, this.brand(), (): void => {
|
this.actionBtn('详情', FbTint.white, this.brand(), (): void => {
|
||||||
this.onDetail();
|
this.onDetail();
|
||||||
@@ -375,9 +386,6 @@ export struct FbPersonQuickSheet {
|
|||||||
this.actionBtn('分享', this.brand(), fbAlpha(this.brand(), 0.10), (): void => {
|
this.actionBtn('分享', this.brand(), fbAlpha(this.brand(), 0.10), (): void => {
|
||||||
this.onShare();
|
this.onShare();
|
||||||
})
|
})
|
||||||
this.actionBtn('关闭', FbTint.muted, INPUT_BG, (): void => {
|
|
||||||
this.onClose();
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
|
|||||||
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
|
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
|
||||||
import { FbAvatar, FbTag } from './Widgets';
|
import { FbAvatar, FbTag } from './Widgets';
|
||||||
import { FbSheetRing } from './FbSheetRing';
|
import { FbSheetRing } from './FbSheetRing';
|
||||||
|
import { FbSheetClose } from './FbSheetClose';
|
||||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||||
import { fbImageSrc } from '../data/FbAttachQueue';
|
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||||
|
|
||||||
@@ -400,11 +401,13 @@ export struct FbPersonSheet {
|
|||||||
.fontWeight(FontWeight.Medium)
|
.fontWeight(FontWeight.Medium)
|
||||||
.fontColor(FbColors.ink)
|
.fontColor(FbColors.ink)
|
||||||
.maxLines(1)
|
.maxLines(1)
|
||||||
|
.flexShrink(1)
|
||||||
if (this.person !== null && this.person.isAdmin) {
|
if (this.person !== null && this.person.isAdmin) {
|
||||||
FbTag({ text: '管理员', tone: 'brand' })
|
FbTag({ text: '管理员', tone: 'brand' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
// 本名(昵称与不同名时才有意义)
|
// 本名(昵称与不同名时才有意义)
|
||||||
if (this.person !== null && this.person.name !== this.person.shown) {
|
if (this.person !== null && this.person.name !== this.person.shown) {
|
||||||
@@ -419,9 +422,21 @@ export struct FbPersonSheet {
|
|||||||
}
|
}
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
|
||||||
|
/* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。
|
||||||
|
⚠️ 与头像**同一行**:原来底部那个「关闭」按钮已删 ——
|
||||||
|
它和「编辑」并排等宽,用户反馈"分不清哪个是主操作"。
|
||||||
|
⚠️ `alignItems` 用 `Top`:头像 60 高,✕ 32 高,
|
||||||
|
居中会让它离卡片顶边太远(看着像浮在名字旁边)。 */
|
||||||
|
FbSheetClose({
|
||||||
|
onClose: (): void => {
|
||||||
|
this.onClose();
|
||||||
|
},
|
||||||
|
color: this.accentColor()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Top)
|
||||||
|
|
||||||
// ---- 标签 ----
|
// ---- 标签 ----
|
||||||
// ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的
|
// ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的
|
||||||
@@ -540,17 +555,6 @@ export struct FbPersonSheet {
|
|||||||
.onClick((): void => {
|
.onClick((): void => {
|
||||||
this.onEdit();
|
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%')
|
.width('100%')
|
||||||
.margin({ top: 18 })
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,19 +206,24 @@ export struct FbAvatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
// ⚠️ 用 if/else 而不是"图层叠":两种形态的**尺寸来源**不同
|
/* 🔴🔴 v62(用户 2026-10-19):「**如果一个人没头像,在列表是有一个文字的
|
||||||
// (Image 有 intrinsic size,Text 靠 width/height)——
|
头像的,那么点击后弹框,也应该有。**」
|
||||||
// 叠起来容易出现"图把字盖住但字还在占位"的错位。
|
⇒ 列表有文字头像(因为 `imageUrl` 是空 ⇒ 走 Text 分支),
|
||||||
if (this.imageUrl !== '') {
|
而详情/简要卡是**纯色块**。
|
||||||
Image(this.imageUrl)
|
真因:这里原来是 `if/else` —— 只要 `imageUrl` 非空就**只画 Image**,
|
||||||
.width(this.dia)
|
而那张图**加载失败时 `Image` 什么都不显示**(只露出 `.backgroundColor(color)`
|
||||||
.height(this.dia)
|
的纯色块)⇒ 看起来就是"一个光板,没有文字头像"
|
||||||
.borderRadius(this.dia / 2)
|
(`alt()` 只在**加载中**起作用,失败后是空白)。
|
||||||
// 加载失败/慢时先显示底色,避免"一片空白"
|
⚠️ 而"有 imageUrl 的人"其实不多(实测 800 人里 19 人),
|
||||||
.backgroundColor(this.color)
|
但**空 key 与坏 key 的观感差别很大**:前者有字,后者是光板。
|
||||||
.objectFit(ImageFit.Cover)
|
⇒ 改成**叠加**:底色 + 文字头像**常驻**,图片有就叠在上面
|
||||||
.alt($r('app.media.fb_logo'))
|
(图片一旦加载成功就完全盖住文字,失败则自动露出文字头像)。
|
||||||
} else {
|
⚠️ 之前注释说"图层叠会错位"——那是因为当时想靠 Image 的 intrinsic size 撑尺寸。
|
||||||
|
现在**两层都显式给 `dia`**,尺寸来源唯一,不存在错位。
|
||||||
|
⚠️ 用 Stack 而不是 `backgroundImage`:`backgroundImage` 不接受网络地址,
|
||||||
|
而头像走的是 `media.php?k=...`(见 `fbImageUrl` 的说明)。 */
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
// ① 底色 + 文字头像(**永远在**,是"图的兜底")
|
||||||
Text(this.initial())
|
Text(this.initial())
|
||||||
.width(this.dia)
|
.width(this.dia)
|
||||||
.height(this.dia)
|
.height(this.dia)
|
||||||
@@ -228,7 +233,18 @@ export struct FbAvatar {
|
|||||||
.fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42))
|
.fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42))
|
||||||
.fontWeight(FontWeight.Medium)
|
.fontWeight(FontWeight.Medium)
|
||||||
.textAlign(TextAlign.Center)
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { http } from '@kit.NetworkKit';
|
|||||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { FB_SERVER_STORE } from './FbServerStore';
|
import { FB_SERVER_STORE } from './FbServerStore';
|
||||||
|
// ⚠️ 只为 `fbApi` 的"漏传令牌兜底"(见那里的说明)——
|
||||||
|
// `FbTokenStore` 不 import 本文件,不存在循环依赖。
|
||||||
|
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||||
|
|
||||||
const DOMAIN: number = 0x0000;
|
const DOMAIN: number = 0x0000;
|
||||||
const TAG: string = 'FbHttp';
|
const TAG: string = 'FbHttp';
|
||||||
@@ -219,8 +222,29 @@ export async function fbApi(path: string, opts: FbApiOptions = {}): Promise<FbAp
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
};
|
};
|
||||||
if (opts.token !== undefined && opts.token !== '') {
|
/* 🔴🔴 漏传令牌兜底(2026-10-19 事故后加的)。
|
||||||
header['Authorization'] = 'Bearer ' + opts.token;
|
*
|
||||||
|
* 事故经过:新写的「个人设置」页调 `fbApi('/api/v1/account')` 时**忘了传令牌**
|
||||||
|
* ⇒ 服务端 401 ⇒ 下面的全局回调把令牌清掉、把用户踢回登录页。
|
||||||
|
* 症状是"点一下右上角头像,App 就退出登录了"(用户报的"不稳定、会退出")。
|
||||||
|
*
|
||||||
|
* 这类 bug 的**危险程度与"接口用不了"完全不同**:它顺手把用户踢下线。
|
||||||
|
* ⇒ 两道防线,这里是第一道:**本地有令牌、调用方又没显式传时,自动补上**。
|
||||||
|
* 大部分接口本来就要鉴权,补上是"纠正"而不是"越权";
|
||||||
|
* 少数公开接口(`/ping`、`/login`)服务端根本不看这个头,带了也无害。
|
||||||
|
* ⚠️ 想**明确不带**令牌(真的不需要鉴权的探测)时,传 `token: ''` 也行 ——
|
||||||
|
* 空串会走下面的分支被当成"没传",所以需要靠 `authProbe` 去跳过踢出逻辑。
|
||||||
|
*/
|
||||||
|
let sentToken: string = opts.token !== undefined ? opts.token : '';
|
||||||
|
if (sentToken === '' && opts.authProbe !== true) {
|
||||||
|
const fallback: string = FB_TOKEN_STORE.get();
|
||||||
|
if (fallback !== '') {
|
||||||
|
sentToken = fallback;
|
||||||
|
hilog.warn(DOMAIN, TAG, '调用方没传令牌,已自动补上:%{public}s', path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sentToken !== '') {
|
||||||
|
header['Authorization'] = 'Bearer ' + sentToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extra: http.HttpRequestOptions = {
|
const extra: http.HttpRequestOptions = {
|
||||||
@@ -239,8 +263,16 @@ export async function fbApi(path: string, opts: FbApiOptions = {}): Promise<FbAp
|
|||||||
const out: FbApiResult = parseEnvelope(body, resp.responseCode);
|
const out: FbApiResult = parseEnvelope(body, resp.responseCode);
|
||||||
/* v57:401 = 令牌无效/过期/被踢 → 立刻通知全局锁定(见 FbUnauthorizedCb 的说明)。
|
/* v57:401 = 令牌无效/过期/被踢 → 立刻通知全局锁定(见 FbUnauthorizedCb 的说明)。
|
||||||
⚠️ 判的是 **HTTP 状态码**而不是 `code` 字段:服务端对三种情况都回 401,
|
⚠️ 判的是 **HTTP 状态码**而不是 `code` 字段:服务端对三种情况都回 401,
|
||||||
端上不需要(也不该)区分是"过期"还是"被踢"——处理动作完全一样。 */
|
端上不需要(也不该)区分是"过期"还是"被踢"——处理动作完全一样。
|
||||||
if (out.status === 401 && opts.authProbe !== true && unauthorizedCb !== null) {
|
🔴🔴 但有一条**必须排除**(2026-10-19 事故后加的,第二道防线):
|
||||||
|
**这次请求压根就没带令牌** ⇒ 服务端说"缺少 API 令牌",
|
||||||
|
这不是"会话失效",是"我们没证明自己是谁"。
|
||||||
|
把它也当成被踢,就会出现"某个接口忘了传令牌 → 用户被踢下线"。
|
||||||
|
⇒ 只有**确实带了令牌**却仍 401,才说明令牌真的失效了。 */
|
||||||
|
const got401: boolean = out.status === 401 && opts.authProbe !== true;
|
||||||
|
if (got401 && sentToken === '') {
|
||||||
|
hilog.warn(DOMAIN, TAG, '请求没带令牌而收到 401(不当成"被踢"):%{public}s', path);
|
||||||
|
} else if (got401 && unauthorizedCb !== null) {
|
||||||
unauthorizedCb();
|
unauthorizedCb();
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { relationalStore } from '@kit.ArkData';
|
|||||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||||
import { FbDb, FbRow } from './FbDb';
|
import { FbDb, FbRow } from './FbDb';
|
||||||
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
|
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
|
||||||
|
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||||
|
|
||||||
const DOMAIN: number = 0x0000;
|
const DOMAIN: number = 0x0000;
|
||||||
const TAG: string = 'FbMovieRepo';
|
const TAG: string = 'FbMovieRepo';
|
||||||
@@ -99,6 +100,105 @@ export interface FbSyncResult {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== v62:影院管理的统计 / 报告 / 搜索 ===================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 观影总览(`MovieStats::overview`)—— 与网页端**同一份实现**,数字必须一致。
|
||||||
|
*/
|
||||||
|
export interface FbMovieOverview {
|
||||||
|
movies?: number;
|
||||||
|
cinemas?: number;
|
||||||
|
persons?: number;
|
||||||
|
viewership?: number;
|
||||||
|
first_date?: string | null;
|
||||||
|
last_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按影院计数(`MovieStats::byCinema`) */
|
||||||
|
export interface FbMovieCinemaStat {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
place?: string;
|
||||||
|
color?: string;
|
||||||
|
cnt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按月计数(`MovieStats::byMonth`) */
|
||||||
|
export interface FbMovieMonthStat {
|
||||||
|
ym?: string;
|
||||||
|
label?: string;
|
||||||
|
cnt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按年计数(`MovieStats::byYear`) */
|
||||||
|
export interface FbMovieYearStat {
|
||||||
|
year?: number;
|
||||||
|
cnt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/api/v1/movies/stats` 的完整载荷 */
|
||||||
|
export interface FbMovieStats {
|
||||||
|
overview?: FbMovieOverview;
|
||||||
|
by_cinema?: FbMovieCinemaStat[];
|
||||||
|
by_person?: Object[];
|
||||||
|
by_month?: FbMovieMonthStat[];
|
||||||
|
by_year?: FbMovieYearStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 年度报告(`MovieStats::annual`)—— 以登录者为中心,只统计他参与过的场次。
|
||||||
|
*
|
||||||
|
* ⚠️ 字段很多(月份分布 / 星期分布 / 最常去的影院 / 一起看最多的人……),
|
||||||
|
* 这里只声明**端上要显示的那几个**,其余原样留在 JSON 里不取。
|
||||||
|
*/
|
||||||
|
export interface FbMovieReportStats {
|
||||||
|
total?: number;
|
||||||
|
days?: number;
|
||||||
|
top_cinema?: string;
|
||||||
|
top_cinema_count?: number;
|
||||||
|
top_person?: string;
|
||||||
|
top_person_count?: number;
|
||||||
|
first_date?: string | null;
|
||||||
|
last_date?: string | null;
|
||||||
|
most_viewers_movie?: string;
|
||||||
|
most_viewers_count?: number;
|
||||||
|
avg_per_month?: number;
|
||||||
|
avg_days_between?: number;
|
||||||
|
month_distribution?: Record<string, number>;
|
||||||
|
weekday_distribution?: Record<string, number>;
|
||||||
|
keywords?: string[];
|
||||||
|
recent_movies?: Object[];
|
||||||
|
my_movies?: Object[];
|
||||||
|
common_viewers?: Object[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FbMovieReport {
|
||||||
|
year?: number;
|
||||||
|
years?: number[];
|
||||||
|
stats?: FbMovieReportStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 高级搜索的条件(全空 = 不查,与网页 `hasCond` 同一行为) */
|
||||||
|
export interface FbMovieSearchParams {
|
||||||
|
title: string;
|
||||||
|
cinemaId: number;
|
||||||
|
personId: number;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 高级搜索返回的一行(服务端 `MovieStats::search` 的字段) */
|
||||||
|
export interface FbApiMovieSearchRow {
|
||||||
|
id?: number;
|
||||||
|
title?: string;
|
||||||
|
starts_at?: string;
|
||||||
|
ends_at?: string;
|
||||||
|
poster_key?: string | null;
|
||||||
|
cinema_name?: string | null;
|
||||||
|
cinema_color?: string | null;
|
||||||
|
attendee_names?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 观影详情(点卡片后展示) */
|
/** 观影详情(点卡片后展示) */
|
||||||
export interface FbMovieDetail {
|
export interface FbMovieDetail {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -558,6 +658,158 @@ export class FbMovieRepo {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建 / 修改一家影院(**直接打接口,不等同步**)。
|
||||||
|
*
|
||||||
|
* 🔴 v62(用户 2026-10-19):「设置里面的影院管理,这些功能都还没有,
|
||||||
|
* 应该补充,就是增加影院呢、修改影院呢……」
|
||||||
|
* ⇒ 影院**本来只能被动等观影同步带下来**(`sync()` 里那段),
|
||||||
|
* 用户想"先建一家影院、再往里记片子"是做不到的。
|
||||||
|
*
|
||||||
|
* ⚠️ 服务端**早就有** CRUD(`cinemas()` 支持 POST/PATCH/DELETE),
|
||||||
|
* 缺的只是端上没接 —— 所以这里不需要动服务端,只要把它接上。
|
||||||
|
* ⚠️ 写完**立刻拉一次影院列表回写本地库**:否则用户新建完,
|
||||||
|
* 这个浮层还显示旧列表(他刚建的那家要等下次同步才出现,看着像失败)。
|
||||||
|
* ⇒ 与"本地优先"不冲突:本地优先说的是**读**走本地,
|
||||||
|
* 而写操作本来就必须联网(写不进去时本地也不该装作成功)。
|
||||||
|
*
|
||||||
|
* @param id 0 = 新建;> 0 = 修改
|
||||||
|
* @returns 错误文案('' = 成功)
|
||||||
|
*/
|
||||||
|
async saveCinema(id: number, place: string, name: string,
|
||||||
|
address: string, color: string): Promise<string> {
|
||||||
|
const token: string = FB_TOKEN_STORE.get();
|
||||||
|
if (token === '') {
|
||||||
|
return '还没登录';
|
||||||
|
}
|
||||||
|
if (name.trim() === '') {
|
||||||
|
return '影院名称不能为空';
|
||||||
|
}
|
||||||
|
const body: Record<string, Object> = {};
|
||||||
|
body['place'] = place.trim();
|
||||||
|
body['name'] = name.trim();
|
||||||
|
body['address'] = address.trim();
|
||||||
|
body['color'] = color;
|
||||||
|
const opts: FbApiOptions = id > 0
|
||||||
|
? { method: 'PATCH', token: token, body: body }
|
||||||
|
: { method: 'POST', token: token, body: body };
|
||||||
|
const r: FbApiResult = id > 0
|
||||||
|
? await fbApi('/api/v1/cinemas/' + id.toString(), opts)
|
||||||
|
: await fbApi('/api/v1/cinemas', opts);
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.error !== '' ? r.error : '保存失败';
|
||||||
|
}
|
||||||
|
// 立刻回写本地库(见方法说明:不回写的话浮层还显示旧列表)
|
||||||
|
await this.refreshCinemas(token);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删一家影院(服务端会把关联观影记录的 `cinema_id` 置空,历史记录保留) */
|
||||||
|
async deleteCinema(id: number): Promise<string> {
|
||||||
|
const token: string = FB_TOKEN_STORE.get();
|
||||||
|
if (token === '') {
|
||||||
|
return '还没登录';
|
||||||
|
}
|
||||||
|
const r: FbApiResult = await fbApi('/api/v1/cinemas/' + id.toString(),
|
||||||
|
{ method: 'DELETE', token: token });
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.error !== '' ? r.error : '删除失败';
|
||||||
|
}
|
||||||
|
await this.refreshCinemas(token);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新拉一次影院列表并覆盖本地 `cinemas` 表。
|
||||||
|
*
|
||||||
|
* ⚠️ 用 `INSERT OR REPLACE` 而不是"先清空再插":清空再插会在中途失败时
|
||||||
|
* 留下一个空表(用户的影院列表整个消失),而 REPLACE 最差也只是旧的没更新。
|
||||||
|
*/
|
||||||
|
async refreshCinemas(token?: string): Promise<number> {
|
||||||
|
const tk: string = token !== undefined ? token : FB_TOKEN_STORE.get();
|
||||||
|
if (tk === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const r: FbApiResult = await fbApi('/api/v1/cinemas', { method: 'GET', token: tk });
|
||||||
|
if (!r.ok) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const rows: FbCinemaRow[] = parseCinemaRows(r.data);
|
||||||
|
for (const c of rows) {
|
||||||
|
await this.db.exec(
|
||||||
|
'INSERT OR REPLACE INTO cinemas (id, family_id, place, name, address, color,'
|
||||||
|
+ ' created_at, updated_at, deleted_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||||
|
[c.id, c.familyId, c.place, c.name, c.address, c.color,
|
||||||
|
c.createdAt, c.updatedAt, c.deletedAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 观影统计(家族级)—— 走 `/api/v1/movies/stats`。
|
||||||
|
*
|
||||||
|
* 🔴 为什么不从本地库算:统计口径必须与**网页端**一致
|
||||||
|
* (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分",
|
||||||
|
* 少同步一次就少一部 —— 用户会发现"网页 124 部、App 121 部"。
|
||||||
|
* ⇒ 统计**必须联网**(它本来就是"看一眼现状",不要求断网可用)。
|
||||||
|
*/
|
||||||
|
async stats(token: string): Promise<FbMovieStats | null> {
|
||||||
|
const r: FbApiResult = await fbApi('/api/v1/movies/stats', { method: 'GET', token: token });
|
||||||
|
if (!r.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(r.data) as FbMovieStats;
|
||||||
|
} catch (err) {
|
||||||
|
hilog.error(DOMAIN, TAG, '统计解析失败:%{public}s', JSON.stringify(err));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 年度报告(以登录者为中心)—— 走 `/api/v1/movies/report` */
|
||||||
|
async report(token: string, year: number): Promise<FbMovieReport | null> {
|
||||||
|
/* ⚠️ `query` 必须走**显式声明类型**的变量:ArkTS 的
|
||||||
|
`arkts-no-untyped-obj-literals` 不允许把裸对象字面量直接传进
|
||||||
|
`Record<string, string>` 形参(本项目已踩过同一条,见 FbAccountPage)。 */
|
||||||
|
const q: Record<string, string> = {};
|
||||||
|
q['year'] = year.toString();
|
||||||
|
const r: FbApiResult = await fbApi('/api/v1/movies/report',
|
||||||
|
{ method: 'GET', token: token, query: q });
|
||||||
|
if (!r.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(r.data) as FbMovieReport;
|
||||||
|
} catch (err) {
|
||||||
|
hilog.error(DOMAIN, TAG, '年度报告解析失败:%{public}s', JSON.stringify(err));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 高级搜索 —— 走 `/api/v1/movies/search` */
|
||||||
|
async search(token: string, p: FbMovieSearchParams): Promise<FbApiMovieSearchRow[]> {
|
||||||
|
const q: Record<string, string> = {};
|
||||||
|
if (p.title !== '') { q['title'] = p.title; }
|
||||||
|
if (p.cinemaId > 0) { q['cinema_id'] = p.cinemaId.toString(); }
|
||||||
|
if (p.personId > 0) { q['person_id'] = p.personId.toString(); }
|
||||||
|
if (p.dateFrom !== '') { q['date_from'] = p.dateFrom; }
|
||||||
|
if (p.dateTo !== '') { q['date_to'] = p.dateTo; }
|
||||||
|
const r: FbApiResult = await fbApi('/api/v1/movies/search',
|
||||||
|
{ method: 'GET', token: token, query: q });
|
||||||
|
if (!r.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
|
||||||
|
const arr: Object[] | undefined = o['rows'] as Object[] | undefined;
|
||||||
|
return arr !== undefined && Array.isArray(arr) ? (arr as FbApiMovieSearchRow[]) : [];
|
||||||
|
} catch (err) {
|
||||||
|
hilog.error(DOMAIN, TAG, '搜索解析失败:%{public}s', JSON.stringify(err));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async watermark(table: string): Promise<string> {
|
async watermark(table: string): Promise<string> {
|
||||||
const row: FbRow | null = await this.db.queryOne(
|
const row: FbRow | null = await this.db.queryOne(
|
||||||
'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]);
|
'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]);
|
||||||
|
|||||||
@@ -77,18 +77,18 @@ function fbChip(id: number, shown: string, kinship: string, color: string): FbKi
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export const FB_DEMO_ROSTER: FbRosterItem[] = [
|
export const FB_DEMO_ROSTER: FbRosterItem[] = [
|
||||||
{ id: 1, name: '杨永全', kinship: '我', group: '本家', tags: ['管理员'], avatarColor: '#A84A28', roster: 'member', birthday: '03-12', avatar: '', phone: '', initials: 'x', isAdmin: true, phones: [] },
|
{ id: 1, name: '杨永全', kinship: '我', group: '本家', tags: ['管理员'], avatarColor: '#A84A28', roster: 'member', birthday: '03-12', avatar: '', phone: '', initials: 'x', isAdmin: true, phones: [], org: '', title: '' },
|
||||||
{ id: 2, name: '李慧', kinship: '配偶', group: '本家', tags: [], avatarColor: '#9A5C7A', roster: 'member', birthday: '07-08', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 2, name: '李慧', kinship: '配偶', group: '本家', tags: [], avatarColor: '#9A5C7A', roster: 'member', birthday: '07-08', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 3, name: '杨小满', kinship: '儿子', group: '本家', tags: ['学生'], avatarColor: '#4A7C82', roster: 'member', birthday: '10-18', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 3, name: '杨小满', kinship: '儿子', group: '本家', tags: ['学生'], avatarColor: '#4A7C82', roster: 'member', birthday: '10-18', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 4, name: '杨小禾', kinship: '女儿', group: '本家', tags: ['学生'], avatarColor: '#6B5B95', roster: 'member', birthday: '01-04', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 4, name: '杨小禾', kinship: '女儿', group: '本家', tags: ['学生'], avatarColor: '#6B5B95', roster: 'member', birthday: '01-04', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 5, name: '杨建国', kinship: '父亲', group: '长辈', tags: ['退休'], avatarColor: '#8A6B4B', roster: 'member', birthday: '05-21', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 5, name: '杨建国', kinship: '父亲', group: '长辈', tags: ['退休'], avatarColor: '#8A6B4B', roster: 'member', birthday: '05-21', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 6, name: '王秀兰', kinship: '母亲', group: '长辈', tags: [], avatarColor: '#B0763A', roster: 'member', birthday: '11-30', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 6, name: '王秀兰', kinship: '母亲', group: '长辈', tags: [], avatarColor: '#B0763A', roster: 'member', birthday: '11-30', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 7, name: '杨永强', kinship: '弟弟', group: '本家', tags: [], avatarColor: '#3F7D5A', roster: 'member', birthday: '08-15', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 7, name: '杨永强', kinship: '弟弟', group: '本家', tags: [], avatarColor: '#3F7D5A', roster: 'member', birthday: '08-15', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 8, name: '李国栋', kinship: '岳父', group: '姻亲', tags: [], avatarColor: '#5566A8', roster: 'member', birthday: '02-26', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 8, name: '李国栋', kinship: '岳父', group: '姻亲', tags: [], avatarColor: '#5566A8', roster: 'member', birthday: '02-26', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 9, name: '陈静', kinship: '岳母', group: '姻亲', tags: [], avatarColor: '#A8574F', roster: 'member', birthday: '09-09', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 9, name: '陈静', kinship: '岳母', group: '姻亲', tags: [], avatarColor: '#A8574F', roster: 'member', birthday: '09-09', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 10, name: '张伟', kinship: '朋友', group: '亲友', tags: ['同事'], avatarColor: '#5E7A3C', roster: 'friend', birthday: '06-14', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 10, name: '张伟', kinship: '朋友', group: '亲友', tags: ['同事'], avatarColor: '#5E7A3C', roster: 'friend', birthday: '06-14', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 11, name: '刘敏', kinship: '朋友', group: '亲友', tags: ['同学'], avatarColor: '#7A6A4F', roster: 'friend', birthday: '12-02', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 11, name: '刘敏', kinship: '朋友', group: '亲友', tags: ['同学'], avatarColor: '#7A6A4F', roster: 'friend', birthday: '12-02', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
{ id: 12, name: '赵鹏', kinship: '朋友', group: '亲友', tags: ['邻居'], avatarColor: '#3D6B8E', roster: 'friend', birthday: '04-17', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
|
{ id: 12, name: '赵鹏', kinship: '朋友', group: '亲友', tags: ['邻居'], avatarColor: '#3D6B8E', roster: 'friend', birthday: '04-17', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 家谱连线(父母 / 配偶 / 子女) */
|
/** 家谱连线(父母 / 配偶 / 子女) */
|
||||||
|
|||||||
@@ -315,6 +315,21 @@ export interface FbRosterItem {
|
|||||||
birthday: string;
|
birthday: string;
|
||||||
/** 是否管理员徽标 */
|
/** 是否管理员徽标 */
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
/**
|
||||||
|
* 工作单位(如「中国海洋大学」;空 = 没填)。
|
||||||
|
*
|
||||||
|
* 🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容,
|
||||||
|
* 例如**工作单位、职务**。」
|
||||||
|
*
|
||||||
|
* ⚠️ 用 `org`/`title` 而**不是** `person_jobs`(工作经历表):
|
||||||
|
* 列表读的是**本地库**,而本地库只存 `persons` 那一行
|
||||||
|
* (`person_jobs` 是一对多,端上没同步那张表 —— 见 `rowToPerson` 的说明)。
|
||||||
|
* `org`/`title` 是服务端 `persons` 上的字段、**已经在本地库里**,
|
||||||
|
* 线上实测 801 人里 192 人有单位、48 人有职务 ⇒ 列表立刻有内容。
|
||||||
|
*/
|
||||||
|
org: string;
|
||||||
|
/** 职务 / 职称(如「副教授」;空 = 没填) */
|
||||||
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -100,7 +100,17 @@ export struct FbAccountPage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r: FbApiResult = await fbApi('/api/v1/account');
|
/* 🔴🔴 必须把令牌传进去(2026-10-19 事故):
|
||||||
|
这里原来调用时**只给了路径、没给请求选项** —— `token` 变量取了却没用。
|
||||||
|
后果**极严重**:本页在 `aboutToAppear` 里就调它,而 `/account` 是要鉴权的
|
||||||
|
⇒ 请求 401 ⇒ 网络层的全局回调(`fbOnUnauthorized`)立刻**清掉本地令牌
|
||||||
|
+ 踢回登录页** ⇒ 用户表现为"点一下右上角头像,App 就退出登录了"
|
||||||
|
(这就是用户报的"现在还是不稳定,会退出,提示:这台设备已经被退出登录")。
|
||||||
|
⚠️ 同类检查:**任何带 token 变量却不传的 fbApi 调用都是定时炸弹**,
|
||||||
|
因为它不是"这个接口不能用",而是"顺手把你踢下线"。
|
||||||
|
⇒ 已在 `fbApi()` 里加了兜底(本地有令牌、请求又没带时自动补上),
|
||||||
|
并加了静态守卫(`_fb_arkts_guard.js` 的 groupV62)盯这类写法。 */
|
||||||
|
const r: FbApiResult = await fbApi('/api/v1/account', { token: token });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.accountNote = r.error;
|
this.accountNote = r.error;
|
||||||
return;
|
return;
|
||||||
@@ -181,8 +191,9 @@ export struct FbAccountPage {
|
|||||||
body['password'] = this.pw.fresh;
|
body['password'] = this.pw.fresh;
|
||||||
body['password2'] = this.pw.again;
|
body['password2'] = this.pw.again;
|
||||||
try {
|
try {
|
||||||
|
// 🔴 同 `loadAccount` 那处:令牌必须传(漏传 = 401 = 被踢下线)
|
||||||
const r: FbApiResult = await fbApi('/api/v1/account/password',
|
const r: FbApiResult = await fbApi('/api/v1/account/password',
|
||||||
{ method: 'POST', body: body });
|
{ method: 'POST', body: body, token: FB_TOKEN_STORE.get() });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.pwBad = true;
|
this.pwBad = true;
|
||||||
this.pwNote = r.error;
|
this.pwNote = r.error;
|
||||||
|
|||||||
@@ -323,6 +323,23 @@ export struct FbMoviesPage {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新读一次本地影院列表(影院管理里增/改/删之后调)。
|
||||||
|
*
|
||||||
|
* 🔴 v62:不重读的话,关掉影院浮层后页头上那个「影院 N 家」还是旧数字
|
||||||
|
* (用户刚新增了一家,页头却还写着 26 家 ⇒ 会以为没保存成功)。
|
||||||
|
* ⚠️ 只读本地库(`cinemaList()`)不联网 —— 写操作那边已经回写过本地库了
|
||||||
|
* (见 `FbMovieRepo.saveCinema` 的说明),这里再打一次接口是多余的往返。
|
||||||
|
* ⚠️ 失败**不弹错**:这只是个页头计数,失败了下次同步也会纠正。
|
||||||
|
*/
|
||||||
|
private async reloadCinemas(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.cinemas = await this.repo.cinemaList();
|
||||||
|
} catch (err) {
|
||||||
|
hilog.warn(DOMAIN, TAG, '重读本地影院失败:%{public}s', JSON.stringify(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台同步:拉接口 → 落本地库 → 再读一次刷新界面。
|
* 后台同步:拉接口 → 落本地库 → 再读一次刷新界面。
|
||||||
*
|
*
|
||||||
@@ -394,8 +411,16 @@ export struct FbMoviesPage {
|
|||||||
|
|
||||||
FbCinemaSheet({
|
FbCinemaSheet({
|
||||||
cinemas: this.cinemas,
|
cinemas: this.cinemas,
|
||||||
|
// 🔴 v62:环色/主题色也传(影院管理里的页签、按钮都用观影模块色)
|
||||||
|
accent: this.accent,
|
||||||
onClose: (): void => {
|
onClose: (): void => {
|
||||||
this.cinemaSheetOpen = false;
|
this.cinemaSheetOpen = false;
|
||||||
|
},
|
||||||
|
/* 🔴 v62(用户 2026-10-19):影院管理里新增/修改/删除之后,
|
||||||
|
要让**本页也拿到新列表** —— 否则关掉浮层,页头上那个
|
||||||
|
「影院 N 家」和影院色点还是旧的(用户会以为没保存成功)。 */
|
||||||
|
onCinemasChanged: (): void => {
|
||||||
|
this.reloadCinemas();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.transition(fbSheetTransition())
|
.transition(fbSheetTransition())
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ function toRosterItem(p: FbRosterPerson): FbRosterItem {
|
|||||||
roster: 'member',
|
roster: 'member',
|
||||||
birthday: p.birthdaySolar,
|
birthday: p.birthdaySolar,
|
||||||
isAdmin: p.isAdmin,
|
isAdmin: p.isAdmin,
|
||||||
|
// 🔴 v62:单位/职务(列表要显示,见 FbRosterItem.org 的说明)。
|
||||||
|
// ⚠️ 直接取 `p.org`/`p.title`(本地库里已有),**不是** `p.jobs[0]` ——
|
||||||
|
// 列表读本地库,而本地的 `jobs` 恒为空(那张表没同步)。
|
||||||
|
org: p.org,
|
||||||
|
title: p.title,
|
||||||
};
|
};
|
||||||
return it;
|
return it;
|
||||||
}
|
}
|
||||||
@@ -436,6 +441,28 @@ export struct FbPeoplePage {
|
|||||||
: '登录后会从服务器拉取你名册里的人';
|
: '登录后会从服务器拉取你名册里的人';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表里那行「单位|职务」。
|
||||||
|
*
|
||||||
|
* 🔴 v62:用户要列表显示单位/职务。取 `jobs[0]`(= 当前/最近那段,
|
||||||
|
* 排序由服务端保证,与详情页一致)。
|
||||||
|
* ⚠️ **不显示起止时间**:列表要的是"他现在在哪、做什么",
|
||||||
|
* 加个「(2020-09 至今)」会让这一行太长、被省略号吃掉职务
|
||||||
|
* (起止在详情页的「工作经历」里有完整显示)。
|
||||||
|
* ⚠️ 只有单位没有职务(或反之)时不要留一个空的分隔符 ——
|
||||||
|
* 用数组 join,缺哪个就少一段。
|
||||||
|
*/
|
||||||
|
private jobLine(p: FbRosterItem): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (p.org !== '') {
|
||||||
|
parts.push(p.org);
|
||||||
|
}
|
||||||
|
if (p.title !== '') {
|
||||||
|
parts.push(p.title);
|
||||||
|
}
|
||||||
|
return parts.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
/** 从本地库读名册并刷新界面 */
|
/** 从本地库读名册并刷新界面 */
|
||||||
private async reloadFromLocal(): Promise<void> {
|
private async reloadFromLocal(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -1164,10 +1191,20 @@ export struct FbPeoplePage {
|
|||||||
*/
|
*/
|
||||||
@Builder
|
@Builder
|
||||||
dockSpacerItem() {
|
dockSpacerItem() {
|
||||||
|
/* 🔴🔴 v62(用户 2026-10-19):「**名册搜索,搜索后,下面有一个白色的框,
|
||||||
|
什么内容都没有,删除。**」
|
||||||
|
⇒ 就是这个占位块。它**必须留着**(高度要给悬浮栏让位,
|
||||||
|
否则最后一张卡片会被悬浮栏压住),但它的底色原来是 `FbColors.surface`
|
||||||
|
(**白**),而列表底色是 `FbColors.paper`(**米**)⇒
|
||||||
|
列表内容一短(搜索只剩一两条时)它就露出来,看着像"一个什么都没有的白框"。
|
||||||
|
⇒ 改成与列表同色(paper)⇒ 它变成"看不见的空白",
|
||||||
|
预留高度的作用不变,但不再有那个框。
|
||||||
|
⚠️ 别改成 `height(0)` 或删掉:那会让最后一张卡片被悬浮栏压住
|
||||||
|
(这正是当初加它的原因,见 `build` 里关于"内容从悬浮框上面滚出来"的说明)。 */
|
||||||
Row()
|
Row()
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height(FB_DOCK_RESERVE)
|
.height(FB_DOCK_RESERVE)
|
||||||
.backgroundColor(FbColors.surface)
|
.backgroundColor(FbColors.paper)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一个人一行:头像 + 名字/称谓 + 标签 + 生日 */
|
/** 一个人一行:头像 + 名字/称谓 + 标签 + 生日 */
|
||||||
@@ -1218,6 +1255,22 @@ export struct FbPeoplePage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
/* 🔴🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容,
|
||||||
|
例如**工作单位、职务**。」
|
||||||
|
⚠️ **独立成一行**(不是塞进上面标签那行):
|
||||||
|
单位名可以很长("XX大学XX学院XX系"),与标签/生日挤同一行时
|
||||||
|
会被省略号吃掉职务 —— 而"在哪、做什么"正是用户要看的信息。
|
||||||
|
独立一行后,单位名最多占满整行宽度(仍带省略号防溢出)。 */
|
||||||
|
if (this.jobLine(p) !== '') {
|
||||||
|
Text(this.jobLine(p))
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor(FbColors.muted)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user