Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbTripNewSheet.ets
T
laoyang c7bd26dd1b v77 旅程:列表只留整卡入口 + 动作区改成 2×2 + 地址不截断 + 题图
用户 2026-10-22 的五条:

① 列表卡去掉「看行程」「编辑」——「点进去之后会有编辑,直接点进去
   看行程就可以」。卡尾整行删除,整卡可点变唯一入口(标题行尾留一个 ›
   作提示);openEdit() 一并删掉,不留死代码。

② 详情页动作区重排——「排列得不好看,乱七八糟的」。旧版是四颗彩色实心
   胶囊分两行、右端对不齐。现在 ☰ 挪到标题行右侧,四颗动作做成 2×2
   等宽(layoutWeight(1));主操作保持实心,编辑/删除改浅底同色字。
   配色与顺序仍是 v41 硬要求,一个 hex 都没改。

③ 出行方式:十种方式**不能增删**(网页版也只是只读 + "常用信息",
   而端上加行程表单里根本没有车次/车牌,那条备忘在端上无从使用)
   ⇒ 方向反过来,给加行程表单补上「车次/航班号」(火车/飞机/轮船/
   公交/地铁)和「车牌」(自驾,从车辆库选一台,服务端按 vehicle_id
   join 出车牌)。没有这两项,"出行方式"在端上就只剩一个图标。

④ 行程详情里的地址显示不全——「从哪到哪,如果地址特别长就显示不全」。
   原来出发地与到达地挤在一条 Row 里各 maxLines(1) + 省略号;
   改成「从」「到」上下两行、整段铺开不截断。

⑤ 旅程题图:端上原来根本没有上传链路(服务端只有观影的 poster 槽位)。
   服务端已加 POST /trips/<id>/cover;端上新建/编辑浮层加 16:9 题图选区,
   流程「先建 → 拿 id → 再传图」,传图失败记 id 再点只补传(不会建出
   两趟一样的旅程)。详情页空题图那块也能直接点开去加。

顺带把 multipart 上传收敛成一份(data/FbUpload.ets):原来头像 / 海报 /
观影照片各抄了一遍,旅程题图会是第四份。
2026-10-22 10:15:03 +08:00

459 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 「新建旅程」浮层(鸿蒙端)
*
* 对应网页版 `templates/trips/form.php`(新建那一路)。
*
* 🔴 为什么单独做这一批:端上原来的「新建旅程」按钮**根本没接 `onTap`**
* (`FbTripsPage` 里 `FbPrimaryButton({ label: '新建旅程', ... })` 没有回调),
* 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。
*
* ⚠️ 表单只放**网页版真有的三项**:
* 标题 / 简介 / 是否公开(具体城市与起止时间**不在**这里 ——
* 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**,
* 见 `Trip::derived()`)。
*
* 🔴🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗?
* 为什么这里没有增加题图的功能呢?」
* ⇒ 补上题图。之前没做是因为**服务端没有旅程的图片上传槽位**
* (只有观影的 `/movies/<id>/poster`),现在服务端加了
* `POST /api/v1/trips/<id>/cover`(槽位 `cover`,1920×1080 / 16:9),
* 与网页版 `trips/form.php` 走**同一条** `Upload::image` 管线。
* ⚠️ 端上**不裁剪**:裁/缩/转 JPEG 全在服务端,否则"改造过的客户端"
* 能存进非 16:9 的图,网页版与端上显示比例就不一致了。
* ⚠️ 新建时还没有 id ⇒ 流程是「先建 → 拿 id → 再传图」。
* 传图失败**不算创建失败**(记录已经落库了),所以要把 id 记住,
* 再点一次只重传题图,不会建出两趟一样的旅程。
* ⚠️ 暂时**不给"移除题图"**:服务端还没有"清空题图"的端点,
* 摆一颗点了不生效的按钮是本项目的红线。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createTrip, updateTrip, uploadTripCover, tripIdOf, FbTripDraft } from '../data/FbTripRepo';
import { FbApiResult } from '../data/FbHttp';
import { fbImageSrc } from '../data/FbAttachQueue';
import { picker } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
@ComponentV2
export struct FbTripNewSheet {
/** 模块主题色(旅程 = 绿) */
@Param accent: string = '#3F7D5A';
/**
* 编辑模式传进来的旅程(`null`/`undefined` = 新建)。
*
* ⚠️ 用 `id` 判断而不是靠"标题是否为空" —— 空标题是**合法**的中间状态
* (用户刚清空输入框,还没打完),那时不该被当成"新建"。
*/
@Param editingId: number = 0;
@Param editingTitle: string = '';
@Param editingSummary: string = '';
@Param editingPublic: boolean = true;
/**
* 编辑时已有的题图(**裸 key**,如 `covers/xxx.jpg`;空串 = 还没设题图)。
*
* ⚠️ 传裸 key 而不是完整 URL:完整 URL 带令牌、会过期,
* 而且端上统一走 `fbImageSrc()` 拼(见 `FbAttachQueue`)。
*/
@Param editingCoverKey: string = '';
@Event onClose: () => void = (): void => {};
/** 保存成功:父页面据此刷新列表 */
@Event onSaved: () => void = (): void => {};
@Local title: string = '';
@Local summary: string = '';
@Local isPublic: boolean = true;
@Local saving: boolean = false;
/** 错误/提示(红字) */
@Local note: string = '';
/** 刚选的题图 URI(`file://…`;空串 = 这次没换图) */
@Local pickedCover: string = '';
/** 老题图加载失败(别每次都重试) */
@Local coverFailed: boolean = false;
/**
* 已经建好的旅程 id(新建流程用)。
*
* ⚠️ 为什么要记住:题图上传失败时记录**已经落库**了,
* 这时再点一次保存不能再 `POST /trips`(会建出两趟一样的旅程),
* 只能接着补传图 —— 所以要把 id 存下来。
*/
@Local createdId: number = 0;
aboutToAppear(): void {
this.title = this.editingTitle;
this.summary = this.editingSummary;
this.isPublic = this.editingPublic;
}
build() {
/* 🔴🔴 根必须是 `Stack`(见 `pitfalls.md` 的"全屏遮罩不能当 Column 子项排")。
`Column { 整高遮罩; 卡片 }` 会把卡片挤出屏幕 ⇒ 浮层"点了没反应"。 */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
// ---- 标题栏(统一版式:左标题 + 右 ✕)----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.editingId > 0 ? '编辑旅程' : '新建旅程')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text('先起个名字;城市与时间等加了行程自然就有了')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(2)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
/* ---- 可滚区:题图 + 名字 + 简介 + 公开 ----
⚠️ v77 加了题图(16:9 ≈ 186vp 高)之后,整张浮层在小屏上会顶出
屏幕(键盘一弹更糟)⇒ 与「加一段行程」浮层同一套处理:
中间这块给 `Scroll` + `maxHeight`,底部保存按钮永远可见。
⚠️ 不写 `maxHeight` 的话,"内容不多"时浮层还是按内容自适应高度,
不会变成一块留白的死板子。 */
Scroll() {
Column({ space: 12 }) {
// ---- 题图(v77:点整块选区,选完立刻有预览)----
this.coverPicker()
// ---- 旅程名(必填)----
this.textField('旅程名', '如「青岛·崂山 两日」', this.title,
(v: string): void => {
this.title = v;
this.note = '';
})
// ---- 简介 ----
this.areaField('简介', '这趟出行想记的几句话(可空)', this.summary,
(v: string): void => {
this.summary = v;
})
// ---- 是否公开 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('在列表里公开')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('不公开的旅程不会出现在列表里')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.isPublic })
.selectedColor(this.accent)
.onChange((on: boolean): void => {
this.isPublic = on;
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ top: 2, bottom: 2 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 430 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
// ---- 错误 / 提示 ----
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
// ---- 底部:保存(关闭统一在右上角 ✕)----
Row() {
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '建好这趟旅程'))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
.padding({ top: 4 })
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
/**
* 提交。
*
* ⚠️ 保存中要**挡住重复点击**:连点两下会建出两趟一模一样的旅程
* (服务端不去重)。`this.saving` 在这里是真正的功能,不是装饰。
*
* 🔴 v77 的两段式:**先落库、再传图**。
* ① 新建:`POST /trips` 拿到 id(记进 `createdId`)→ 再 `POST /trips/<id>/cover`;
* ② 编辑:`PATCH /trips/<id>` → 再传图。
* ⚠️ 传图失败**不撤销**旅程(记录已经存在,删掉反而更糟),
* 而是把错误说清楚 + 记住 id ⇒ 用户再点一次只重传图。
*/
private async save(): Promise<void> {
if (this.saving) {
return;
}
const title: string = this.title.trim();
if (title === '') {
this.note = '旅程得有个名字。';
return;
}
this.saving = true;
this.note = '';
const token: string = FB_TOKEN_STORE.get();
const d: FbTripDraft = {
title: title,
summary: this.summary.trim(),
isPublic: this.isPublic
};
/* ---- ① 旅程本体:上次只差传图(`createdId > 0`)就不要再建一条 ---- */
let id: number = this.editingId > 0 ? this.editingId : this.createdId;
if (id <= 0) {
const res: FbApiResult = await createTrip(token, d);
if (!res.ok) {
this.saving = false;
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
id = tripIdOf(res);
this.createdId = id;
} else if (this.editingId > 0) {
/* 编辑:标题 / 简介 / 公开每次都要重写(用户可能刚改过) */
const res: FbApiResult = await updateTrip(token, this.editingId, d);
if (!res.ok) {
this.saving = false;
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
}
/* ---- ② 题图(这次没换图就跳过)---- */
if (id > 0 && this.pickedCover !== '') {
const err: string = await uploadTripCover(token, id, this.pickedCover);
if (err !== '') {
this.saving = false;
this.note = '旅程已经保存好了,但题图没传上去:' + err + '。再点一次可以只重传题图。';
return;
}
}
this.saving = false;
this.onSaved();
}
/**
* 这张图到底有没有(决定显示预览还是"点这里选题图")。
*
* ⚠️ 优先级是**刚选的** > 服务端已有的:刚选的那张一定是用户眼前想要的,
* 此时老图还没被替换(要等保存后才传上去)。
*/
private hasCover(): boolean {
if (this.pickedCover !== '') {
return true;
}
return this.editingCoverKey !== '' && !this.coverFailed;
}
/**
* 选题图(系统图库选择器)。
*
* ⚠️ 用 `photoViewPicker` 而不是相机:选图不需要额外权限,
* 也不会因为没授相机权限卡住(与换头像同一套)。
* ⚠️ 这里**只记 URI、不上传** —— 新建时还没有 id,
* 统一在 `save()` 里"先建后传"。
*/
private async pickCover(): Promise<void> {
try {
const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions();
opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
opts.maxSelectNumber = 1;
const p: picker.PhotoViewPicker = new picker.PhotoViewPicker();
const sel: picker.PhotoSelectResult = await p.select(opts);
if (sel.photoUris.length === 0) {
return; // 用户取消了
}
this.pickedCover = sel.photoUris[0];
this.note = '';
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(0x0000, 'FamilyBook', '选题图失败:%{public}s', JSON.stringify(e));
this.note = '选图失败,请再试一次。';
}
}
/**
* 题图选择块(16:9,与 `ImageSlot` 的 `cover` 槽位一致)。
*
* ⚠️ 整块可点:手机上"点一张大图换一张大图"比"去找一个小按钮"自然得多。
* ⚠️ 预览用 `Cover`:槽位是按 16:9 裁好的,`Cover` 正好铺满不留边
* (只有查看器那种"要看清整张"的场合才用 `Contain`)。
*/
@Builder
coverPicker() {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text('题图')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.layoutWeight(1)
Text(this.hasCover() ? '换一张' : '选一张')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#F1E7D6')
if (this.pickedCover !== '') {
Image(this.pickedCover)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
} else if (this.editingCoverKey !== '' && !this.coverFailed) {
Image(fbImageSrc(this.editingCoverKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.onError((): void => {
this.coverFailed = true;
})
}
if (!this.hasCover()) {
Column({ space: 4 }) {
Text('+')
.fontSize(20)
.fontColor('#A8988A')
Text('点这里选题图')
.fontSize(FbMetric.fontTiny)
.fontColor('#A8988A')
Text('16:9,上传后按这个比例裁好')
.fontSize(10)
.fontColor('#B8AA9C')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
.width('100%')
.aspectRatio(16 / 9)
.borderRadius(FbMetric.radiusS)
.clip(true)
.onClick((): void => {
this.pickCover();
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 单行输入 */
@Builder
textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(48)
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(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/** 多行输入(简介) */
@Builder
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(48)
TextArea({ text: value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(84)
.layoutWeight(1)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
}