v63+v64:影院管理补齐(v63)与观影「记一场」豆瓣/弹框选择(v64)
⚠️ 本提交含**两批**改动(同一批文件里交织,无法按文件拆开): · v63(影院管理与搜索/统计/年报拆成平级入口)—— 本会话之外的工作, 随本轮一并提交以免工作区长期留半成品; · v64(本会话,用户 2026-10-19 六条)。 v64 六条: ① 片名可经豆瓣自动获取简介与海报(与网页端同一份服务端输出: summary 全文 + 已落盘的 poster_key);简介**只填空白的**,不覆盖用户写过的。 联想 320ms 防抖(与网页端同值)。 ② 开始/结束改成**与日程同一个**日期时间选择控件 —— 新抽 components/FbDateTimeField.ets (行 + 弹层 + 一组换算函数),日程那边旧的自绘实现整段删掉改用它 (用户要的就是"两处一样")。 ③ 影院、同看人改成**弹框选择**: · FbCinemaPickSheet:顶部搜索框,匹配「地方 + 名称」,「不记录影院」恒在列表; · FbPersonPickSheet:顶部搜索框,拼音首字母**子序列**匹配(yyq 打成 yq 也能搜到), 多选走"暂存 + 确定"两段式(点一个不关窗)。 两排横向胶囊/头像全删(用户:"这种方式太难找了")。 ④ 允许上传**多张**图片:海报 1 张 + 观影照片可一次选 9 张、可多次追加、可逐张删。 ⚠️ 顺序是**先建记录再传图**(建记录失败就不会留孤儿图);图失败**不挡记录**。 ⑤ 删掉「地点」输入口(location 字段仍发,网页端与历史数据不受影响)。 ⑥ 观影页搜索框改明显:白底 + 实色描边 + 图标放大到 18 并染主题色 + 占位文字提到 muted/fontBody + 右侧「筛选 ›」暗示(用户:"根本看不清")。 FbMovieRepo 新增 doubanSuggest / doubanFetch / uploadPoster / uploadPhoto (multipart 手写,与头像上传同一形态,抽成共用的 uploadOne)。 验证:静态守卫 636/0 · 端上反向验证 87/0 · 接口 e2e 57/0 · 服务端反向验证 18/0 · 本地全量 e2e 1521/0 · hvigor 编译成功并已装机。 ⚠️ 真机验证脚本 _fb_v64_verify.py 已写好(六条全覆盖),因设备锁屏未能执行。
This commit is contained in:
@@ -20,9 +20,12 @@
|
||||
*/
|
||||
|
||||
import { relationalStore } from '@kit.ArkData';
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { FbDb, FbRow } from './FbDb';
|
||||
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
|
||||
import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from './FbHttp';
|
||||
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
@@ -136,6 +139,65 @@ export interface FbMovieYearStat {
|
||||
cnt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「记一场」表单填出来的草稿(→ `createMovie`)。
|
||||
*
|
||||
* ⚠️ 字段名与 `createMovie` 的请求体一一对应(那里再映射成服务端的下划线命名)。
|
||||
*/
|
||||
export interface FbMovieDraft {
|
||||
/** 片名(必填) */
|
||||
title: string;
|
||||
/** 开始时间 `YYYY-MM-DD HH:MM`(必填;服务端会补秒) */
|
||||
startsAt: string;
|
||||
/** 结束时间(可空) */
|
||||
endsAt: string;
|
||||
/** 地点(服务端字段是 `location`;可空) */
|
||||
location: string;
|
||||
/** 备注(可空) */
|
||||
note: string;
|
||||
/** 剧情简介(可空) */
|
||||
info: string;
|
||||
/** 影院 id(0 = 没选) */
|
||||
cinemaId: number;
|
||||
/** 同看人的 person id 列表 */
|
||||
attendees: number[];
|
||||
/**
|
||||
* 海报存储键(`posters/xxx.jpg`)。
|
||||
*
|
||||
* 🔴 v64:**豆瓣抓取来的海报**走的不是"上传文件"那条路 ——
|
||||
* 服务端抓取时已经把图落盘了,返回一个键;端上保存记录时把它带上即可
|
||||
* (与网页端隐藏字段 `poster_key` 完全同一条路,见 `movies/create` 路由)。
|
||||
* ⚠️ 端上自己选的图片则走 `uploadPoster()` 单独上传(记录建完之后),
|
||||
* 两种来源**不会同时用**:抓来的那份只有键、端上选的那份是字节。
|
||||
*/
|
||||
posterKey: string;
|
||||
}
|
||||
|
||||
/** 豆瓣片名联想的一条候选(`/movies/douban?action=suggest`) */
|
||||
export interface FbDoubanSuggest {
|
||||
id?: string;
|
||||
title?: string;
|
||||
sub_title?: string;
|
||||
year?: string;
|
||||
kind?: string;
|
||||
/** 缩略图地址(**已是 API 代理形式**,端上补域名 + token 即可加载) */
|
||||
thumb?: string;
|
||||
}
|
||||
|
||||
/** 豆瓣抓取的结果(`/movies/douban`) */
|
||||
export interface FbDoubanFetch {
|
||||
/** 豆瓣正规片名(回填输入框) */
|
||||
title: string;
|
||||
/** **服务端拼好的**简介全文(含导演/主演/年份/类型/片长/国家/评分 + 剧情) */
|
||||
summary: string;
|
||||
/** 已落盘的海报存储键(`posters/xxx.jpg`;没抓到就是空串) */
|
||||
posterKey: string;
|
||||
/** 海报可访问地址(预览用) */
|
||||
posterUrl: string;
|
||||
/** 豆瓣元数据 JSON 串(原样存进 `douban_meta`,详情页会另有一块展示) */
|
||||
meta: string;
|
||||
}
|
||||
|
||||
/** `/api/v1/movies/stats` 的完整载荷 */
|
||||
export interface FbMovieStats {
|
||||
overview?: FbMovieOverview;
|
||||
@@ -658,6 +720,81 @@ export class FbMovieRepo {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **新建一场观影**(用户 2026-10-19:「电影新增一场、新增一个电影,
|
||||
* 也就是说"记一场"这个功能现在可以开始做了。」)
|
||||
*
|
||||
* 🔴 为什么必须联网、且**不等同步**:
|
||||
* 这是"写"操作 —— 写进去才算数。接口成功后**立刻重拉列表**回写本地库,
|
||||
* 否则用户刚记的那场要等下次整批同步才出现(看着像没保存)。
|
||||
*
|
||||
* ⚠️ 字段名必须与服务端 `ApiHandler::movieData()` 逐字一致:
|
||||
* `title` / `starts_at` / `ends_at` / `location` / `note` / `info` /
|
||||
* `cinema_id` / `attendees`(int[])。
|
||||
* ⚠️ `starts_at` 服务端走 `Api::dt()`,要 `YYYY-MM-DD HH:MM:SS` 或
|
||||
* `YYYY-MM-DD HH:MM`(端上拼第二种,服务端会补秒)。
|
||||
*
|
||||
* @returns 新建成功返回新 id(>0);失败返回 0,原因见 `lastError`
|
||||
*/
|
||||
async createMovie(d: FbMovieDraft, token: string): Promise<number> {
|
||||
if (token === '') {
|
||||
this.lastError = '还没登录';
|
||||
return 0;
|
||||
}
|
||||
if (d.title.trim() === '') {
|
||||
this.lastError = '片名不能为空';
|
||||
return 0;
|
||||
}
|
||||
if (d.startsAt.trim() === '') {
|
||||
this.lastError = '观影时间不能为空';
|
||||
return 0;
|
||||
}
|
||||
/* ⚠️ 请求体要用**显式声明的 interface**(`arkts-no-untyped-obj-literals`)——
|
||||
这里用 `Record<string, Object>` 逐键赋值(与 `FbCalRepo.toBody` 同一写法)。 */
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = d.title.trim();
|
||||
body['starts_at'] = d.startsAt.trim();
|
||||
body['ends_at'] = d.endsAt.trim();
|
||||
body['location'] = d.location.trim();
|
||||
body['note'] = d.note.trim();
|
||||
body['info'] = d.info.trim();
|
||||
body['cinema_id'] = d.cinemaId;
|
||||
body['attendees'] = d.attendees;
|
||||
/* 🔴 v64:豆瓣抓来的海报键随记录一起提交(与网页端隐藏字段同一条路)。
|
||||
⚠️ 只有非空才发 —— 发空串会**清掉**已有的海报(服务端的 `movieData` 认
|
||||
"字段存在"为"要改")。 */
|
||||
if (d.posterKey !== '') {
|
||||
body['poster_key'] = d.posterKey;
|
||||
}
|
||||
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
const r: FbApiResult = await fbApi('/api/v1/movies', opts);
|
||||
if (!r.ok) {
|
||||
this.lastError = r.error !== '' ? r.error : '保存失败';
|
||||
return 0;
|
||||
}
|
||||
let newId: number = 0;
|
||||
try {
|
||||
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
|
||||
const v: Object | undefined = o['id'];
|
||||
newId = typeof v === 'number' ? v : 0;
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '新建观影回包解析失败:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
this.lastError = '';
|
||||
// 🔴 立刻重拉列表回写本地库(见方法说明)
|
||||
await this.sync(token);
|
||||
return newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一次写操作的错误原因('' = 成功)。
|
||||
*
|
||||
* ⚠️ 不用"返回 string 错误 + 用 id 表示成功"那种混合签名:
|
||||
* `createMovie` 要**返回新 id**,错误就只好另找地方放(本项目在别处踩过)。
|
||||
*/
|
||||
lastError: string = '';
|
||||
|
||||
/**
|
||||
* 新建 / 修改一家影院(**直接打接口,不等同步**)。
|
||||
*
|
||||
@@ -810,6 +947,127 @@ export class FbMovieRepo {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
🔴 v64:豆瓣自动补充(片名联想 / 一键抓取简介 + 海报)
|
||||
============================================================ */
|
||||
|
||||
/**
|
||||
* 片名联想(`/api/v1/movies/douban?action=suggest&q=…`)。
|
||||
*
|
||||
* 🔴 用户 2026-10-19:「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。
|
||||
* 这个在网页端是直接实现了的,我们的手机端也必须同步实现。」
|
||||
*
|
||||
* ⚠️ 与网页端**同一个上游**(`App\Douban::suggest`),候选的顺序、过滤影人、
|
||||
* 限量 8 条的规则完全一致 —— 两边看到的候选不该有差别。
|
||||
* ⚠️ 失败**静默**(返回空数组):联想是"打字时的辅助",弹个错误框会
|
||||
* 打断输入;真正的错误在用户点「自动获取」时给(与网页端同一取舍)。
|
||||
*
|
||||
* @returns 候选列表(最多 8 条;失败或没搜到都是空数组)
|
||||
*/
|
||||
async doubanSuggest(q: string, token: string): Promise<FbDoubanSuggest[]> {
|
||||
const kw: string = q.trim();
|
||||
if (kw === '') {
|
||||
return [];
|
||||
}
|
||||
const query: Record<string, string> = {};
|
||||
query['action'] = 'suggest';
|
||||
query['q'] = kw;
|
||||
const r: FbApiResult = await fbApi('/api/v1/movies/douban',
|
||||
{ method: 'GET', token: token, query: query });
|
||||
if (!r.ok) {
|
||||
hilog.warn(DOMAIN, TAG, '豆瓣联想失败(静默):%{public}s', r.error);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
|
||||
const arr: Object[] | undefined = o['items'] as Object[] | undefined;
|
||||
return arr !== undefined && Array.isArray(arr) ? (arr as FbDoubanSuggest[]) : [];
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '豆瓣联想解析失败:%{public}s', JSON.stringify(err));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取详情 + 简介 + 海报(`/api/v1/movies/douban?id=<豆瓣id>` 或 `?q=<片名>`)。
|
||||
*
|
||||
* 🔴 与网页端 `apply()` 的契约**逐条对齐**:
|
||||
* · `summary` 是**服务端拼好的**简介全文(含导演/主演/年份/类型/片长/国家/评分
|
||||
* + 剧情原文)—— 端上不自己拼,否则两边写进库的文本会不一样;
|
||||
* · `poster_key` 是**已经落盘**的海报存储键(服务端走 poster 槽位管线裁成 2:3),
|
||||
* 端上保存记录时把它当 `poster_key` 一起提交即可,不需要自己再传文件。
|
||||
*
|
||||
* ⚠️ 只填空白的(与网页端同一语义):调用方拿到结果后,`info` 已有内容就不覆盖。
|
||||
*
|
||||
* @returns null = 失败(原因见 `lastError`)
|
||||
*/
|
||||
async doubanFetch(token: string, doubanId: string, q: string): Promise<FbDoubanFetch | null> {
|
||||
if (token === '') {
|
||||
this.lastError = '还没登录';
|
||||
return null;
|
||||
}
|
||||
const query: Record<string, string> = {};
|
||||
if (doubanId !== '') {
|
||||
query['id'] = doubanId;
|
||||
} else if (q.trim() !== '') {
|
||||
query['q'] = q.trim();
|
||||
} else {
|
||||
this.lastError = '先写个片名';
|
||||
return null;
|
||||
}
|
||||
const r: FbApiResult = await fbApi('/api/v1/movies/douban',
|
||||
{ method: 'GET', token: token, query: query });
|
||||
if (!r.ok) {
|
||||
this.lastError = r.error !== '' ? r.error : '抓取失败,请稍后再试';
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
|
||||
const out: FbDoubanFetch = {
|
||||
title: strField(o, 'title'),
|
||||
summary: strField(o, 'summary'),
|
||||
posterKey: strField(o, 'poster_key'),
|
||||
posterUrl: strField(o, 'poster'),
|
||||
meta: o['meta'] !== undefined ? JSON.stringify(o['meta']) : ''
|
||||
};
|
||||
this.lastError = '';
|
||||
return out;
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '豆瓣抓取解析失败:%{public}s', JSON.stringify(err));
|
||||
this.lastError = '返回内容异常';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 传一张**海报**(`POST /api/v1/movies/<id>/poster`,multipart 字段 `poster`)。
|
||||
*
|
||||
* 🔴 用户 2026-10-19:「**允许上传多张图片,包括海报。**」
|
||||
*
|
||||
* ⚠️ 与服务端的分工:裁剪(2:3)、缩放、转 JPEG 全在**服务端**做
|
||||
* (`Upload::image` 的 poster 槽位)—— 端上只负责把原图字节送上去。
|
||||
* 端上自己裁会让"绕过端上(改造过的客户端)"存进非 2:3 的图。
|
||||
*
|
||||
* @returns 空串 = 成功;否则是错误说明
|
||||
*/
|
||||
async uploadPoster(id: number, uri: string, token: string): Promise<string> {
|
||||
return uploadOne('/api/v1/movies/' + id.toString() + '/poster', 'poster', uri, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 传**观影照片**(`POST /api/v1/movies/<id>/photos`,multipart 字段 `photo`)。
|
||||
*
|
||||
* ⚠️ 一张一张传(而不是一次拼多张 multipart):
|
||||
* · 每张的成败独立 —— 一张 12MB 的原图失败,不该把已经成功的几张一起回滚;
|
||||
* · 端上进度好做(传了三张就显示"3 / 5");
|
||||
* · 服务端那次是**追加**语义(`Movie::addPhotos`),多次调用结果与一次相同。
|
||||
*
|
||||
* @returns 空串 = 成功;否则是错误说明
|
||||
*/
|
||||
async uploadPhoto(id: number, uri: string, token: string): Promise<string> {
|
||||
return uploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token);
|
||||
}
|
||||
|
||||
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]);
|
||||
@@ -1059,6 +1317,106 @@ function strOf(v: string | null | undefined): string {
|
||||
function numOf(v: number | null | undefined): number {
|
||||
return v !== null && v !== undefined ? v : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 对象里安全取一个字符串字段(非字符串 → 空串)。
|
||||
*
|
||||
* ⚠️ 与 `strOf` 的分工:`strOf` 处理"值本身可能 null/undefined",
|
||||
* 这个处理"**值可能是任意类型**"(如 `meta` 是对象、`id` 是数字)。
|
||||
* 豆瓣那条接口的 `meta` 是嵌套对象,硬当字符串读会崩。
|
||||
*/
|
||||
function strField(o: Record<string, Object>, key: string): string {
|
||||
const v: Object | undefined = o[key];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传一个文件到某个观影子资源(**手工拼 multipart**)。
|
||||
*
|
||||
* 🔴 与 `FbPersonEditSheet.uploadAvatar` 是同一套写法(HarmonyOS 的 `http`
|
||||
* 没有现成的 multipart 封装,得手写 boundary 与分片)。
|
||||
* ⚠️ 抽到这里而不是在表单里再写一遍:那个方法是 `private` 且绑定了
|
||||
* "头像"这个字段名/URL,观影这边是两个不同端点、两个字段名 ——
|
||||
* 复制一份必然漂移(本项目为"两份实现漂移"栽过好几次)。
|
||||
*
|
||||
* ⚠️ 顺序必须是 head(字符串) → 文件(ArrayBuffer) → tail(字符串):
|
||||
* 服务端按这个顺序解析出一个分片。
|
||||
* ⚠️ `filename` 给固定值(`upload.jpg`):服务端**不信客户端文件名**
|
||||
* (`Upload::image` 自己生成随机名 + finfo 嗅探真实 MIME),
|
||||
* 这里给什么都不影响结果 —— 但必须给,否则 multipart 头不合法。
|
||||
*
|
||||
* @param path 接口路径(如 `/api/v1/movies/12/photos`)
|
||||
* @param field 表单字段名(`poster` / `photo`,**必须与服务端读的一致**)
|
||||
* @param uri picker 给的文件 URI(`file://...`)
|
||||
* @returns 空串 = 成功;否则是错误说明
|
||||
*/
|
||||
async function uploadOne(path: string, field: string, uri: string, token: string): Promise<string> {
|
||||
const base: string = fbApiBase();
|
||||
if (base === '') {
|
||||
return '还没有设置服务器地址';
|
||||
}
|
||||
if (token === '') {
|
||||
return '还没登录';
|
||||
}
|
||||
let buf: ArrayBuffer;
|
||||
try {
|
||||
const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
|
||||
const stat: fileIo.Stat = fileIo.statSync(file.fd);
|
||||
buf = new ArrayBuffer(stat.size);
|
||||
fileIo.readSync(file.fd, buf);
|
||||
fileIo.closeSync(file);
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err));
|
||||
return '读不出这张图';
|
||||
}
|
||||
|
||||
const boundary: string = '----FbMovie' + Date.now().toString();
|
||||
const head: string = '--' + boundary + '\r\n'
|
||||
+ 'Content-Disposition: form-data; name="' + field + '"; filename="upload.jpg"\r\n'
|
||||
+ 'Content-Type: image/jpeg\r\n\r\n';
|
||||
const tail: string = '\r\n--' + boundary + '--\r\n';
|
||||
|
||||
const req: http.HttpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await req.request(base + path, {
|
||||
method: http.RequestMethod.POST,
|
||||
header: {
|
||||
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
extraData: [head, buf, tail],
|
||||
connectTimeout: 20000,
|
||||
// ⚠️ 读超时给足 60 秒:手机原图可能十几 MB,服务端还要裁/缩/转码,
|
||||
// 15 秒(业务请求那个值)在这里会**假性失败** —— 图其实存下来了,
|
||||
// 用户却看到"上传失败",再点一次就变成两张。
|
||||
readTimeout: 60000,
|
||||
expectDataType: http.HttpDataType.STRING
|
||||
});
|
||||
if (resp.responseCode >= 200 && resp.responseCode < 300) {
|
||||
return '';
|
||||
}
|
||||
const body: string = typeof resp.result === 'string' ? resp.result : '';
|
||||
// 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话
|
||||
let msg: string = 'HTTP ' + resp.responseCode.toString();
|
||||
try {
|
||||
const o: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
|
||||
const e: Object | undefined = o['error'];
|
||||
if (typeof e === 'string' && e !== '') {
|
||||
msg = e;
|
||||
}
|
||||
} catch (err) {
|
||||
// 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码
|
||||
}
|
||||
return msg;
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e));
|
||||
return '网络异常';
|
||||
} finally {
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// 观影详情的解析
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user