From 4a063114c5a7b66afbcb7c6919291b8b732bbaf1 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 19 Oct 2026 15:27:31 +0800 Subject: [PATCH] =?UTF-8?q?v62=EF=BC=9A=E4=BF=AE=20P0=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=A2=AB=E8=B8=A2=20/=20=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8F=B3=E4=B8=8A=E8=A7=92=20/=20=E8=A7=82?= =?UTF-8?q?=E5=BD=B1=E4=B8=89=E5=A4=84=20/=20=E5=BD=B1=E9=99=A2=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=A1=A5=E9=BD=90=20/=20=E5=90=8D=E5=86=8C=E4=B8=89?= =?UTF-8?q?=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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`、四个页签、点头像不被踢) --- .../ets/components/FbCalSettingsSheet.ets | 35 +- .../src/main/ets/components/FbCinemaSheet.ets | 1265 +++++++++++++++-- .../src/main/ets/components/FbEventSheet.ets | 31 +- .../ets/components/FbModuleSettingsSheet.ets | 24 +- .../ets/components/FbMovieDetailSheet.ets | 84 +- .../ets/components/FbPersonQuickSheet.ets | 24 +- .../src/main/ets/components/FbPersonSheet.ets | 28 +- .../src/main/ets/components/FbSheetClose.ets | 161 +++ entry/src/main/ets/components/Widgets.ets | 42 +- entry/src/main/ets/data/FbHttp.ets | 40 +- entry/src/main/ets/data/FbMovieRepo.ets | 252 ++++ entry/src/main/ets/model/DemoData.ets | 24 +- entry/src/main/ets/model/Types.ets | 15 + .../main/ets/pages/account/FbAccountPage.ets | 15 +- .../main/ets/pages/movies/FbMoviesPage.ets | 25 + .../main/ets/pages/people/FbPeoplePage.ets | 55 +- 16 files changed, 1905 insertions(+), 215 deletions(-) create mode 100644 entry/src/main/ets/components/FbSheetClose.ets diff --git a/entry/src/main/ets/components/FbCalSettingsSheet.ets b/entry/src/main/ets/components/FbCalSettingsSheet.ets index de18dff..690e72c 100644 --- a/entry/src/main/ets/components/FbCalSettingsSheet.ets +++ b/entry/src/main/ets/components/FbCalSettingsSheet.ets @@ -29,6 +29,7 @@ import { FbCalBook } from '../model/Types'; import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings'; import { FbCalOptionRow } from './FbCalOptionRow'; import { FbSheetRing } from './FbSheetRing'; +import { FbSheetTitleBar } from './FbSheetClose'; import { FbDot } from './Widgets'; const FB_SHEET_BOTTOM_GAP: number = 16; @@ -133,32 +134,22 @@ export struct FbCalSettingsSheet { Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { - // ---- 标题栏 ---- + // ---- 标题栏(全应用统一版式:左标题 + 右「完成」,见 FbSheetClose.ets)---- Row({ space: 12 }) { - Column({ space: 2 }) { - Text('日程设置') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - // 🔴 把"当前是什么"直接写在标题下面 —— - // 用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾) - Text('当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style)) - .fontSize(FbMetric.fontTiny) - .fontColor('#4A7C82') - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - - Text('完成') - .fontSize(FbMetric.fontSmall) - .fontColor('#4A7C82') - .padding({ left: 12, right: 12, top: 6, bottom: 6 }) - .onClick((): void => { + FbSheetTitleBar({ + title: '日程设置', + /* 🔴 把"当前是什么"直接写在标题下面 —— + 用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾) */ + subtitle: '当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style), + accent: this.accentColor(), + // ⚠️ 设置类是"改完即生效" ⇒ 用「完成」而不是 ✕ + confirmText: '完成', + onClose: (): void => { this.onClose(); - }) + } + }) } .width('100%') - .alignItems(VerticalAlign.Center) Scroll() { Column({ space: 6 }) { diff --git a/entry/src/main/ets/components/FbCinemaSheet.ets b/entry/src/main/ets/components/FbCinemaSheet.ets index 49a6647..d16a086 100644 --- a/entry/src/main/ets/components/FbCinemaSheet.ets +++ b/entry/src/main/ets/components/FbCinemaSheet.ets @@ -1,21 +1,40 @@ /** - * 家书 FamilyBook · 影院列表浮层(鸿蒙端) + * 家书 FamilyBook · 影院管理浮层(鸿蒙端) * - * 观影模块的"影院管理"入口(Web 端在观影页的设置里)。 + * 🔴 v62(用户 2026-10-19)原话: + * 「设置里面的影院管理,这些功能都还没有,应该补充,就是增加影院呢、修改影院呢、 + * 高级搜索、统计、年度报告,这些全部都应该补充进去。」 * - * 🔴 为什么影院**不占抽屉里的位置**: - * 它不是独立模块,是"观影"的一部分 —— 一家影院只有"在哪、 - * 叫什么、在那看过几部"这三个属性,独立成一页太空。 - * ⇒ 做成观影页里的浮层(与详情、设置同一套交互)。 + * ⇒ 之前这个浮层**只是一个只读列表**(连"新增"都没有),而网页端 + * `templates/movies/` 下早就有 `cinemas.php` / `search.php` / `stats.php` / + * `report.php` 四页。这一版把端上补齐到与网页同一套能力: + * · 顶部一排「页签」:影院 / 统计 / 搜索 / 年报; + * · 影院页:**新增 / 修改 / 删除**(服务端 CRUD 早就有,端上没接); + * · 统计页:总览 + 按影院计数(`/api/v1/movies/stats`); + * · 搜索页:片名 / 影院 / 人 / 起止日期(`/api/v1/movies/search`); + * · 年报页:按年切换(`/api/v1/movies/report`)。 * - * ⚠️ 数据来自**本地库**(观影同步时一起落库的 `cinemas` 表), - * 不是现拉接口 —— 见 `FbMovieRepo.cinemaList()` 的说明。 + * ⚠️ 四个页签**共用一个浮层**(而不是四个独立浮层): + * 它们是"同一个东西的不同视角",来回切换不该经过"关掉再打开"(那会闪一下)。 + * + * 🔴 数据来源分两路(有意的): + * · **影院列表**读**本地库**(`cinemas` 表)—— 与观影页其它内容一致,断网也看得见; + * · **统计 / 年报 / 搜索**必须**联网**——它们的口径要与网页**完全一致** + * (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分", + * 少同步一次就少一部,用户会发现"网页 124 部、App 121 部"。 + * ⇒ 这三页断网时显示"需要联网",而不是给一个偏低的数字。 */ -import { FbColors, FbMetric } from '../common/Theme'; +import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; -import { FbCinemaItem } from '../data/FbMovieRepo'; +import { + FbApiMovieSearchRow, FbCinemaItem, FbMovieCinemaStat, FbMovieMonthStat, + FbMovieOverview, FbMovieReport, FbMovieRepo, FbMovieSearchParams, FbMovieStats, + FbMovieYearStat +} from '../data/FbMovieRepo'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; /** * 贴底卡片的手势区缓冲(vp)。 @@ -26,14 +45,36 @@ import { FbSheetRing } from './FbSheetRing'; */ const FB_SHEET_BOTTOM_GAP: number = 16; +/** 四个页签 */ +const TABS: string[] = ['影院', '统计', '搜索', '年报']; + +/** 新增影院时的默认色(与网页 `Cinema::PALETTE` 里的紫同一档,便于区分新建的) */ +const DEFAULT_CINEMA_COLOR: string = '#7A4E8C'; + +/** + * 空的报告统计对象。 + * + * ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals` **不允许空对象字面量** + * (`return {}` 直接编译不过)⇒ 用一个显式声明的空 interface 顶掉。 + */ +interface FbEmptyStats { +} + +const EMPTY_STATS: FbEmptyStats = {}; + +/** 同理:总览的空值(`return {}` 编译不过) */ +const EMPTY_OVERVIEW: FbMovieOverview = {}; @ComponentV2 export struct FbCinemaSheet { - /** 影院列表 */ + /** 影院列表(本地库;外部传入,改动后由本组件刷新并回调) */ @Param cinemas: FbCinemaItem[] = []; /** 关闭回调 */ @Event onClose: () => void = (): void => { }; + /** 影院列表有变化时通知页面刷新(新建/修改/删除之后) */ + @Event onCinemasChanged: () => void = (): void => { + }; /** * 卡片外圈描边环的颜色(hex,'' = 用观影模块色)。 @@ -42,6 +83,45 @@ export struct FbCinemaSheet { */ @Param accent: string = ''; + /** 当前页签(0=影院 1=统计 2=搜索 3=年报) */ + @Local tab: number = 0; + + // ---- 编辑态(影院页)---- + /** 正在编辑的影院 id(0 = 新增;-1 = 没在编辑) */ + @Local editId: number = -1; + @Local fName: string = ''; + @Local fPlace: string = ''; + @Local fAddress: string = ''; + @Local fColor: string = DEFAULT_CINEMA_COLOR; + /** 保存中(防连点) */ + @Local saving: boolean = false; + /** 一句话结果(成功/失败都写这里,页面上显示一行灰字) */ + @Local note: string = ''; + /** 待确认删除的影院 id(0 = 没有) */ + @Local delId: number = 0; + + // ---- 统计 / 年报 / 搜索的加载态 ---- + @Local statsLoading: boolean = false; + @Local stats: FbMovieStats | null = null; + @Local statsError: string = ''; + + @Local reportLoading: boolean = false; + @Local report: FbMovieReport | null = null; + @Local reportError: string = ''; + /** 年报年份(0 = 用服务端返回的默认年) */ + @Local reportYear: number = 0; + + @Local searchLoading: boolean = false; + @Local searchRows: FbApiMovieSearchRow[] = []; + @Local searchError: string = ''; + /** 搜索条件 */ + @Local sTitle: string = ''; + @Local sCinemaId: number = 0; + @Local sFrom: string = ''; + @Local sTo: string = ''; + + private repo: FbMovieRepo = FbMovieRepo.inst(); + /** 环色:显式给了就用给的,否则取观影模块色(影院属于观影) */ private accentColor(): string { if (this.accent !== '') { @@ -51,6 +131,134 @@ export struct FbCinemaSheet { return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; } + /** 切页签时按需拉数据(进统计/年报/搜索才打接口,不在打开浮层就打三个) */ + private switchTab(i: number): void { + this.tab = i; + this.note = ''; + if (i === 1 && this.stats === null) { + this.loadStats(); + } + if (i === 3 && this.report === null) { + this.loadReport(0); + } + } + + private async loadStats(): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.statsError = '还没登录'; + return; + } + this.statsLoading = true; + this.statsError = ''; + const r: FbMovieStats | null = await this.repo.stats(token); + this.statsLoading = false; + if (r === null) { + this.statsError = '统计需要联网(本地库只有已同步的一部分,算不准)'; + return; + } + this.stats = r; + } + + private async loadReport(year: number): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.reportError = '还没登录'; + return; + } + this.reportLoading = true; + this.reportError = ''; + const r: FbMovieReport | null = await this.repo.report(token, year); + this.reportLoading = false; + if (r === null) { + this.reportError = '年度报告需要联网'; + return; + } + this.report = r; + const y: number | undefined = r.year; + this.reportYear = y !== undefined ? y : year; + } + + private async doSearch(): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.searchError = '还没登录'; + return; + } + const p: FbMovieSearchParams = { + title: this.sTitle.trim(), + cinemaId: this.sCinemaId, + personId: 0, + dateFrom: this.sFrom.trim(), + dateTo: this.sTo.trim() + }; + // ⚠️ 与网页同规则:条件全空就不查(否则等于"列出所有观影",不是搜索) + if (p.title === '' && p.cinemaId <= 0 && p.dateFrom === '' && p.dateTo === '') { + this.searchError = '先设一个条件再搜(片名 / 影院 / 日期)'; + this.searchRows = []; + return; + } + this.searchLoading = true; + this.searchError = ''; + this.searchRows = await this.repo.search(token, p); + this.searchLoading = false; + if (this.searchRows.length === 0) { + this.searchError = '没有符合条件的观影记录'; + } + } + + /** 开始编辑(id=0 表示新增) */ + private startEdit(id: number): void { + if (id === 0) { + this.editId = 0; + this.fName = ''; + this.fPlace = ''; + this.fAddress = ''; + this.fColor = DEFAULT_CINEMA_COLOR; + } else { + const c: FbCinemaItem | undefined = this.cinemas.find((x: FbCinemaItem): boolean => x.id === id); + if (c === undefined) { + return; + } + this.editId = id; + this.fName = c.name; + this.fPlace = c.place; + this.fAddress = c.address; + this.fColor = c.color; + } + this.note = ''; + } + + private async save(): Promise { + if (this.saving) { + return; + } + if (this.fName.trim() === '') { + this.note = '影院名称不能为空'; + return; + } + this.saving = true; + const err: string = await this.repo.saveCinema( + this.editId, this.fPlace, this.fName, this.fAddress, this.fColor); + this.saving = false; + if (err !== '') { + this.note = err; + return; + } + this.note = this.editId === 0 ? '已新增' : '已保存'; + this.editId = -1; + this.onCinemasChanged(); + } + + private async remove(id: number): Promise { + this.delId = 0; + const err: string = await this.repo.deleteCinema(id); + this.note = err !== '' ? err : '已删除(历史观影记录保留)'; + if (err === '') { + this.onCinemasChanged(); + } + } + build() { // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— @@ -62,111 +270,77 @@ export struct FbCinemaSheet { Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { - // ---- 标题 ---- - Row() { + // ---- 标题栏(统一版式:左标题 + 右 ✕,见 FbSheetClose.ets)---- + Row({ space: 8 }) { Column({ space: 2 }) { - Text('影院') + Text(this.titleText()) .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) - Text(this.cinemas.length.toString() + ' 家 · 按名字排序') + .maxLines(1) + Text(this.subtitleText()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) + .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) + .flexShrink(1) - Text('关闭') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .padding({ left: 12, right: 12, top: 6, bottom: 6 }) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .onClick((): void => { + FbSheetClose({ + onClose: (): void => { this.onClose(); - }) + }, + color: this.accentColor() + }) } .width('100%') .alignItems(VerticalAlign.Center) + // ---- 页签 ---- + this.tabBar() + Divider() .color(FbColors.line) - .margin({ top: 12, bottom: 4 }) + .margin({ top: 4, bottom: 4 }) - // ---- 列表 ---- - // ⚠️ 用 List 而不是 ForEach 堆在 Column 里:影院可能几十家, - // List 才会**按需渲染**(进视口才建),Column 会一次全建。 - if (this.cinemas.length === 0) { - Text('还没有影院。同步观影记录后,去过的地方会自动出现在这里。') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) - .width('100%') - .padding({ top: 32, bottom: 32, left: 12, right: 12 }) - .textAlign(TextAlign.Center) - } else { - List({ space: 0 }) { - ForEach(this.cinemas, (c: FbCinemaItem, idx: number) => { - ListItem() { - Column({ space: 0 }) { - Row({ space: 12 }) { - // 色点:每家影院一个色(与 Web 端一致,便于在别处一眼认出来) - Column() - .width(10) - .height(10) - .backgroundColor(c.color) - .borderRadius(5) - Column({ space: 3 }) { - Row({ space: 8 }) { - Text(c.name) - .fontSize(FbMetric.fontBody) - .fontColor(FbColors.ink) - .maxLines(1) - if (c.movieCount > 0) { - Text(c.movieCount.toString() + ' 部') - .fontSize(10) - .fontColor(FbColors.faint) - } - } - .alignItems(VerticalAlign.Center) - - // 场所 / 地址(有才显示) - if (c.place !== '' || c.address !== '') { - Text(c.place !== '' ? c.place : c.address) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - } - .width('100%') - .padding({ left: 4, right: 4, top: 11, bottom: 11 }) - .alignItems(VerticalAlign.Center) - - if (idx < this.cinemas.length - 1) { - Divider() - .strokeWidth(0.5) - .color(FbColors.line) - } - } - .width('100%') - } - }, (c: FbCinemaItem): string => 'cin_' + c.id.toString()) + // ---- 内容区 ---- + // ⚠️ `flexShrink(1)` 让内容**按自身高度收缩**(短内容不撑满 maxHeight)—— + // 本项目在日程详情/模块设置踩过"卡片空半屏"(`layoutWeight(1)` 会吃满)。 + Scroll() { + Column({ space: 0 }) { + if (this.tab === 0) { + this.cinemaPane() + } else if (this.tab === 1) { + this.statsPane() + } else if (this.tab === 2) { + this.searchPane() + } else { + this.reportPane() + } } .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Off) - .edgeEffect(EdgeEffect.Spring) + } + .flexShrink(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + + // ---- 底部:影院页的一行结果提示 ---- + if (this.note !== '') { + Text(this.note) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .width('100%') + .padding({ top: 8 }) } } - // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… - // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, - // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 - // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… + // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, + // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 + // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 .width('100%') - .constraintSize({ maxHeight: '72%' }) + .constraintSize({ maxHeight: '76%' }) .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) .alignItems(HorizontalAlign.Start) } @@ -178,4 +352,907 @@ export struct FbCinemaSheet { .justifyContent(FlexAlign.Center) .hitTestBehavior(HitTestMode.Transparent) } -} \ No newline at end of file + + /** 标题(随页签变,用户一眼知道自己在哪一页) */ + private titleText(): string { + if (this.tab === 1) { + return '观影统计' + } + if (this.tab === 2) { + return '高级搜索' + } + if (this.tab === 3) { + return '年度报告' + } + return this.editId >= 0 ? (this.editId === 0 ? '新增影院' : '修改影院') : '影院管理'; + } + + private subtitleText(): string { + if (this.tab === 1) { + return '与网页端口径一致(需要联网)'; + } + if (this.tab === 2) { + return '片名 / 影院 / 日期组合查'; + } + if (this.tab === 3) { + return '以我为中心,只算我参与过的场次'; + } + return this.cinemas.length.toString() + ' 家 · 按名字排序'; + } + + /** 四个页签(选中用主题色实底 + 白字,与顶栏分段同一套观感) */ + @Builder + tabBar() { + Row({ space: 4 }) { + ForEach(TABS, (t: string, i: number) => { + Text(t) + .fontSize(FbMetric.fontSmall) + .fontColor(this.tab === i ? Color.White : FbColors.muted) + .fontWeight(this.tab === i ? FontWeight.Medium : FontWeight.Normal) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(32) + .backgroundColor(this.tab === i ? this.accentColor() : Color.Transparent) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.switchTab(i); + }) + }, (t: string): string => 'cintab_' + t) + } + .width('100%') + .padding(3) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .margin({ top: 10 }) + } + + // ==================== ① 影院:增 / 改 / 删 ==================== + + @Builder + cinemaPane() { + // ---- 编辑表单(编辑态才显示)---- + if (this.editId >= 0) { + Column({ space: 8 }) { + this.field('影院名', '如「横店电影城」', this.fName, (v: string): void => { + this.fName = v; + }) + this.field('场所', '如「东明县京都大润发」(可空)', this.fPlace, (v: string): void => { + this.fPlace = v; + }) + this.field('地址', '详细地址(可空)', this.fAddress, (v: string): void => { + this.fAddress = v; + }) + // 颜色:给一排候选(网页 `Cinema::PALETTE` 是同样的做法) + Row({ space: 8 }) { + Text('颜色') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(52) + Row({ space: 6 }) { + ForEach(PALETTE, (c: string) => { + Column() + .width(20) + .height(20) + .backgroundColor(c) + .borderRadius(10) + // ⚠️ 选中的加一圈描边(否则分不出选了哪个) + .border({ width: this.fColor === c ? 2 : 0, color: FbColors.ink }) + .onClick((): void => { + this.fColor = c; + }) + }, (c: string): string => 'pal_' + c) + } + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Row({ space: 8 }) { + Text(this.saving ? '保存中…' : '保存') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + .backgroundColor(this.saving ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.save(); + }) + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.editId = -1; + this.note = ''; + }) + } + .width('100%') + .margin({ top: 2 }) + } + .width('100%') + .padding({ top: 13, bottom: 13 }) + } else { + // ---- 顶部动作:新增 ---- + Row() { + Text('+ 新增影院') + .fontSize(FbMetric.fontSmall) + .fontColor(this.accentColor()) + .fontWeight(FontWeight.Medium) + .height(34) + .padding({ left: 12, right: 12 }) + .borderRadius(FbMetric.radiusS) + .backgroundColor(fbAlpha(this.accentColor(), 0.10)) + .textAlign(TextAlign.Center) + .onClick((): void => { + this.startEdit(0); + }) + } + .width('100%') + .padding({ top: 10, bottom: 4 }) + } + + // ---- 列表 ---- + if (this.cinemas.length === 0) { + Text('还没有影院。可以点上面的「新增影院」自己加,' + + '同步观影记录后去过的地方也会自动出现在这里。') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 28, bottom: 28, left: 12, right: 12 }) + .textAlign(TextAlign.Center) + } else { + Column({ space: 0 }) { + ForEach(this.cinemas, (c: FbCinemaItem) => { + Column({ space: 0 }) { + Row({ space: 12 }) { + // 色点:每家影院一个色(与 Web 端一致,便于在别处一眼认出来) + Column() + .width(10) + .height(10) + .backgroundColor(c.color) + .borderRadius(5) + Column({ space: 3 }) { + Row({ space: 8 }) { + Text(c.name) + .fontSize(FbMetric.fontBody) + .fontColor(FbColors.ink) + .maxLines(1) + .flexShrink(1) + if (c.movieCount > 0) { + Text(c.movieCount.toString() + ' 部') + .fontSize(10) + .fontColor(FbColors.faint) + } + } + .alignItems(VerticalAlign.Center) + .width('100%') + + // 场所 / 地址(有才显示) + if (c.place !== '' || c.address !== '') { + Text(c.place !== '' ? c.place : c.address) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + // ---- 改 / 删 ---- + Text('改') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + .padding({ left: 8, right: 8, top: 5, bottom: 5 }) + .onClick((): void => { + this.startEdit(c.id); + }) + Text('删') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.red) + .padding({ left: 8, right: 8, top: 5, bottom: 5 }) + .onClick((): void => { + this.delId = c.id; + }) + } + .width('100%') + .padding({ left: 4, right: 0, top: 11, bottom: 11 }) + .alignItems(VerticalAlign.Center) + + Divider() + .strokeWidth(0.5) + .color(FbColors.line) + } + .width('100%') + }, (c: FbCinemaItem): string => 'cin_' + c.id.toString()) + } + .width('100%') + } + + // ---- 删除确认(红底确认框,与其它删除同一规格 —— 用户要求"删除按钮全部红色背景")---- + if (this.delId > 0) { + this.confirmDelete() + } + } + + /** 一行输入(标签 + 输入框) */ + @Builder + field(label: string, placeholder: string, value: string, onChange: (v: string) => void) { + Row({ space: 8 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(52) + TextInput({ text: value, placeholder: placeholder }) + .placeholderColor(FbColors.faint) + .placeholderFont({ size: FbMetric.fontTiny }) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(38) + .layoutWeight(1) + .padding({ left: 10, right: 10 }) + .onChange((v: string): void => { + onChange(v); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + /** 删除一家影院的二次确认 */ + @Builder + confirmDelete() { + Column({ space: 10 }) { + Text('删掉这家影院?') + .fontSize(FbMetric.fontBody) + .fontColor(FbColors.ink) + .fontWeight(FontWeight.Medium) + Text('在这家看过的记录**会保留**(只是不再显示影院名)。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + Row({ space: 10 }) { + Text('删除') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + // 🔴 用户 2026-10-19:「删除按钮全部用红色背景。」 + .backgroundColor(FbColors.red) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.remove(this.delId); + }) + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.delId = 0; + }) + } + .width('100%') + } + .width('100%') + .padding({ top: 16, bottom: 6 }) + .alignItems(HorizontalAlign.Center) + } + + // ==================== ② 统计 ==================== + + @Builder + statsPane() { + if (this.statsLoading) { + this.loadingRow('正在取统计…') + } else if (this.statsError !== '') { + this.errorRow(this.statsError, (): void => { + this.loadStats(); + }) + } else if (this.stats !== null) { + // ---- 总览 ---- + this.sectionTitle('总览') + Row({ space: 0 }) { + this.statCell('看过', this.ov().movies) + this.statCell('影院', this.ov().cinemas) + this.statCell('参与的人', this.ov().persons) + this.statCell('人次', this.ov().viewership) + } + .width('100%') + .padding({ top: 6, bottom: 10 }) + + if (this.spanText() !== '') { + Text(this.spanText()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + + // ---- 按影院 ---- + if (this.cinemaStats().length > 0) { + this.sectionTitle('按影院') + Column({ space: 6 }) { + ForEach(this.cinemaStats(), (c: FbMovieCinemaStat) => { + this.barRow( + c.name !== undefined && c.name !== '' ? c.name : '(未命名)', + c.cnt !== undefined ? c.cnt : 0, + this.maxCinemaCount(), + c.color !== undefined && c.color !== '' ? c.color : this.accentColor()) + }, (c: FbMovieCinemaStat): string => 'cs_' + (c.id !== undefined ? c.id : 0).toString()) + } + .width('100%') + .padding({ top: 4 }) + } + + // ---- 按年 ---- + if (this.yearStats().length > 0) { + this.sectionTitle('按年') + Column({ space: 6 }) { + ForEach(this.yearStats(), (y: FbMovieYearStat) => { + this.barRow( + (y.year !== undefined ? y.year.toString() : '') + ' 年', + y.cnt !== undefined ? y.cnt : 0, + this.maxYearCount(), + this.accentColor()) + }, (y: FbMovieYearStat): string => 'ys_' + (y.year !== undefined ? y.year : 0).toString()) + } + .width('100%') + .padding({ top: 4 }) + } + + // ---- 按月(近 12 个月)---- + if (this.monthStats().length > 0) { + this.sectionTitle('近 12 个月') + Column({ space: 6 }) { + ForEach(this.monthStats(), (m: FbMovieMonthStat) => { + this.barRow( + m.label !== undefined && m.label !== '' + ? m.label : (m.ym !== undefined ? m.ym : ''), + m.cnt !== undefined ? m.cnt : 0, + this.maxMonthCount(), + this.accentColor()) + }, (m: FbMovieMonthStat): string => + 'ms_' + (m.ym !== undefined ? m.ym : (m.label !== undefined ? m.label : ''))) + } + .width('100%') + .padding({ top: 4 }) + } + } + } + + /** 总览的安全取值 */ + private ov(): FbMovieOverview { + if (this.stats === null || this.stats.overview === undefined) { + return EMPTY_OVERVIEW; + } + return this.stats.overview; + } + + private spanText(): string { + const o: FbMovieOverview = this.ov(); + const f: string | null | undefined = o.first_date; + const l: string | null | undefined = o.last_date; + if (f === undefined || f === null || f === '') { + return ''; + } + return '从 ' + f + ' 到 ' + (l !== undefined && l !== null && l !== '' ? l : '今'); + } + + private cinemaStats(): FbMovieCinemaStat[] { + return this.stats !== null && this.stats.by_cinema !== undefined ? this.stats.by_cinema : []; + } + + private yearStats(): FbMovieYearStat[] { + return this.stats !== null && this.stats.by_year !== undefined ? this.stats.by_year : []; + } + + private monthStats(): FbMovieMonthStat[] { + return this.stats !== null && this.stats.by_month !== undefined ? this.stats.by_month : []; + } + + private maxCinemaCount(): number { + let m: number = 1; + for (const c of this.cinemaStats()) { + const n: number = c.cnt !== undefined ? c.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + private maxYearCount(): number { + let m: number = 1; + for (const y of this.yearStats()) { + const n: number = y.cnt !== undefined ? y.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + private maxMonthCount(): number { + let m: number = 1; + for (const y of this.monthStats()) { + const n: number = y.cnt !== undefined ? y.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + /** 总览的一格(大数字 + 小标签) */ + @Builder + statCell(label: string, value: number | undefined) { + Column({ space: 2 }) { + Text(value !== undefined ? value.toString() : '0') + .fontSize(22) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + /** + * 一条横向条形统计(名字 + 条 + 数字)。 + * + * ⚠️ 条的宽度用 `layoutWeight` 按**比例**分配,而不是直接给百分比字符串: + * ArkTS 里 `width('62%')` 要拼字符串,且宽度为 0 的条会塌成一条线。 + * 这里用"按 max 归一后的整数份数"再 `layoutWeight`,0 的时候 + * 仍保留一个极小的占位(否则那一行看起来缺了一块)。 + */ + @Builder + barRow(label: string, value: number, max: number, color: string) { + Row({ space: 8 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width(72) + Row() { + Row() + .width(Math.max(4, Math.round((value / max) * 100))) + .height(8) + .backgroundColor(color) + .borderRadius(4) + Blank() + } + .layoutWeight(1) + .height(8) + .alignItems(VerticalAlign.Center) + + Text(value.toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .width(30) + .textAlign(TextAlign.End) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + // ==================== ③ 高级搜索 ==================== + + @Builder + searchPane() { + Column({ space: 8 }) { + this.field('片名', '含这几个字就算命中', this.sTitle, (v: string): void => { + this.sTitle = v; + }) + // 影院:从本地影院列表里选(不额外打接口) + Row({ space: 8 }) { + Text('影院') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(52) + Scroll() { + Row({ space: 6 }) { + Text('不限') + .fontSize(FbMetric.fontTiny) + .fontColor(this.sCinemaId === 0 ? Color.White : FbColors.muted) + .padding({ left: 10, right: 10, top: 5, bottom: 5 }) + .backgroundColor(this.sCinemaId === 0 ? this.accentColor() : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.sCinemaId = 0; + }) + ForEach(this.cinemas, (c: FbCinemaItem) => { + Text(c.name) + .fontSize(FbMetric.fontTiny) + .fontColor(this.sCinemaId === c.id ? Color.White : FbColors.muted) + .maxLines(1) + .padding({ left: 10, right: 10, top: 5, bottom: 5 }) + .backgroundColor(this.sCinemaId === c.id ? this.accentColor() : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.sCinemaId = c.id; + }) + }, (c: FbCinemaItem): string => 'sch_' + c.id.toString()) + } + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + this.field('从', 'YYYY-MM-DD(可空)', this.sFrom, (v: string): void => { + this.sFrom = v; + }) + this.field('到', 'YYYY-MM-DD(可空)', this.sTo, (v: string): void => { + this.sTo = v; + }) + + Row({ space: 8 }) { + Text(this.searchLoading ? '搜索中…' : '搜索') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + .backgroundColor(this.searchLoading + ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.doSearch(); + }) + Text('清空') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.sTitle = ''; + this.sCinemaId = 0; + this.sFrom = ''; + this.sTo = ''; + this.searchRows = []; + this.searchError = ''; + }) + } + .width('100%') + .margin({ top: 2 }) + } + .width('100%') + .padding({ top: 12 }) + .alignItems(HorizontalAlign.Start) + + // ---- 结果 ---- + if (this.searchError !== '') { + Text(this.searchError) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 12, bottom: 8 }) + } + if (this.searchRows.length > 0) { + this.sectionTitle('命中 ' + this.searchRows.length.toString() + ' 条') + Column({ space: 0 }) { + ForEach(this.searchRows, (r: FbApiMovieSearchRow) => { + Column({ space: 0 }) { + Row({ space: 10 }) { + Column() + .width(4) + .height(34) + .backgroundColor(r.cinema_color !== null && r.cinema_color !== undefined + && r.cinema_color !== '' ? r.cinema_color : this.accentColor()) + .borderRadius(2) + Column({ space: 3 }) { + Text(r.title !== undefined && r.title !== '' ? r.title : '未命名影片') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Row({ space: 6 }) { + Text(this.dateOf(r.starts_at)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + if (r.cinema_name !== null && r.cinema_name !== undefined && r.cinema_name !== '') { + Text(r.cinema_name) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + .layoutWeight(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .padding({ top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + + Divider().strokeWidth(0.5).color(FbColors.line) + } + .width('100%') + }, (r: FbApiMovieSearchRow): string => 'srch_' + (r.id !== undefined ? r.id : 0).toString()) + } + .width('100%') + } + } + + /** `2026-10-19 19:30:00` → `2026-10-19` */ + private dateOf(v: string | undefined | null): string { + if (v === undefined || v === null || v.length < 10) { + return ''; + } + return v.substring(0, 10); + } + + // ==================== ④ 年度报告 ==================== + + @Builder + reportPane() { + if (this.reportLoading) { + this.loadingRow('正在生成年度报告…') + } else if (this.reportError !== '') { + this.errorRow(this.reportError, (): void => { + this.loadReport(this.reportYear); + }) + } else if (this.report !== null) { + // ---- 年份切换 ---- + if (this.reportYears().length > 1) { + Scroll() { + Row({ space: 6 }) { + ForEach(this.reportYears(), (y: number) => { + Text(y.toString() + ' 年') + .fontSize(FbMetric.fontTiny) + .fontColor(this.reportYear === y ? Color.White : FbColors.muted) + .padding({ left: 12, right: 12, top: 5, bottom: 5 }) + .backgroundColor(this.reportYear === y ? this.accentColor() : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.loadReport(y); + }) + }, (y: number): string => 'ry_' + y.toString()) + } + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .width('100%') + .padding({ top: 10, bottom: 2 }) + } + + // ---- 大数 ---- + Row({ space: 0 }) { + this.statCell('看了', this.numField('total')) + this.statCell('观影天数', this.numField('days')) + this.statCell('平均每月', this.numField('avg_per_month')) + } + .width('100%') + .padding({ top: 12, bottom: 10 }) + + // ---- 几条"最" ---- + this.reportLine('最常去的影院', this.strField('top_cinema'), + this.numField('top_cinema_count'), '次') + this.reportLine('一起看最多的人', this.strField('top_person'), + this.numField('top_person_count'), '场') + this.reportLine('最多人一起看的', this.strField('most_viewers_movie'), + this.numField('most_viewers_count'), '人') + + // ---- 首末场 ---- + /* ⚠️ `@Builder` 里**不能写 `const` 声明**(编译器报 "Only UI component + syntax can be written here")—— 只能直接调方法。 + ⇒ 判空/拼接都收进 `reportSpan()` 里,返回空串就不渲染这行。 */ + if (this.reportSpan() !== '') { + this.sectionTitle('这一年') + Text(this.reportSpan()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .padding({ top: 2 }) + } + + // ---- 月份分布 ---- + if (this.monthDist().length > 0) { + this.sectionTitle('每月分布') + Column({ space: 6 }) { + ForEach(this.monthDist(), (m: FbMovieMonthStat) => { + this.barRow( + m.label !== undefined ? m.label : '', + m.cnt !== undefined ? m.cnt : 0, + this.maxMonthDist(), + this.accentColor()) + }, (m: FbMovieMonthStat): string => 'rd_' + (m.label !== undefined ? m.label : '')) + } + .width('100%') + .padding({ top: 4 }) + } + + if (this.numField('total') === 0) { + Text('这一年(' + this.reportYear.toString() + ')没有你参与的观影记录。') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 24, bottom: 16 }) + .textAlign(TextAlign.Center) + } + } + } + + private reportYears(): number[] { + if (this.report === null || this.report.years === undefined) { + return []; + } + return this.report.years; + } + + private rp(): Object { + if (this.report === null || this.report.stats === undefined) { + // ⚠️ ArkTS 不允许空对象字面量 ⇒ 用一个显式声明的空 interface + return EMPTY_STATS; + } + return this.report.stats as Object; + } + + /** 年报告里的「第一场 … 最后一场 …」;没有首场时返回空串(不渲染) */ + private reportSpan(): string { + const f: string = this.strField('first_date'); + if (f === '') { + return ''; + } + const l: string = this.strField('last_date'); + return '第一场 ' + f + ' · 最后一场 ' + (l !== '' ? l : '—'); + } + + /** 取一个数值字段(JSON 解析出来的是弱类型,统一在这里兜底) */ + private numField(key: string): number { + const o: Record = this.rp() as Record; + const v: Object | undefined = o[key]; + return typeof v === 'number' ? v : 0; + } + + private strField(key: string): string { + const o: Record = this.rp() as Record; + const v: Object | undefined = o[key]; + return typeof v === 'string' ? v : ''; + } + + /** 月份分布 → 条形数据(1..12 月) */ + private monthDist(): FbMovieMonthStat[] { + const o: Record = this.rp() as Record; + const md: Object | undefined = o['month_distribution']; + const out: FbMovieMonthStat[] = []; + if (md === undefined || md === null) { + return out; + } + const m: Record = md as Record; + for (let i = 1; i <= 12; i++) { + const v: Object | undefined = m[i.toString()]; + const it: FbMovieMonthStat = {}; + it.label = i.toString() + ' 月'; + it.cnt = typeof v === 'number' ? v : 0; + out.push(it); + } + return out; + } + + private maxMonthDist(): number { + let m: number = 1; + for (const it of this.monthDist()) { + const n: number = it.cnt !== undefined ? it.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + /** + * 报告里的一条「最 XX」。 + * + * ⚠️ `total` 字段在端上是**弱类型**(服务端可能给 int), + * 所以统一走 `numField/strField` 兜底,不直接下标取值。 + */ + @Builder + reportLine(label: string, value: string, count: number, unit: string) { + if (value !== '' && value !== '无') { + Row({ space: 8 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(96) + Text(value) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + if (count > 0) { + Text(count.toString() + ' ' + unit) + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + } + .width('100%') + .padding({ top: 6, bottom: 6 }) + .alignItems(VerticalAlign.Center) + } + } + + // ==================== 小组件 ==================== + + @Builder + sectionTitle(t: string) { + Text(t) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 14, bottom: 2 }) + } + + @Builder + loadingRow(t: string) { + Row({ space: 8 }) { + LoadingProgress().width(18).height(18).color(FbColors.faint) + Text(t) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + } + .width('100%') + .justifyContent(FlexAlign.Center) + .padding({ top: 40, bottom: 40 }) + } + + /** 失败 + 一个「重试」(用户最烦"点了没反应",给一条出路) */ + @Builder + errorRow(msg: string, retry: () => void) { + Column({ space: 10 }) { + Text(msg) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .textAlign(TextAlign.Center) + Text('重试') + .fontSize(FbMetric.fontSmall) + .fontColor(this.accentColor()) + .height(34) + .padding({ left: 16, right: 16 }) + .borderRadius(FbMetric.radiusS) + .backgroundColor(fbAlpha(this.accentColor(), 0.10)) + .textAlign(TextAlign.Center) + .onClick((): void => { + retry(); + }) + } + .width('100%') + .padding({ top: 32, bottom: 32 }) + .alignItems(HorizontalAlign.Center) + } +} + +/** + * 新建影院时可选的颜色(与网页 `Cinema::PALETTE` 同一族,取前若干个常用的)。 + * + * ⚠️ 不是**全部**调色板(那有 40 个,竖排一片看不出差别)—— + * 这里挑的是"色相差得开"的那几个,用户一眼能分。 + */ +const PALETTE: string[] = [ + '#7A4E8C', '#e84393', '#e91e63', '#f06292', + '#2980b9', '#6c5ce7', '#16a085', '#2ecc71', + '#f8c471', '#a67c52', '#95a5a6', '#7f8c8d' +]; \ No newline at end of file diff --git a/entry/src/main/ets/components/FbEventSheet.ets b/entry/src/main/ets/components/FbEventSheet.ets index a422bbf..e5b82eb 100644 --- a/entry/src/main/ets/components/FbEventSheet.ets +++ b/entry/src/main/ets/components/FbEventSheet.ets @@ -20,6 +20,7 @@ import { FbCalEventFull } from '../data/FbCalRepo'; import { FbCalBook } from '../model/Types'; import { FbDot } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; const FB_SHEET_BOTTOM_GAP: number = 16; @@ -227,6 +228,15 @@ export struct FbEventSheet { .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusPill) } + /* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。 + ⚠️ 放在标题行的最右端,而不是下面独立一行 —— 独立一行会让卡片 + 顶部多出一段空白,标题反而往下掉。 */ + FbSheetClose({ + onClose: (): void => { + this.onClose(); + }, + color: '' + }) } .width('100%') .alignItems(VerticalAlign.Top) @@ -301,20 +311,9 @@ export struct FbEventSheet { .align(Alignment.TopStart) } - // ---- 底部按钮 ---- - Row({ space: 10 }) { - Text('关闭') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .textAlign(TextAlign.Center) - .layoutWeight(1) - .height(42) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .onClick((): void => { - this.onClose(); - }) - if (this.canEdit && this.event !== null) { + // ---- 底部按钮(只有动作,关闭统一到右上角 ✕)---- + if (this.canEdit && this.event !== null) { + Row({ space: 10 }) { /* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」 ⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。 改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */ @@ -330,9 +329,9 @@ export struct FbEventSheet { this.onEdit(); }) } + .width('100%') + .margin({ top: 14 }) } - .width('100%') - .margin({ top: 14 }) } // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, diff --git a/entry/src/main/ets/components/FbModuleSettingsSheet.ets b/entry/src/main/ets/components/FbModuleSettingsSheet.ets index dd1f6f5..a30a4a0 100644 --- a/entry/src/main/ets/components/FbModuleSettingsSheet.ets +++ b/entry/src/main/ets/components/FbModuleSettingsSheet.ets @@ -20,6 +20,7 @@ import { FbColors, FbMetric, FbIcon, fbTint, FbTint } from '../common/Theme'; import { FbSheetRing } from './FbSheetRing'; +import { FbSheetTitleBar } from './FbSheetClose'; /** 贴底卡片的手势区缓冲(vp)—— 同其它浮层 * ⚠️ 2026-10-19 起本浮层改成**居中弹框**,这个常量暂时没有引用; @@ -80,23 +81,20 @@ export struct FbModuleSettingsSheet { Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { - // ---- 标题栏 ---- + // ---- 标题栏(全应用统一版式:左标题 + 右「完成」,见 FbSheetClose.ets)---- Row() { - Text(this.title) - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - .layoutWeight(1) - Text('完成') - .fontSize(FbMetric.fontSmall) - .fontColor('#4A7C82') - .padding({ left: 12, right: 12, top: 6, bottom: 6 }) - .onClick((): void => { + FbSheetTitleBar({ + title: this.title, + accent: this.accentColor(), + // ⚠️ 设置类是"改完即生效" ⇒ 用「完成」而不是 ✕(见 FbSheetTitleBar 的说明) + confirmText: '完成', + onClose: (): void => { this.onClose(); - }) + } + }) } .width('100%') - .alignItems(VerticalAlign.Center) + .padding({ left: 18, right: 12, top: 14, bottom: 6 }) Scroll() { Column({ space: 0 }) { diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index a1c9ed0..fd37ce6 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -21,6 +21,7 @@ import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbAvatar, FbRating } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; import { fbImageSrc } from '../data/FbAttachQueue'; /** @@ -184,27 +185,31 @@ export struct FbMovieDetailSheet { } /** 浮层顶部:把手 + 关闭 */ + /** 浮层顶部:**只有右上角的 ✕**(关闭位置全应用统一)。 */ @Builder sheetHeader() { + /* 🔴🔴 用户 2026-10-19:「点击一个影片之后,弹出的框**左上角有一条灰色的线**, + 不知道是什么。」 + ⇒ 那是原来放在标题行**最左端**的"把手"(36×4 的浅灰短线, + `FbColors.line2`)。我的本意是"抽屉可以下拉关掉"的视觉提示, + 但 ① 它落在左上角、旁边没有任何文字,看起来就是**一条莫名其妙的灰线**; + ② 这个浮层的关闭本来就不靠下拉(有按钮)。 + ⇒ 直接**去掉把手**(用户明确认不出它 = 它没起到提示作用), + 同时把关闭收到右上角统一的 ✕(见 `FbSheetClose.ets`)。 + ⚠️ 不要"改成居中的把手"来救 —— 那只会把灰线从左上角挪到中间, + 用户依然不知道它是什么;这一屏真正需要的信息是"怎么关掉我", + 而那个就是右上角的 ✕。 */ Row() { - // 把手(视觉提示"可以关掉") - Column() - .width(36) - .height(4) - .backgroundColor(FbColors.line2) - .borderRadius(2) Blank() - Text('关闭') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .padding({ left: 10, right: 10, top: 6, bottom: 6 }) - .onClick((): void => { + FbSheetClose({ + onClose: (): void => { this.onClose(); - }) + } + }) } .width('100%') .alignItems(VerticalAlign.Center) - .padding({ left: 16, right: 16, top: 8, bottom: 4 }) + .padding({ left: 16, right: 16, top: 10, bottom: 2 }) } /** 浮层主体(三种态) */ @@ -275,10 +280,41 @@ export struct FbMovieDetailSheet { // ② 大图 // ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图", // 裁掉边缘就失去意义(缩略图才用 Cover)。 - Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get())) - .width('100%') - .height('76%') - .objectFit(ImageFit.Contain) + // 🔴 用户 2026-10-19:「点击这个图片呢,不能左右滑动看所有的这个图片。」 + // ⇒ 原来只有底部的「上一张/下一张」文字按钮,**没有滑动手势**。 + // 现在整张大图可以左右滑(见下面 `parallelGesture`)。 + Stack({ alignContent: Alignment.Center }) { + Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get())) + .width('100%') + .height('76%') + .objectFit(ImageFit.Contain) + /* 🔴🔴 左右滑翻页。 + ⚠️ 用 **`gesture(PanGesture)`** 而不是 `SwipeGesture`: + 本轮在日程页实测过 —— `SwipeGesture` 要求"**快速**划动"才识别, + 在有滚动容器/复杂布局的页面里容易被别的手势先吃掉,**慢划不触发** + (`.gesture` 与 `.priorityGesture` 都试过)。而 `PanGesture` 看的是 + **累计位移**(`event.offsetX`),只要够长就认,稳得多。 + ⚠️ 这里用 `.gesture`(普通优先级)而不是 `parallelGesture`: + 查看器是**全屏覆盖层**,下面没有需要并行滚动的东西; + 而且同一个节点上挂两条方向不同的 `parallelGesture` 会互相压制 + (日程页周条的实测结论)。 + ⚠️ 判定必须要求「横向位移明显大于纵向」:竖着划(想关掉/看上下)时 + 不该被当成翻页。 */ + .gesture( + PanGesture({ direction: PanDirection.Horizontal }) + .onActionEnd((event: GestureEvent): void => { + const dx: number = event.offsetX; + const dy: number = event.offsetY; + if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) { + return; + } + // 左滑(dx < 0)= 看下一张;右滑 = 上一张(与相册一致) + this.viewerStep(dx < 0 ? 1 : -1); + }) + ) + } + .width('100%') + .height('76%') // ③ 底部控制条(靠下对齐) Row({ space: 18 }) { @@ -421,7 +457,19 @@ export struct FbMovieDetailSheet { // 别写死一个灰 —— 那样所有人的头像都是同一个颜色。 color: a.avatarColor !== undefined && a.avatarColor !== '' ? a.avatarColor : '#A84A28', - dia: 34 + dia: 34, + /* 🔴🔴 用户 2026-10-19:「然后这个观影人的头像并没有带进来。」 + ⇒ 同看人这里**只传了名字和底色**,漏了 `imageUrl` —— + 于是**有头像的人也退化成色块字**,看起来像"头像没带进来"。 + 服务端 `Movie::attendees()` 本来就 `SELECT p.avatar`, + 端上 `parseAttendees` 也解析进了 `a.avatar`, + 纯粹是**渲染时忘了用它**。 + ⚠️ 与名册详情 2026-10-15 踩的那个是**同一个 bug** + (当时是 `FbPersonSheet` 漏传 `imageUrl`)—— + 凡是 `FbAvatar` 都要检查是否传了 `imageUrl`。 + ⚠️ `a.avatar` 是**裸 key**,必须走 `fbImageSrc` 拼绝对地址 + + 令牌(`Image()` 不支持自定义请求头,令牌只能进查询串)。 */ + imageUrl: a.avatar !== '' ? fbImageSrc(a.avatar, FB_TOKEN_STORE.get()) : '' }) Text(a.myNickname !== '' ? a.myNickname : a.name) .fontSize(10) diff --git a/entry/src/main/ets/components/FbPersonQuickSheet.ets b/entry/src/main/ets/components/FbPersonQuickSheet.ets index 5528125..92ac7b2 100644 --- a/entry/src/main/ets/components/FbPersonQuickSheet.ets +++ b/entry/src/main/ets/components/FbPersonQuickSheet.ets @@ -41,6 +41,7 @@ import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbRosterPerson } from '../data/FbPersonRepo'; import { FbAvatar, FbTag } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; import { fbMediaUrl } from '../data/FbHttp'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; @@ -329,9 +330,17 @@ export struct FbPersonQuickSheet { .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) + + /* 🔴 关闭统一到**右上角**(用户 2026-10-19,见 `FbSheetClose.ets`) */ + FbSheetClose({ + onClose: (): void => { + this.onClose(); + }, + color: this.brand() + }) } .width('100%') - .alignItems(VerticalAlign.Center) + .alignItems(VerticalAlign.Top) // ---- 简要信息(电话 / 邮箱 / 单位 / 生日;有才显示)---- // @@ -358,13 +367,15 @@ export struct FbPersonQuickSheet { } .width('100%') - // ---- 四个动作:详情 / 编辑 / 分享 / 关闭 ---- + // ---- 三个动作:详情 / 编辑 / 分享 ---- // // 🔴 「删除」**不在这里**(用户 2026-10-18:太容易误触)—— // 它挪到了完整详情卡片的最下面(`FbPersonSheet` 的 `onDelete`)。 - // ⚠️ 「关闭」也做成**同等大小的按钮**(而不是一行文字链接): - // 四个动作同一个视觉权重,用户不用在"哪个是按钮"上犹豫。 - // 它是灰色(`inputBg` + 次要字色)—— 语义上"这一下什么都不改"。 + // 🔴 「关闭」**也不在这里了**(用户 2026-10-19): + // 「你这个按钮到底是在弹框的右上角关闭,还是在下面关闭……全放在右上角。」 + // ⇒ 全应用统一成**右上角 ✕**(见 `FbSheetClose.ets` 的选择理由)。 + // 底部只留"这一页真正要做的事";关闭是**每个浮层都有的同一动作**, + // 放在固定的右上角,手指不必每次重新找。 Row({ space: 8 }) { this.actionBtn('详情', FbTint.white, this.brand(), (): void => { this.onDetail(); @@ -375,9 +386,6 @@ export struct FbPersonQuickSheet { this.actionBtn('分享', this.brand(), fbAlpha(this.brand(), 0.10), (): void => { this.onShare(); }) - this.actionBtn('关闭', FbTint.muted, INPUT_BG, (): void => { - this.onClose(); - }) } .width('100%') } diff --git a/entry/src/main/ets/components/FbPersonSheet.ets b/entry/src/main/ets/components/FbPersonSheet.ets index 1ff5252..83e3627 100644 --- a/entry/src/main/ets/components/FbPersonSheet.ets +++ b/entry/src/main/ets/components/FbPersonSheet.ets @@ -29,6 +29,7 @@ import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FbAvatar, FbTag } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { fbImageSrc } from '../data/FbAttachQueue'; @@ -400,11 +401,13 @@ export struct FbPersonSheet { .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) + .flexShrink(1) if (this.person !== null && this.person.isAdmin) { FbTag({ text: '管理员', tone: 'brand' }) } } .alignItems(VerticalAlign.Center) + .width('100%') // 本名(昵称与不同名时才有意义) if (this.person !== null && this.person.name !== this.person.shown) { @@ -419,9 +422,21 @@ export struct FbPersonSheet { } .alignItems(HorizontalAlign.Start) .layoutWeight(1) + + /* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。 + ⚠️ 与头像**同一行**:原来底部那个「关闭」按钮已删 —— + 它和「编辑」并排等宽,用户反馈"分不清哪个是主操作"。 + ⚠️ `alignItems` 用 `Top`:头像 60 高,✕ 32 高, + 居中会让它离卡片顶边太远(看着像浮在名字旁边)。 */ + FbSheetClose({ + onClose: (): void => { + this.onClose(); + }, + color: this.accentColor() + }) } .width('100%') - .alignItems(VerticalAlign.Center) + .alignItems(VerticalAlign.Top) // ---- 标签 ---- // ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的 @@ -540,17 +555,6 @@ export struct FbPersonSheet { .onClick((): void => { this.onEdit(); }) - Text('关闭') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .textAlign(TextAlign.Center) - .layoutWeight(1) - .height(40) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .onClick((): void => { - this.onClose(); - }) } .width('100%') .margin({ top: 18 }) diff --git a/entry/src/main/ets/components/FbSheetClose.ets b/entry/src/main/ets/components/FbSheetClose.ets new file mode 100644 index 0000000..4fc66c4 --- /dev/null +++ b/entry/src/main/ets/components/FbSheetClose.ets @@ -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); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/Widgets.ets b/entry/src/main/ets/components/Widgets.ets index 272cdf9..00f7bae 100644 --- a/entry/src/main/ets/components/Widgets.ets +++ b/entry/src/main/ets/components/Widgets.ets @@ -206,19 +206,24 @@ export struct FbAvatar { } build() { - // ⚠️ 用 if/else 而不是"图层叠":两种形态的**尺寸来源**不同 - // (Image 有 intrinsic size,Text 靠 width/height)—— - // 叠起来容易出现"图把字盖住但字还在占位"的错位。 - if (this.imageUrl !== '') { - Image(this.imageUrl) - .width(this.dia) - .height(this.dia) - .borderRadius(this.dia / 2) - // 加载失败/慢时先显示底色,避免"一片空白" - .backgroundColor(this.color) - .objectFit(ImageFit.Cover) - .alt($r('app.media.fb_logo')) - } else { + /* 🔴🔴 v62(用户 2026-10-19):「**如果一个人没头像,在列表是有一个文字的 + 头像的,那么点击后弹框,也应该有。**」 + ⇒ 列表有文字头像(因为 `imageUrl` 是空 ⇒ 走 Text 分支), + 而详情/简要卡是**纯色块**。 + 真因:这里原来是 `if/else` —— 只要 `imageUrl` 非空就**只画 Image**, + 而那张图**加载失败时 `Image` 什么都不显示**(只露出 `.backgroundColor(color)` + 的纯色块)⇒ 看起来就是"一个光板,没有文字头像" + (`alt()` 只在**加载中**起作用,失败后是空白)。 + ⚠️ 而"有 imageUrl 的人"其实不多(实测 800 人里 19 人), + 但**空 key 与坏 key 的观感差别很大**:前者有字,后者是光板。 + ⇒ 改成**叠加**:底色 + 文字头像**常驻**,图片有就叠在上面 + (图片一旦加载成功就完全盖住文字,失败则自动露出文字头像)。 + ⚠️ 之前注释说"图层叠会错位"——那是因为当时想靠 Image 的 intrinsic size 撑尺寸。 + 现在**两层都显式给 `dia`**,尺寸来源唯一,不存在错位。 + ⚠️ 用 Stack 而不是 `backgroundImage`:`backgroundImage` 不接受网络地址, + 而头像走的是 `media.php?k=...`(见 `fbImageUrl` 的说明)。 */ + Stack({ alignContent: Alignment.Center }) { + // ① 底色 + 文字头像(**永远在**,是"图的兜底") Text(this.initial()) .width(this.dia) .height(this.dia) @@ -228,7 +233,18 @@ export struct FbAvatar { .fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42)) .fontWeight(FontWeight.Medium) .textAlign(TextAlign.Center) + + // ② 真头像(有才画;加载成功即盖住①,失败则①露出来) + if (this.imageUrl !== '') { + Image(this.imageUrl) + .width(this.dia) + .height(this.dia) + .borderRadius(this.dia / 2) + .objectFit(ImageFit.Cover) + } } + .width(this.dia) + .height(this.dia) } } diff --git a/entry/src/main/ets/data/FbHttp.ets b/entry/src/main/ets/data/FbHttp.ets index 61912e8..4a6640c 100644 --- a/entry/src/main/ets/data/FbHttp.ets +++ b/entry/src/main/ets/data/FbHttp.ets @@ -18,6 +18,9 @@ import { http } from '@kit.NetworkKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FB_SERVER_STORE } from './FbServerStore'; +// ⚠️ 只为 `fbApi` 的"漏传令牌兜底"(见那里的说明)—— +// `FbTokenStore` 不 import 本文件,不存在循环依赖。 +import { FB_TOKEN_STORE } from './FbTokenStore'; const DOMAIN: number = 0x0000; const TAG: string = 'FbHttp'; @@ -219,8 +222,29 @@ export async function fbApi(path: string, opts: FbApiOptions = {}): Promise; + weekday_distribution?: Record; + 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 { ok: boolean; @@ -558,6 +658,158 @@ export class FbMovieRepo { 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 { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + return '还没登录'; + } + if (name.trim() === '') { + return '影院名称不能为空'; + } + const body: Record = {}; + 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 { + 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 { + 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 { + 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 { + /* ⚠️ `query` 必须走**显式声明类型**的变量:ArkTS 的 + `arkts-no-untyped-obj-literals` 不允许把裸对象字面量直接传进 + `Record` 形参(本项目已踩过同一条,见 FbAccountPage)。 */ + const q: Record = {}; + 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 { + const q: Record = {}; + 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 = JSON.parse(r.data) as Record; + 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 { const row: FbRow | null = await this.db.queryOne( 'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]); diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index 554cb62..6d39df3 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -77,18 +77,18 @@ function fbChip(id: number, shown: string, kinship: string, color: string): FbKi // ============================================================ 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: 2, name: '李慧', kinship: '配偶', group: '本家', tags: [], avatarColor: '#9A5C7A', roster: 'member', birthday: '07-08', 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: [] }, - { id: 4, name: '杨小禾', kinship: '女儿', group: '本家', tags: ['学生'], avatarColor: '#6B5B95', roster: 'member', birthday: '01-04', 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: [] }, - { id: 6, name: '王秀兰', kinship: '母亲', group: '长辈', tags: [], avatarColor: '#B0763A', roster: 'member', birthday: '11-30', 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: [] }, - { id: 8, name: '李国栋', kinship: '岳父', group: '姻亲', tags: [], avatarColor: '#5566A8', roster: 'member', birthday: '02-26', 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: [] }, - { id: 10, name: '张伟', kinship: '朋友', group: '亲友', tags: ['同事'], avatarColor: '#5E7A3C', roster: 'friend', birthday: '06-14', 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: [] }, - { id: 12, name: '赵鹏', kinship: '朋友', group: '亲友', tags: ['邻居'], avatarColor: '#3D6B8E', roster: 'friend', birthday: '04-17', avatar: '', phone: '', initials: 'x', isAdmin: false, 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: [], org: '', title: '' }, + { 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: [], org: '', title: '' }, + { 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: [], org: '', title: '' }, + { 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: [], org: '', title: '' }, + { 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: [], org: '', title: '' }, + { 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: [], org: '', title: '' }, ]; /** 家谱连线(父母 / 配偶 / 子女) */ diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index fcab718..0e2db75 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -315,6 +315,21 @@ export interface FbRosterItem { birthday: string; /** 是否管理员徽标 */ isAdmin: boolean; + /** + * 工作单位(如「中国海洋大学」;空 = 没填)。 + * + * 🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容, + * 例如**工作单位、职务**。」 + * + * ⚠️ 用 `org`/`title` 而**不是** `person_jobs`(工作经历表): + * 列表读的是**本地库**,而本地库只存 `persons` 那一行 + * (`person_jobs` 是一对多,端上没同步那张表 —— 见 `rowToPerson` 的说明)。 + * `org`/`title` 是服务端 `persons` 上的字段、**已经在本地库里**, + * 线上实测 801 人里 192 人有单位、48 人有职务 ⇒ 列表立刻有内容。 + */ + org: string; + /** 职务 / 职称(如「副教授」;空 = 没填) */ + title: string; } /** diff --git a/entry/src/main/ets/pages/account/FbAccountPage.ets b/entry/src/main/ets/pages/account/FbAccountPage.ets index 8fa7928..14456c1 100644 --- a/entry/src/main/ets/pages/account/FbAccountPage.ets +++ b/entry/src/main/ets/pages/account/FbAccountPage.ets @@ -100,7 +100,17 @@ export struct FbAccountPage { return; } 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) { this.accountNote = r.error; return; @@ -181,8 +191,9 @@ export struct FbAccountPage { body['password'] = this.pw.fresh; body['password2'] = this.pw.again; try { + // 🔴 同 `loadAccount` 那处:令牌必须传(漏传 = 401 = 被踢下线) 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) { this.pwBad = true; this.pwNote = r.error; diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets index 200435c..b3c243b 100644 --- a/entry/src/main/ets/pages/movies/FbMoviesPage.ets +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -323,6 +323,23 @@ export struct FbMoviesPage { this.loading = false; } + /** + * 重新读一次本地影院列表(影院管理里增/改/删之后调)。 + * + * 🔴 v62:不重读的话,关掉影院浮层后页头上那个「影院 N 家」还是旧数字 + * (用户刚新增了一家,页头却还写着 26 家 ⇒ 会以为没保存成功)。 + * ⚠️ 只读本地库(`cinemaList()`)不联网 —— 写操作那边已经回写过本地库了 + * (见 `FbMovieRepo.saveCinema` 的说明),这里再打一次接口是多余的往返。 + * ⚠️ 失败**不弹错**:这只是个页头计数,失败了下次同步也会纠正。 + */ + private async reloadCinemas(): Promise { + 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({ cinemas: this.cinemas, + // 🔴 v62:环色/主题色也传(影院管理里的页签、按钮都用观影模块色) + accent: this.accent, onClose: (): void => { this.cinemaSheetOpen = false; + }, + /* 🔴 v62(用户 2026-10-19):影院管理里新增/修改/删除之后, + 要让**本页也拿到新列表** —— 否则关掉浮层,页头上那个 + 「影院 N 家」和影院色点还是旧的(用户会以为没保存成功)。 */ + onCinemasChanged: (): void => { + this.reloadCinemas(); } }) .transition(fbSheetTransition()) diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets index 7ef507f..3fb7796 100644 --- a/entry/src/main/ets/pages/people/FbPeoplePage.ets +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -70,6 +70,11 @@ function toRosterItem(p: FbRosterPerson): FbRosterItem { roster: 'member', birthday: p.birthdaySolar, isAdmin: p.isAdmin, + // 🔴 v62:单位/职务(列表要显示,见 FbRosterItem.org 的说明)。 + // ⚠️ 直接取 `p.org`/`p.title`(本地库里已有),**不是** `p.jobs[0]` —— + // 列表读本地库,而本地的 `jobs` 恒为空(那张表没同步)。 + org: p.org, + title: p.title, }; 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 { try { @@ -1164,10 +1191,20 @@ export struct FbPeoplePage { */ @Builder dockSpacerItem() { + /* 🔴🔴 v62(用户 2026-10-19):「**名册搜索,搜索后,下面有一个白色的框, + 什么内容都没有,删除。**」 + ⇒ 就是这个占位块。它**必须留着**(高度要给悬浮栏让位, + 否则最后一张卡片会被悬浮栏压住),但它的底色原来是 `FbColors.surface` + (**白**),而列表底色是 `FbColors.paper`(**米**)⇒ + 列表内容一短(搜索只剩一两条时)它就露出来,看着像"一个什么都没有的白框"。 + ⇒ 改成与列表同色(paper)⇒ 它变成"看不见的空白", + 预留高度的作用不变,但不再有那个框。 + ⚠️ 别改成 `height(0)` 或删掉:那会让最后一张卡片被悬浮栏压住 + (这正是当初加它的原因,见 `build` 里关于"内容从悬浮框上面滚出来"的说明)。 */ Row() .width('100%') .height(FB_DOCK_RESERVE) - .backgroundColor(FbColors.surface) + .backgroundColor(FbColors.paper) } /** 一个人一行:头像 + 名字/称谓 + 标签 + 生日 */ @@ -1218,6 +1255,22 @@ export struct FbPeoplePage { } } .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) .layoutWeight(1)