Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbTripNewSheet.ets
T

266 lines
9.2 KiB
Plaintext
Raw Normal View History

/**
* 家书 FamilyBook · 「新建旅程」浮层(鸿蒙端)
*
* 对应网页版 `templates/trips/form.php`(新建那一路)。
*
* 🔴 为什么单独做这一批:端上原来的「新建旅程」按钮**根本没接 `onTap`**
* (`FbTripsPage` 里 `FbPrimaryButton({ label: '新建旅程', ... })` 没有回调),
* 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。
*
* ⚠️ 表单只放**网页版真有的三项**:
* 标题 / 简介 / 是否公开。**没有起止时间、没有城市** ——
* 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**
* (见 `Trip::derived()`)。加个"时间"输入框只会让人以为填了有用。
*
* ⚠️ 题图这一批**不做**:网页版走 `cover` 文件上传 + `cover_crop` 裁剪,
* 而服务端目前只有观影的 `/movies/<id>/poster` 上传槽位,没有旅程的。
* ⇒ 新建时先不传图;列表卡仍会显示已有数据的题图。
* (补上传端点时要与网页版 `ImageSlot` 的 `cover` 槽位比例 16:9 一致,
* 否则宽高比会和网页端不一样 —— 那条在 app.css 里有专门的断言比对。)
*/
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, FbTripDraft } from '../data/FbTripRepo';
import { FbApiResult } from '../data/FbHttp';
@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;
@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 = '';
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)
// ---- 旅程名(必填)----
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 })
// ---- 错误 / 提示 ----
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` 在这里是真正的功能,不是装饰。
*/
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
};
const res: FbApiResult = this.editingId > 0
? await updateTrip(token, this.editingId, d)
: await createTrip(token, d);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.onSaved();
}
/** 单行输入 */
@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)
}
}