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:
@@ -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<FbAp
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
if (opts.token !== undefined && opts.token !== '') {
|
||||
header['Authorization'] = 'Bearer ' + opts.token;
|
||||
/* 🔴🔴 漏传令牌兜底(2026-10-19 事故后加的)。
|
||||
*
|
||||
* 事故经过:新写的「个人设置」页调 `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 = {
|
||||
@@ -239,8 +263,16 @@ export async function fbApi(path: string, opts: FbApiOptions = {}): Promise<FbAp
|
||||
const out: FbApiResult = parseEnvelope(body, resp.responseCode);
|
||||
/* v57:401 = 令牌无效/过期/被踢 → 立刻通知全局锁定(见 FbUnauthorizedCb 的说明)。
|
||||
⚠️ 判的是 **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();
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { relationalStore } from '@kit.ArkData';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { FbDb, FbRow } from './FbDb';
|
||||
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
|
||||
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbMovieRepo';
|
||||
@@ -99,6 +100,105 @@ export interface FbSyncResult {
|
||||
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 {
|
||||
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<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> {
|
||||
const row: FbRow | null = await this.db.queryOne(
|
||||
'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]);
|
||||
|
||||
Reference in New Issue
Block a user