v65/v66:六个空壳模块接线 + 桌面服务卡片
用户 2026-10-20:
「做几个和 syncCalendar 差不多的卡片。另外其他的功能也逐步完成……旅程的
新建旅程啊,车辆管理、出行方式、节点设置……资源也点不进去……任务也点不
进去,也没法完成……纪念日、新增纪念日……财务的显示也有问题……笔记现在
也点不进去,记笔记也记不了。」
v65 —— 六个模块(任务 / 资源 / 纪念日 / 笔记 / 账本 / 旅程)原本是**同一套
空壳模板**复制出来的:`FbPrimaryButton` 没有 `onTap`、`ListItem` 没有
`onClick`、设置菜单的 `action` 全是空串。这一版把它们全部接上真实接口:
· 任务:新建 / 编辑(`FbTaskEditSheet` + `FbTaskRepo`)、列表可点、勾完成
(走 `/tasks/<id>/status` —— 服务端 `PATCH` **显式忽略** status,走 Patch
会得到"看起来成功的假成功")、按看板筛选
· 资源:新增 / 查看 / 改 / 删(`FbResourceSheet` + `FbResourceRepo`);
过滤维度从**并不存在的**「证件 / 资料」改成按权限与归属
(`resources` 表没有 category 列,那个筛选一直是筛空的)
· 纪念日:新增 / 编辑(`FbDayEditSheet` + `FbDayRepo`);派生生日
(id=0、来自名册)只给提示不给编辑 —— 点开 `/days/0` 必然 404
· 笔记:记一条 / 打开 / 改(`FbNoteSheet` + `FbNoteRepo`),正文按 blocks 存
· 账本:记一笔(`FbLedgerEntrySheet` + `FbLedgerRepo`);修掉财务显示自相
矛盾 —— 资产/负债改回**服务端 summary**(原来端上按金额正负号重算,与
服务端"负债取绝对值"的口径打架,会算出 净资产 ≠ 资产 − 负债)
· 旅程:题图卡片(封面 + 标题 + 城市)+ 行程 + 新建旅程 + 新建节点
服务端同时修掉两处**静默数据丢失**(网页端因提交格式凑巧没暴露,只有走
API 的客户端会中招):
· 新建笔记传的是 `body`,而 `Note::create()` 只读 `blocks` ⇒ 新笔记只有标题
· 改笔记时 `blocks` 恒为空,而 `Note::update()` 是整组替换 ⇒ 只改标题就把
正文全删光;另修 `annivPeopleOf()`(`find()` 的 SQL 不含 people,原来那个
兜底恒为空 ⇒ 只改纪念日标题会清空关联人)
v66 —— 桌面服务卡片(用户选了「桌面服务卡片」这一项):
· `FbFormAbility`(FormExtensionAbility)+ `form_config.json` 三块尺寸:
2×2 今日概览 / 2×4 左右分栏 / 4×4 今日全景
· `FbCardData` 只打 `/home` 一个接口(与网页首页同源),数据**扁平化**后
由 `formBindingData` 推给卡片;断网时用上次成功的缓存并注明"离线"
· 卡片是后台进程 ⇒ 请求带 `authProbe: true`:401 **不能**触发全局"踢回
登录页"(那会顺手把正在用应用的人踢下线,v62 那次事故同一个坑)
· 点卡片 → `postCardAction(router, fbModule)` → `EntryAbility` 读 Want 参数
直接落到对应模块;补了 `onNewWant`(没有它,第二次点卡片"什么也没发生")
· 4×4 右上角 ↻ 走 `postAction(message)` → `onFormEvent` 重取数再推回
验证:`hvigorw assembleHap` BUILD SUCCESSFUL;打包后的 hap 里确认
`FbFormAbility`(type=form)与 `$profile:form_config` 都在,卡片代码进了
`ets/widgets.abc`。静态守卫 730 条全绿,反向验证 110 条注入全部能让守卫变红。
⚠️ 设备仍断连(`hdc list targets` 为空),真机尚未验收。
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 「新建 / 编辑纪念日」浮层(鸿蒙端)
|
||||
*
|
||||
* 对应网页版 `templates/days/form.php`(网页 2026-10-11 起也是**弹框**形态)。
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「纪念日、新增纪念日、设置相关的功能。」
|
||||
* ⇒ 端上「添加」按钮没有 `onTap`、列表行没有 `onClick` —— 都没有接。
|
||||
*
|
||||
* 字段照服务端 `annivData()` 的契约:名称 / 按什么重复 / 日期(月+日)/ 分类 / 备注 / 关联人。
|
||||
*
|
||||
* ⚠️⚠️ **只存月日,不存年**(`anniversaries` 表就那么设计的):
|
||||
* 日期控件选完只取 `month` / `day`,年份丢掉。所以这里的文案是
|
||||
* 「几月几号」而不是「哪一天」—— 别让用户以为它记住了年份。
|
||||
*
|
||||
* ⚠️ 分类是**自由文本**:服务端 `kind` 就是用户自己配的分类名
|
||||
* (`Anniversary::kindsFull` 那份清单**没有 API**,端上取不到)。
|
||||
* ⇒ 这里给几个预置名当快捷方式 + 一个可自己填的输入框;
|
||||
* 编辑老数据时若分类不在预置里,也要照原样显示出来(不能一保存就被清空)。
|
||||
*/
|
||||
|
||||
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
|
||||
import { FbSheetClose } from './FbSheetClose';
|
||||
import { FbSheetRing } from './FbSheetRing';
|
||||
import { FbDateTimeRow, FbDateTimePicker, fbToday } from './FbDateTimeField';
|
||||
import { FbPersonPickSheet } from './FbPersonPickSheet';
|
||||
import { FbRosterPerson } from '../data/FbPersonRepo';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { createDay, updateDay, deleteDay, FbDayDraft } from '../data/FbDayRepo';
|
||||
import { FbApiResult } from '../data/FbHttp';
|
||||
|
||||
/** 重复方式的键(与服务端 `Anniversary::REPEAT` 一字不差) */
|
||||
const REPEAT_KEYS: string[] = ['solar', 'lunar', 'once'];
|
||||
const REPEAT_LABELS: string[] = ['每年(阳历)', '每年(农历)', '只此一次'];
|
||||
|
||||
/** 预置分类(服务端 `PRESET_KINDS` 的同一份;用户还能自己填) */
|
||||
const PRESET_KINDS: string[] = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
|
||||
|
||||
@ComponentV2
|
||||
export struct FbDayEditSheet {
|
||||
/** 模块主题色(纪念日 = 陶土橙) */
|
||||
@Param accent: string = '#B4633C';
|
||||
/** 正在编辑的纪念日 id(0 = 新建) */
|
||||
@Param editingId: number = 0;
|
||||
@Param editingTitle: string = '';
|
||||
@Param editingKind: string = '';
|
||||
@Param editingNote: string = '';
|
||||
/** solar / lunar / once */
|
||||
@Param editingRepeat: string = 'solar';
|
||||
@Param editingMonth: number = 1;
|
||||
@Param editingDay: number = 1;
|
||||
@Param editingPersonIds: number[] = [];
|
||||
/** 名册(选关联人用) */
|
||||
@Param roster: FbRosterPerson[] = [];
|
||||
|
||||
@Event onClose: () => void = (): void => {};
|
||||
@Event onSaved: () => void = (): void => {};
|
||||
@Event onDeleted: () => void = (): void => {};
|
||||
|
||||
@Local title: string = '';
|
||||
@Local kind: string = '';
|
||||
@Local note: string = '';
|
||||
@Local repeat: string = 'solar';
|
||||
@Local month: number = 1;
|
||||
@Local day: number = 1;
|
||||
/** 日期控件的载体(`YYYY-MM-DD`;年份无意义,只借它拿月日) */
|
||||
@Local pickDate: string = '';
|
||||
@Local pickOpen: boolean = false;
|
||||
@Local personIds: number[] = [];
|
||||
@Local personPickOpen: boolean = false;
|
||||
@Local saving: boolean = false;
|
||||
@Local confirmingDelete: boolean = false;
|
||||
@Local note2: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.title = this.editingTitle;
|
||||
this.kind = this.editingKind;
|
||||
this.note = this.editingNote;
|
||||
this.repeat = this.editingRepeat !== '' ? this.editingRepeat : 'solar';
|
||||
this.month = this.editingMonth > 0 ? this.editingMonth : 1;
|
||||
this.day = this.editingDay > 0 ? this.editingDay : 1;
|
||||
this.personIds = this.editingPersonIds.slice();
|
||||
/* 载体日期:用**今年**的这一天。年份只为了拼出一个合法 `YYYY-MM-DD`
|
||||
给日期控件用,提交时只取月日(见文件头)。 */
|
||||
const y: string = fbToday().substring(0, 4);
|
||||
this.pickDate = y + '-' + this.pad2(this.month) + '-' + this.pad2(this.day);
|
||||
}
|
||||
|
||||
private pad2(v: number): string {
|
||||
return v < 10 ? ('0' + v.toString()) : v.toString();
|
||||
}
|
||||
|
||||
/** 下标 → 重复方式中文名(越界给「每年(阳历)」,不抛错) */
|
||||
private repeatLabel(idx: number): string {
|
||||
return idx >= 0 && idx < REPEAT_LABELS.length ? REPEAT_LABELS[idx] : REPEAT_LABELS[0];
|
||||
}
|
||||
|
||||
/** 已选的关联人显示名(「全家的事」= 一个都没选) */
|
||||
private peopleText(): string {
|
||||
if (this.personIds.length === 0) {
|
||||
return '全家的事';
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const id of this.personIds) {
|
||||
const p: FbRosterPerson | undefined =
|
||||
this.roster.find((x: FbRosterPerson): boolean => x.id === id);
|
||||
if (p !== undefined) {
|
||||
names.push(p.shown !== '' ? p.shown : p.name);
|
||||
}
|
||||
}
|
||||
return names.length > 0 ? names.join('、') : ('已选 ' + this.personIds.length.toString() + ' 人');
|
||||
}
|
||||
|
||||
/** 分类清单:预置 + 当前值(老数据里的自定义分类不能丢) */
|
||||
private kinds(): string[] {
|
||||
const out: string[] = [];
|
||||
for (const k of PRESET_KINDS) {
|
||||
out.push(k);
|
||||
}
|
||||
if (this.kind !== '' && out.indexOf(this.kind) < 0) {
|
||||
out.push(this.kind);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
build() {
|
||||
/* 🔴🔴 根必须 `Stack`:遮罩与卡片要"叠",不能当 `Column` 子项"排"
|
||||
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
|
||||
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(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.flexShrink(1)
|
||||
|
||||
FbSheetClose({
|
||||
onClose: (): void => {
|
||||
this.onClose();
|
||||
},
|
||||
color: this.accent
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// ---- 名称 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('叫什么')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(44)
|
||||
TextInput({ text: this.title, 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 => {
|
||||
this.title = v;
|
||||
this.note2 = '';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// ---- 重复方式 ----
|
||||
Column({ space: 6 }) {
|
||||
Text('按什么重复')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
Row({ space: 8 }) {
|
||||
ForEach(REPEAT_KEYS, (k: string, idx: number) => {
|
||||
Text(this.repeatLabel(idx))
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.repeat === k ? '#FFFFFF' : FbColors.muted)
|
||||
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
|
||||
.backgroundColor(this.repeat === k ? this.accent : FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.onClick((): void => {
|
||||
this.repeat = k;
|
||||
})
|
||||
}, (k: string): string => 'rp_' + k)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// ---- 日期(月 + 日)----
|
||||
FbDateTimeRow({
|
||||
label: '日期',
|
||||
date: this.pickDate,
|
||||
allDay: true,
|
||||
accent: this.accent,
|
||||
onTap: (): void => {
|
||||
this.pickOpen = true;
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 分类 ----
|
||||
Column({ space: 6 }) {
|
||||
Text('分类')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
Scroll() {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(this.kinds(), (k: string) => {
|
||||
Text(k)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.kind === k ? '#FFFFFF' : FbColors.muted)
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.kind === k ? this.accent : FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.onClick((): void => {
|
||||
// 再点一下 = 取消分类(不分类是合法状态)
|
||||
this.kind = this.kind === k ? '' : k;
|
||||
})
|
||||
}, (k: string): string => 'kd_' + k)
|
||||
}
|
||||
.padding({ right: 4 })
|
||||
}
|
||||
.scrollable(ScrollDirection.Horizontal)
|
||||
.scrollBar(BarState.Off)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// ---- 关联人 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('关联人')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(44)
|
||||
Text(this.peopleText())
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(this.personIds.length > 0 ? FbColors.ink : FbColors.muted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
.height(42)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.onClick((): void => {
|
||||
this.personPickOpen = true;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// ---- 备注 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('备注')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(44)
|
||||
TextArea({ text: this.note, placeholder: '想记的细节(可空)' })
|
||||
.placeholderColor(FbColors.faint)
|
||||
.placeholderFont({ size: FbMetric.fontTiny })
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(72)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.onChange((v: string): void => {
|
||||
this.note = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.constraintSize({ maxHeight: 400 })
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.TopStart)
|
||||
|
||||
if (this.note2 !== '') {
|
||||
Text(this.note2)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.red)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
|
||||
// ---- 底部:删除(左)+ 保存 ----
|
||||
Row({ space: 10 }) {
|
||||
if (this.editingId > 0) {
|
||||
Text(this.confirmingDelete ? '真的删掉?' : '删除')
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.height(44)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
/* 🔴 v61:删除按钮统一红底白字。红值取自 color.json 的 `red` */
|
||||
.backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86))
|
||||
.onClick((): void => {
|
||||
if (this.confirmingDelete) {
|
||||
this.doDelete();
|
||||
} else {
|
||||
this.confirmingDelete = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下来'))
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(44)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
|
||||
.onClick((): void => {
|
||||
this.save();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
|
||||
// ---- 日期选择(只取月日)----
|
||||
if (this.pickOpen) {
|
||||
FbDateTimePicker({
|
||||
title: '几月几号',
|
||||
date: this.pickDate,
|
||||
allDay: true,
|
||||
accent: this.accent,
|
||||
onConfirm: (d: string, t: string): void => {
|
||||
/* ⚠️ 只留月/日:服务端存的就是月日(见文件头)。
|
||||
`d` 是 `YYYY-MM-DD`,第 6-7 位是月、9-10 位是日。 */
|
||||
if (d.length >= 10) {
|
||||
this.month = parseInt(d.substring(5, 7), 10);
|
||||
this.day = parseInt(d.substring(8, 10), 10);
|
||||
this.pickDate = d;
|
||||
}
|
||||
this.pickOpen = false;
|
||||
},
|
||||
onCancel: (): void => {
|
||||
this.pickOpen = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 选关联人(复用观影那套"搜索 + 暂存 + 确定")----
|
||||
if (this.personPickOpen) {
|
||||
FbPersonPickSheet({
|
||||
people: this.roster,
|
||||
selected: this.personIds,
|
||||
title: '这条纪念日和谁有关',
|
||||
accent: this.accent,
|
||||
onConfirm: (ids: number[]): void => {
|
||||
this.personIds = ids;
|
||||
this.personPickOpen = false;
|
||||
},
|
||||
onClose: (): void => {
|
||||
this.personPickOpen = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
const t: string = this.title.trim();
|
||||
if (t === '') {
|
||||
this.note2 = '纪念日得有个名字(如「结婚纪念日」)。';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note2 = '';
|
||||
const d: FbDayDraft = {
|
||||
title: t,
|
||||
repeatType: this.repeat,
|
||||
month: this.month,
|
||||
day: this.day,
|
||||
kind: this.kind,
|
||||
note: this.note.trim(),
|
||||
personIds: this.personIds
|
||||
};
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
const res: FbApiResult = this.editingId > 0
|
||||
? await updateDay(token, this.editingId, d)
|
||||
: await createDay(token, d);
|
||||
this.saving = false;
|
||||
if (!res.ok) {
|
||||
this.note2 = res.error !== '' ? res.error : '保存失败,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onSaved();
|
||||
}
|
||||
|
||||
private async doDelete(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note2 = '';
|
||||
const res: FbApiResult = await deleteDay(FB_TOKEN_STORE.get(), this.editingId);
|
||||
this.saving = false;
|
||||
this.confirmingDelete = false;
|
||||
if (!res.ok) {
|
||||
this.note2 = res.error !== '' ? res.error : '删除失败,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onDeleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 「记一笔」浮层(鸿蒙端)
|
||||
*
|
||||
* 对应网页版 `templates/ledger/` 的记一笔表单。
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「还有这个财务的显示也有问题。」
|
||||
* ⇒ 端上「记一笔」按钮原来**没有 `onTap`** —— 点了什么都不发生。
|
||||
*
|
||||
* ⚠️ 金额输入的是**元**(可带两位小数),服务端 `Ledger::toCents()` 转分。
|
||||
* 端上**不做**元→分换算(双重换算会差 100 倍)。
|
||||
*
|
||||
* ⚠️ 类别键必须与 `Ledger::KINDS` 一字不差 —— 写错了服务端会落到 `other`,
|
||||
* 而且**负债**(贷款/信用卡/欠款)落到资产会让净资产算错。所以这里照抄那张表。
|
||||
*/
|
||||
|
||||
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 { addLedgerEntry } from '../data/FbLedgerRepo';
|
||||
import { FbApiResult } from '../data/FbHttp';
|
||||
|
||||
/** 类别键(顺序与 `Ledger::KINDS` 一致) */
|
||||
const KIND_KEYS: string[] = ['deposit', 'fund', 'stock', 'insurance', 'house', 'car',
|
||||
'cash', 'loan', 'credit', 'owe', 'other'];
|
||||
const KIND_NAMES: string[] = ['存款', '基金', '股票', '保险', '房子', '车子',
|
||||
'现金', '贷款', '信用卡', '欠款', '其它'];
|
||||
/** 哪些是负债(`Ledger::isDebt` 同一份判断) */
|
||||
const DEBT_KEYS: string[] = ['loan', 'credit', 'owe'];
|
||||
|
||||
@ComponentV2
|
||||
export struct FbLedgerEntrySheet {
|
||||
/** 模块主题色(财务 = 橄榄绿) */
|
||||
@Param accent: string = '#5E7A3C';
|
||||
/** 记到哪本账 */
|
||||
@Param ledgerId: number = 0;
|
||||
@Param ledgerName: string = '';
|
||||
|
||||
@Event onClose: () => void = (): void => {};
|
||||
@Event onSaved: () => void = (): void => {};
|
||||
|
||||
@Local kind: string = 'deposit';
|
||||
@Local amount: string = '';
|
||||
@Local title: string = '';
|
||||
@Local note: string = '';
|
||||
@Local saving: boolean = false;
|
||||
@Local err: string = '';
|
||||
|
||||
private kindNameOf(k: string): string {
|
||||
const i: number = KIND_KEYS.indexOf(k);
|
||||
return i >= 0 ? KIND_NAMES[i] : '其它';
|
||||
}
|
||||
|
||||
/** 下标 → 类别中文名(越界给「其它」,不抛错) */
|
||||
private kindLabel(idx: number): string {
|
||||
return idx >= 0 && idx < KIND_NAMES.length ? KIND_NAMES[idx] : '其它';
|
||||
}
|
||||
|
||||
private isDebt(): boolean {
|
||||
return DEBT_KEYS.indexOf(this.kind) >= 0;
|
||||
}
|
||||
|
||||
build() {
|
||||
/* 🔴🔴 根必须 `Stack`:遮罩与卡片要"叠",不能当 `Column` 子项"排"
|
||||
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
|
||||
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('记一笔')
|
||||
.fontSize(FbMetric.fontTitle)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
.maxLines(1)
|
||||
Text(this.ledgerName !== '' ? ('记到「' + this.ledgerName + '」') : '记到当前账本')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.accent)
|
||||
.maxLines(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.flexShrink(1)
|
||||
|
||||
FbSheetClose({
|
||||
onClose: (): void => {
|
||||
this.onClose();
|
||||
},
|
||||
color: this.accent
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// ---- 类别 ----
|
||||
Column({ space: 6 }) {
|
||||
Text('类别')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
Scroll() {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(KIND_KEYS, (k: string, idx: number) => {
|
||||
Text(this.kindLabel(idx))
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.kind === k ? '#FFFFFF' : FbColors.muted)
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.kind === k ? this.accent : FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.onClick((): void => {
|
||||
this.kind = k;
|
||||
})
|
||||
}, (k: string): string => 'lk_' + k)
|
||||
}
|
||||
.padding({ right: 4 })
|
||||
}
|
||||
.scrollable(ScrollDirection.Horizontal)
|
||||
.scrollBar(BarState.Off)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// ---- 金额 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('金额')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextInput({ text: this.amount, placeholder: '如 12500 或 860.50' })
|
||||
.type(InputType.NUMBER_DECIMAL)
|
||||
.placeholderColor(FbColors.faint)
|
||||
.placeholderFont({ size: FbMetric.fontTiny })
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(44)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 10, right: 10 })
|
||||
.onChange((v: string): void => {
|
||||
this.amount = v;
|
||||
this.err = '';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// 负债提示:用户容易把欠款填成正数又不知道对不对
|
||||
if (this.isDebt()) {
|
||||
Text('「' + this.kindNameOf(this.kind) + '」算负债 —— 填正数就行,'
|
||||
+ '净资产会自动减去它(服务端按绝对值计入总负债)。')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
|
||||
// ---- 名称 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('名称')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextInput({ text: this.title, 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 => {
|
||||
this.title = v;
|
||||
this.err = '';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// ---- 备注 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('备注')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextArea({ text: this.note, placeholder: '想记的细节(可空)' })
|
||||
.placeholderColor(FbColors.faint)
|
||||
.placeholderFont({ size: FbMetric.fontTiny })
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(72)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.onChange((v: string): void => {
|
||||
this.note = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
|
||||
Text('已有账目的金额修改 / 删除还在网页端 —— 接口只开放了"记一笔"。')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.constraintSize({ maxHeight: 400 })
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.TopStart)
|
||||
|
||||
if (this.err !== '') {
|
||||
Text(this.err)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.red)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
|
||||
Text(this.saving ? '记入中…' : '记下来')
|
||||
.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({ left: 18, right: 18, top: 18, bottom: 18 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
const amt: string = this.amount.trim();
|
||||
if (amt === '') {
|
||||
this.err = '金额得填一个数。';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.err = '';
|
||||
const res: FbApiResult = await addLedgerEntry(FB_TOKEN_STORE.get(), this.ledgerId,
|
||||
this.kind, amt, this.title.trim(), this.note.trim());
|
||||
this.saving = false;
|
||||
if (!res.ok) {
|
||||
this.err = res.error !== '' ? res.error : '没记上,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onSaved();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 笔记浮层(新建 / 看 / 改 / 删)(鸿蒙端)
|
||||
*
|
||||
* 对应网页版 `templates/notes/`(网页是"卡片 + 详情页",端上合成一个浮层:
|
||||
* 卡片是一段短文,为它单开一屏不值当)。
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」
|
||||
* ⇒ 端上既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、卡片无 `onClick`。
|
||||
*
|
||||
* ⚠️ 端上只支持**纯文本**(服务端 `body` → 一个 `text` 块):
|
||||
* 网页那边可以图文混排(`note_blocks` 有 image 块),端上先不做 ——
|
||||
* 图片块的内容是附件 key,硬拼进文本框会变成一串乱码。
|
||||
* 所以已有笔记里的图片块在端上显示成「[图片]」(见 `parseNotes` 的 `noteBodyOf`)。
|
||||
*
|
||||
* ⚠️ **只读**:别人共享给我的笔记能看不能改(服务端 `Note::canWrite` 那一套)。
|
||||
* 端上按 `mine` 判断 —— 保守一点宁可少给编辑,也不能给个点了报 403 的按钮。
|
||||
*/
|
||||
|
||||
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 { createNote, updateNote, deleteNote } from '../data/FbNoteRepo';
|
||||
import { FbApiResult } from '../data/FbHttp';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbNoteSheet {
|
||||
/** 模块主题色(笔记 = 褐灰) */
|
||||
@Param accent: string = '#7A6A4F';
|
||||
/** 正在看/改的笔记 id(0 = 新建) */
|
||||
@Param editingId: number = 0;
|
||||
@Param editingTitle: string = '';
|
||||
@Param editingBody: string = '';
|
||||
@Param editingTags: string = '';
|
||||
/** 是不是我写的(不是就只能看) */
|
||||
@Param mine: boolean = true;
|
||||
|
||||
@Event onClose: () => void = (): void => {};
|
||||
@Event onSaved: () => void = (): void => {};
|
||||
@Event onDeleted: () => void = (): void => {};
|
||||
|
||||
@Local title: string = '';
|
||||
@Local body: string = '';
|
||||
/** 标签输入(用空格或逗号分隔,服务端会自己归一) */
|
||||
@Local tags: string = '';
|
||||
@Local saving: boolean = false;
|
||||
@Local confirmingDelete: boolean = false;
|
||||
@Local note: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.title = this.editingTitle;
|
||||
this.body = this.editingBody;
|
||||
this.tags = this.editingTags;
|
||||
}
|
||||
|
||||
/** 能不能改:新建时当然能;已有笔记只有自己写的能改 */
|
||||
private editOk(): boolean {
|
||||
return this.editingId <= 0 || this.mine;
|
||||
}
|
||||
|
||||
/** 标签串 → 数组(逗号 / 顿号 / 空格 / # 都当分隔) */
|
||||
private tagList(): string[] {
|
||||
const norm: string = this.tags.replace(new RegExp('#', 'g'), ' ')
|
||||
.replace(new RegExp(',', 'g'), ' ')
|
||||
.replace(new RegExp(',', 'g'), ' ')
|
||||
.replace(new RegExp('、', 'g'), ' ');
|
||||
const parts: string[] = norm.split(' ');
|
||||
const out: string[] = [];
|
||||
for (const p of parts) {
|
||||
const t: string = p.trim();
|
||||
if (t !== '' && out.indexOf(t) < 0) {
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
build() {
|
||||
/* 🔴🔴 根必须 `Stack`:遮罩与卡片要"叠",不能当 `Column` 子项"排"
|
||||
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
|
||||
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 ? (this.mine ? '笔记' : '别人共享的笔记') : '记一条')
|
||||
.fontSize(FbMetric.fontTitle)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
.maxLines(1)
|
||||
Text(this.editOk() ? '随手记点什么,按标签归类和检索' : '只读:只能看,改要作者来')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.accent)
|
||||
.maxLines(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.flexShrink(1)
|
||||
|
||||
FbSheetClose({
|
||||
onClose: (): void => {
|
||||
this.onClose();
|
||||
},
|
||||
color: this.accent
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// ---- 标题 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('标题')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextInput({ text: this.title, 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 })
|
||||
.enabled(this.editOk())
|
||||
.onChange((v: string): void => {
|
||||
this.title = v;
|
||||
this.note = '';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// ---- 正文 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('正文')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextArea({ text: this.body, placeholder: '想记的都写在这儿' })
|
||||
.placeholderColor(FbColors.faint)
|
||||
.placeholderFont({ size: FbMetric.fontTiny })
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(200)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.enabled(this.editOk())
|
||||
.onChange((v: string): void => {
|
||||
this.body = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
|
||||
// ---- 标签 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('标签')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextInput({ text: this.tags, 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 })
|
||||
.enabled(this.editOk())
|
||||
.onChange((v: string): void => {
|
||||
this.tags = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// 图片块在端上只能显示成「[图片]」—— 说清楚,别让人以为内容丢了
|
||||
if (this.editingId > 0 && this.body.indexOf('[图片]') >= 0) {
|
||||
Text('这条笔记里的图片在网页端看;App 这边先显示成「[图片]」。')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width('100%')
|
||||
.maxLines(2)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.constraintSize({ maxHeight: 420 })
|
||||
.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({ space: 10 }) {
|
||||
if (this.editingId > 0 && this.mine) {
|
||||
Text(this.confirmingDelete ? '真的删掉?' : '删除')
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.height(44)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
/* 🔴 v61:删除按钮统一红底白字。红值取自 color.json 的 `red` */
|
||||
.backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86))
|
||||
.onClick((): void => {
|
||||
if (this.confirmingDelete) {
|
||||
this.doDelete();
|
||||
} else {
|
||||
this.confirmingDelete = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
if (this.editOk()) {
|
||||
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下来'))
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(44)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
|
||||
.onClick((): void => {
|
||||
this.save();
|
||||
})
|
||||
} else {
|
||||
Text('知道了')
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(44)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.backgroundColor(fbAlpha(this.accent, 0.9))
|
||||
.onClick((): void => {
|
||||
this.onClose();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
const t: string = this.title.trim();
|
||||
const b: string = this.body.trim();
|
||||
if (t === '' && b === '') {
|
||||
this.note = '标题和正文至少写一样(服务端不允许存一条空笔记)。';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note = '';
|
||||
/* ⚠️ 标题空着也能存(服务端允许纯正文笔记),但列表卡片上没标题会很难认
|
||||
⇒ 用正文首行兜个标题(只影响显示,不改变用户输入)。 */
|
||||
const useTitle: string = t !== '' ? t : b.substring(0, 20);
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
const res: FbApiResult = this.editingId > 0
|
||||
? await updateNote(token, this.editingId, useTitle, b, this.tagList())
|
||||
: await createNote(token, useTitle, b, this.tagList());
|
||||
this.saving = false;
|
||||
if (!res.ok) {
|
||||
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onSaved();
|
||||
}
|
||||
|
||||
private async doDelete(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note = '';
|
||||
const res: FbApiResult = await deleteNote(FB_TOKEN_STORE.get(), this.editingId);
|
||||
this.saving = false;
|
||||
this.confirmingDelete = false;
|
||||
if (!res.ok) {
|
||||
this.note = res.error !== '' ? res.error : '删除失败,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onDeleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 资源浮层(新建 / 查看 / 编辑 / 删除)(鸿蒙端)
|
||||
*
|
||||
* 对应网页版 `templates/resources/index.php` 里那个 `#resAddCard` 弹框
|
||||
* (网页 2026-10-11 起「新建」与「编辑」**复用同一个弹框**,这里是同一套思路:
|
||||
* 一个组件、`editingId` 为 0 就是新建)。
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「资源也是啊,新增加资源,资源也点不进去。」
|
||||
* ⇒ 端上原来既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、
|
||||
* 列表项无 `onClick`。这个浮层把两件事一次补齐。
|
||||
*
|
||||
* ⚠️⚠️ 服务端只有 title / content(详见 `data/FbResourceRepo` 文件头):
|
||||
* 归属人固定是当前登录的人、附件与共享**没有 API**。
|
||||
* ⇒ 这里**故意不摆**「选归属人」「传附件」「共享给谁」这些入口 ——
|
||||
* 摆了也存不进去,用户会以为存上了。
|
||||
*/
|
||||
|
||||
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 { createResource, updateResource, deleteResource } from '../data/FbResourceRepo';
|
||||
import { FbApiResult } from '../data/FbHttp';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbResourceSheet {
|
||||
/** 模块主题色(资源 = 靛蓝) */
|
||||
@Param accent: string = '#5566A8';
|
||||
/** 正在看/改的资源 id(0 = 新建) */
|
||||
@Param editingId: number = 0;
|
||||
@Param editingTitle: string = '';
|
||||
@Param editingContent: string = '';
|
||||
/** 归属人显示名(新建时为空) */
|
||||
@Param ownerName: string = '';
|
||||
/** 我能不能改(`perm` 是 owner / write)—— 只读共享进来的不给改不给删 */
|
||||
@Param canWrite: boolean = true;
|
||||
|
||||
@Event onClose: () => void = (): void => {};
|
||||
@Event onSaved: () => void = (): void => {};
|
||||
@Event onDeleted: () => void = (): void => {};
|
||||
|
||||
@Local title: string = '';
|
||||
@Local content: string = '';
|
||||
@Local saving: boolean = false;
|
||||
/** 删除的二次确认(网页也是先点一次再确认,避免误删证件) */
|
||||
@Local confirmingDelete: boolean = false;
|
||||
@Local note: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.title = this.editingTitle;
|
||||
this.content = this.editingContent;
|
||||
}
|
||||
|
||||
build() {
|
||||
/* 🔴🔴 根必须 `Stack`:全屏遮罩不能当 `Column` 的子项"排"
|
||||
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
|
||||
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(this.editingId > 0 ? ('归属 ' + this.ownerName) : '挂在你自己名下')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.accent)
|
||||
.maxLines(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.flexShrink(1)
|
||||
|
||||
FbSheetClose({
|
||||
onClose: (): void => {
|
||||
this.onClose();
|
||||
},
|
||||
color: this.accent
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// ---- 标题 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('标题')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextInput({ text: this.title, 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 })
|
||||
.enabled(this.editOk())
|
||||
.onChange((v: string): void => {
|
||||
this.title = v;
|
||||
this.note = '';
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// ---- 正文 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('正文')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(34)
|
||||
TextArea({ text: this.content, placeholder: '编号、有效期、放在哪里…' })
|
||||
.placeholderColor(FbColors.faint)
|
||||
.placeholderFont({ size: FbMetric.fontTiny })
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(120)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.enabled(this.editOk())
|
||||
.onChange((v: string): void => {
|
||||
this.content = v;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
|
||||
// 只读共享进来的:说明白,别让人以为"输入框坏了"
|
||||
if (!this.canWrite) {
|
||||
Text('这条是共享给你的,只有只读权限 —— 要改请让归属人在他那边改。')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
|
||||
// 附件 / 共享:服务端没有接口,照实说明(不摆假入口)
|
||||
if (this.editingId > 0) {
|
||||
Text('附件与共享还在网页端「资源」里管 —— App 这边暂未开放。')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.constraintSize({ maxHeight: 380 })
|
||||
.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({ space: 10 }) {
|
||||
if (this.editingId > 0 && this.canWrite) {
|
||||
Text(this.confirmingDelete ? '真的删掉?' : '删除')
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.height(44)
|
||||
.padding({ left: 16, right: 16 })
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
/* 🔴 v61:删除按钮统一红底白字。
|
||||
⚠️ 红色这里写 hex 而不用 `FbColors.red`:那是 `Resource`
|
||||
(`$r('app.color.red')`),而 `fbAlpha()` 要 hex 字符串。
|
||||
值取自 `resources/base/element/color.json` 的 `red`,两处必须一致。 */
|
||||
.backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86))
|
||||
.onClick((): void => {
|
||||
if (this.confirmingDelete) {
|
||||
this.doDelete();
|
||||
} else {
|
||||
this.confirmingDelete = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
if (this.canWrite) {
|
||||
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '存下来'))
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(44)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
|
||||
.onClick((): void => {
|
||||
this.save();
|
||||
})
|
||||
} else {
|
||||
// 只读时给一个"看清楚了"的出口(没有保存,也不能什么都不给)
|
||||
Text('知道了')
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(44)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.backgroundColor(fbAlpha(this.accent, 0.9))
|
||||
.onClick((): void => {
|
||||
this.onClose();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
/** 能否编辑(只读共享进来的锁输入框,但内容照样能看) */
|
||||
private editOk(): boolean {
|
||||
return this.canWrite;
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
const t: string = this.title.trim();
|
||||
if (t === '') {
|
||||
this.note = '资源得有个标题(如「身份证」)。';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note = '';
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
const res: FbApiResult = this.editingId > 0
|
||||
? await updateResource(token, this.editingId, t, this.content)
|
||||
: await createResource(token, t, this.content);
|
||||
this.saving = false;
|
||||
if (!res.ok) {
|
||||
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onSaved();
|
||||
}
|
||||
|
||||
private async doDelete(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note = '';
|
||||
const res: FbApiResult = await deleteResource(FB_TOKEN_STORE.get(), this.editingId);
|
||||
this.saving = false;
|
||||
this.confirmingDelete = false;
|
||||
if (!res.ok) {
|
||||
this.note = res.error !== '' ? res.error : '删除失败,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onDeleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 「新建 / 编辑任务」浮层(鸿蒙端)
|
||||
*
|
||||
* 对应网页版 `templates/tasks/_edit_form.php`。
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「新建任务、设置这些东西都这都没有。」
|
||||
* 端上「新建任务」按钮原来**没有 `onTap`**,列表项也没有 `onClick`。
|
||||
*
|
||||
* ⚠️ 编辑态**不显示状态选择**:状态只走 `/tasks/<id>/status` 子动作
|
||||
* (服务端 `PATCH` 会忽略请求里的 status,见 `FbTaskRepo` 文件头)。
|
||||
* 在这里放个状态选择器 → 用户改完保存 → 状态没变 ⇒ 典型的"看起来成功了"。
|
||||
* 改成完成请用列表上的那个圈(见 `FbTasksPage` 的 `toggleDone`)。
|
||||
*/
|
||||
|
||||
import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint } from '../common/Theme';
|
||||
import { FbBoardOpt } from '../model/Types';
|
||||
import { FbSheetClose } from './FbSheetClose';
|
||||
import { FbSheetRing } from './FbSheetRing';
|
||||
import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { createTask, updateTask, FbTaskDraft } from '../data/FbTaskRepo';
|
||||
import { FbApiResult } from '../data/FbHttp';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbTaskEditSheet {
|
||||
/** 模块主题色(任务 = 赭红) */
|
||||
@Param accent: string = '#A8574F';
|
||||
/** 可选的看板(父页面从已加载任务里反推出来) */
|
||||
@Param boards: FbBoardOpt[] = [];
|
||||
/** 编辑的这条(0 = 新建) */
|
||||
@Param editingId: number = 0;
|
||||
@Param editingTitle: string = '';
|
||||
@Param editingNote: string = '';
|
||||
@Param editingDueAt: string = '';
|
||||
@Param editingBoardId: number = 0;
|
||||
|
||||
@Event onClose: () => void = (): void => {};
|
||||
@Event onSaved: () => void = (): void => {};
|
||||
|
||||
@Local title: string = '';
|
||||
@Local note: string = '';
|
||||
@Local boardId: number = 0;
|
||||
/** 截止日期 `YYYY-MM-DD`,空 = 没有期限 */
|
||||
@Local dueDate: string = '';
|
||||
/** 截止时分 */
|
||||
@Local dueTime: string = '18:00';
|
||||
@Local pickerOpen: boolean = false;
|
||||
@Local saving: boolean = false;
|
||||
@Local note2: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.title = this.editingTitle;
|
||||
this.note = this.editingNote;
|
||||
this.boardId = this.editingBoardId;
|
||||
// `2026-10-25 18:00` → 日期 + 时分(服务端给的是空格分隔)
|
||||
const raw: string = this.editingDueAt.trim();
|
||||
if (raw.length >= 16) {
|
||||
this.dueDate = raw.substring(0, 10);
|
||||
this.dueTime = raw.substring(11, 16);
|
||||
} else if (raw.length >= 10) {
|
||||
this.dueDate = raw.substring(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
/* 🔴🔴 根必须 `Stack`(见 pitfalls.md:全屏遮罩不能当 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(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.flexShrink(1)
|
||||
|
||||
FbSheetClose({
|
||||
onClose: (): void => {
|
||||
this.onClose();
|
||||
},
|
||||
color: this.accent
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// ---- 标题 ----
|
||||
this.textField('做什么', '如「交电费」「给爸配眼镜」', this.title,
|
||||
(v: string): void => {
|
||||
this.title = v;
|
||||
this.note2 = '';
|
||||
})
|
||||
|
||||
// ---- 备注 ----
|
||||
this.areaField('备注', '想补充的细节(可空)', this.note,
|
||||
(v: string): void => {
|
||||
this.note = v;
|
||||
})
|
||||
|
||||
// ---- 截止时间 ----
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text('期限')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(28)
|
||||
if (this.dueDate !== '') {
|
||||
Text('不设期限')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.onClick((): void => {
|
||||
this.dueDate = '';
|
||||
})
|
||||
} else {
|
||||
Column().layoutWeight(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
if (this.dueDate === '') {
|
||||
Row({ space: 6 }) {
|
||||
Image($r('app.media.fb_ic_plus'))
|
||||
.width(12)
|
||||
.height(12)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.colorFilter(fbTint(this.accent))
|
||||
Text('设一个期限')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.accent)
|
||||
}
|
||||
.padding({ left: 10, right: 12, top: 7, bottom: 7 })
|
||||
.backgroundColor(fbAlpha(this.accent, 0.08))
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick((): void => {
|
||||
this.dueDate = fbIsoOf(new Date());
|
||||
this.pickerOpen = true;
|
||||
})
|
||||
} else {
|
||||
FbDateTimeRow({
|
||||
label: '截止',
|
||||
date: this.dueDate,
|
||||
time: this.dueTime,
|
||||
accent: this.accent,
|
||||
onTap: (): void => {
|
||||
this.pickerOpen = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// ---- 看板 ----
|
||||
Column({ space: 6 }) {
|
||||
Text('看板')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
Scroll() {
|
||||
Row({ space: 8 }) {
|
||||
this.boardChip(0, '不指定', '#A8574F')
|
||||
ForEach(this.boards, (b: FbBoardOpt) => {
|
||||
this.boardChip(b.id, b.name, b.color)
|
||||
}, (b: FbBoardOpt): string => 'tb_' + b.id.toString())
|
||||
}
|
||||
.padding({ right: 4 })
|
||||
}
|
||||
.scrollable(ScrollDirection.Horizontal)
|
||||
.scrollBar(BarState.Off)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.constraintSize({ maxHeight: 360 })
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.TopStart)
|
||||
|
||||
if (this.note2 !== '') {
|
||||
Text(this.note2)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.red)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
|
||||
// ---- 保存 ----
|
||||
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({ left: 18, right: 18, top: 18, bottom: 18 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
|
||||
// ---- 日期时间选择 ----
|
||||
if (this.pickerOpen) {
|
||||
FbDateTimePicker({
|
||||
title: '这个任务什么时候到期',
|
||||
date: this.dueDate !== '' ? this.dueDate : fbIsoOf(new Date()),
|
||||
time: this.dueTime,
|
||||
accent: this.accent,
|
||||
onConfirm: (d: string, t: string): void => {
|
||||
this.dueDate = d;
|
||||
this.dueTime = t;
|
||||
this.pickerOpen = false;
|
||||
},
|
||||
onCancel: (): void => {
|
||||
this.pickerOpen = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
const title: string = this.title.trim();
|
||||
if (title === '') {
|
||||
this.note2 = '任务得写清楚要做什么。';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note2 = '';
|
||||
const d: FbTaskDraft = {
|
||||
title: title,
|
||||
note: this.note.trim(),
|
||||
// 没有期限就发空串(服务端 `Api::dt('due_at', '')` → null)
|
||||
dueAt: this.dueDate !== '' ? fbJoinDateTime(this.dueDate, this.dueTime) : '',
|
||||
boardId: this.boardId,
|
||||
status: 'todo'
|
||||
};
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
const res: FbApiResult = this.editingId > 0
|
||||
? await updateTask(token, this.editingId, d)
|
||||
: await createTask(token, d);
|
||||
this.saving = false;
|
||||
if (!res.ok) {
|
||||
this.note2 = res.error !== '' ? res.error : '保存失败,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onSaved();
|
||||
}
|
||||
|
||||
/** 看板单选胶囊 */
|
||||
@Builder
|
||||
boardChip(id: number, name: string, color: string) {
|
||||
Text(name)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.boardId === id ? '#FFFFFF' : FbColors.muted)
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.boardId === id ? (id === 0 ? this.accent : color) : FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.onClick((): void => {
|
||||
this.boardId = id;
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
||||
Row({ space: 8 }) {
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(44)
|
||||
|
||||
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(44)
|
||||
|
||||
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(80)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.onChange((v: string): void => {
|
||||
onChange(v);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 家书 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 「加一段行程」浮层(鸿蒙端)
|
||||
*
|
||||
* 对应网页版 `templates/trips/node_form.php` 的**核心几项**。
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「现在这个旅程下面也没有这个行程啊,这些都需要增加。」
|
||||
* 端上原先连"行程"都拉不到(`nodes` 恒空),更没有地方能加一段。
|
||||
*
|
||||
* ⚠️ 节点类型(`node_type`)与出行方式(`travel_mode`)的枚举是**服务端常量**
|
||||
* (`Trip::NODE_TYPES` / `Trip::TRAVEL_MODES`),这里照抄一份。
|
||||
* 为什么不从接口取:它们是"物理事实"(走路/火车/飞机不会变),
|
||||
* 服务端注释里也写明这一点;为它多发一次请求不值得。
|
||||
* 但**改枚举时要两边一起改** —— 这个文件里有一条注释提醒。
|
||||
*
|
||||
* ⚠️ 表格里**没有**「出发城市/到达城市」:服务端 `nodeData()` 虽然接受
|
||||
* `from_city`/`to_city`,但网页版的新建表单里这两项是可选的补充,
|
||||
* 端上第一批先只做主干(标题/地点/时间/类型/方式),
|
||||
* 城市仍能通过「地点」体现(列表里会用 place 显示)。
|
||||
*/
|
||||
|
||||
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
|
||||
import { FbSheetClose } from './FbSheetClose';
|
||||
import { FbSheetRing } from './FbSheetRing';
|
||||
import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { addTripNode, FbTripNodeDraft } from '../data/FbTripRepo';
|
||||
import { FbApiResult } from '../data/FbHttp';
|
||||
|
||||
/** 节点类型(⚠️ 与 `app/Trip.php` 的 `NODE_TYPES` 保持一致) */
|
||||
interface FbNodeTypeOpt {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: FbNodeTypeOpt[] = [
|
||||
{ key: 'play', label: '游玩', icon: '🎡' },
|
||||
{ key: 'meal', label: '用餐', icon: '🍜' },
|
||||
{ key: 'move', label: '赶路', icon: '🚗' },
|
||||
{ key: 'stay', label: '住宿', icon: '🏨' },
|
||||
{ key: 'other', label: '其他', icon: '📌' }
|
||||
];
|
||||
|
||||
/** 出行方式(⚠️ 与 `app/Trip.php` 的 `TRAVEL_MODES` 保持一致) */
|
||||
const TRAVEL_MODES: FbNodeTypeOpt[] = [
|
||||
{ key: '', label: '不填', icon: '—' },
|
||||
{ key: 'walk', label: '步行', icon: '🚶' },
|
||||
{ key: 'bike', label: '骑行', icon: '🚲' },
|
||||
{ key: 'drive', label: '自驾', icon: '🚗' },
|
||||
{ key: 'taxi', label: '打车', icon: '🚕' },
|
||||
{ key: 'bus', label: '公交', icon: '🚌' },
|
||||
{ key: 'subway', label: '地铁', icon: '🚇' },
|
||||
{ key: 'train', label: '火车', icon: '🚄' },
|
||||
{ key: 'plane', label: '飞机', icon: '✈️' },
|
||||
{ key: 'ship', label: '轮船', icon: '🚢' },
|
||||
{ key: 'other', label: '其他', icon: '🧭' }
|
||||
];
|
||||
|
||||
@ComponentV2
|
||||
export struct FbTripNodeSheet {
|
||||
/** 所属旅程 id */
|
||||
@Param tripId: number = 0;
|
||||
/** 模块主题色 */
|
||||
@Param accent: string = '#3F7D5A';
|
||||
|
||||
@Event onClose: () => void = (): void => {};
|
||||
/** 加成功后父页面重新拉节点 */
|
||||
@Event onSaved: () => void = (): void => {};
|
||||
|
||||
@Local title: string = '';
|
||||
@Local place: string = '';
|
||||
@Local nodeType: string = 'play';
|
||||
@Local travelMode: string = '';
|
||||
/** 'plan' 计划 / 'actual' 实际 */
|
||||
@Local category: string = 'plan';
|
||||
/** 日期 `YYYY-MM-DD` */
|
||||
@Local date: string = '';
|
||||
/** 时分 `HH:mm` */
|
||||
@Local time: string = '09:00';
|
||||
@Local pickerOpen: boolean = false;
|
||||
@Local saving: boolean = false;
|
||||
@Local note: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 默认今天(多数场景是"记刚刚发生的"或"排今天剩下的")
|
||||
this.date = fbIsoOf(new Date());
|
||||
}
|
||||
|
||||
build() {
|
||||
/* 🔴🔴 根必须 `Stack`(见 `pitfalls.md`:全屏遮罩不能当 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('加一段行程')
|
||||
.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)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// ---- 标题 ----
|
||||
this.textField('这一段', '如「出发」「抵达民宿」', this.title,
|
||||
(v: string): void => {
|
||||
this.title = v;
|
||||
this.note = '';
|
||||
})
|
||||
|
||||
// ---- 地点 ----
|
||||
this.textField('地点', '如「青岛北站」「崂山区·沙子口」', this.place,
|
||||
(v: string): void => {
|
||||
this.place = v;
|
||||
})
|
||||
|
||||
// ---- 时间(复用日程/观影那套日期时间控件)----
|
||||
FbDateTimeRow({
|
||||
label: '时间',
|
||||
date: this.date,
|
||||
time: this.time,
|
||||
accent: this.accent,
|
||||
onTap: (): void => {
|
||||
this.pickerOpen = true;
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 类型(单选 chips)----
|
||||
this.chipRow('类型', NODE_TYPES, this.nodeType, (k: string): void => {
|
||||
this.nodeType = k;
|
||||
})
|
||||
|
||||
// ---- 出行方式(横向滚动 chips,10 项一屏放不下)----
|
||||
Column({ space: 6 }) {
|
||||
Text('出行方式')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
Scroll() {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(TRAVEL_MODES, (m: FbNodeTypeOpt) => {
|
||||
this.chip(m, this.travelMode === m.key, (): void => {
|
||||
this.travelMode = m.key;
|
||||
})
|
||||
}, (m: FbNodeTypeOpt): string => 'tm_' + m.key)
|
||||
}
|
||||
.padding({ right: 4 })
|
||||
}
|
||||
.scrollable(ScrollDirection.Horizontal)
|
||||
.scrollBar(BarState.Off)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// ---- 计划 / 实际 ----
|
||||
this.chipRow('阶段', [
|
||||
{ key: 'plan', label: '计划', icon: '🗓' },
|
||||
{ key: 'actual', label: '实际', icon: '✅' }
|
||||
], this.category, (k: string): void => {
|
||||
this.category = k;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.constraintSize({ maxHeight: 380 })
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.TopStart)
|
||||
|
||||
// ---- 错误 ----
|
||||
if (this.note !== '') {
|
||||
Text(this.note)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.red)
|
||||
.width('100%')
|
||||
.maxLines(3)
|
||||
}
|
||||
|
||||
// ---- 保存 ----
|
||||
Text(this.saving ? '保存中…' : '加到行程里')
|
||||
.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({ left: 18, right: 18, top: 18, bottom: 18 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
.width('92%')
|
||||
|
||||
/* ---- 日期时间选择(叠在最上层;`FbDateTimePicker` 自身也是全屏 Stack,
|
||||
嵌在这里正好盖住整个浮层)---- */
|
||||
if (this.pickerOpen) {
|
||||
FbDateTimePicker({
|
||||
title: '这一段的时间',
|
||||
date: this.date,
|
||||
time: this.time,
|
||||
accent: this.accent,
|
||||
onConfirm: (d: string, t: string): void => {
|
||||
this.date = d;
|
||||
this.time = t;
|
||||
this.pickerOpen = false;
|
||||
},
|
||||
onCancel: (): void => {
|
||||
this.pickerOpen = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
const title: string = this.title.trim();
|
||||
if (title === '') {
|
||||
this.note = '这一段得有个名字(比如「出发」)。';
|
||||
return;
|
||||
}
|
||||
if (this.tripId <= 0) {
|
||||
this.note = '没找到这趟旅程,请返回重进。';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.note = '';
|
||||
const d: FbTripNodeDraft = {
|
||||
title: title,
|
||||
place: this.place.trim(),
|
||||
// 服务端的 `Api::dt()` 吃 `YYYY-MM-DD HH:MM`(秒由服务端补)
|
||||
startsAt: fbJoinDateTime(this.date, this.time),
|
||||
endsAt: '',
|
||||
nodeType: this.nodeType,
|
||||
travelMode: this.travelMode,
|
||||
category: this.category,
|
||||
detail: ''
|
||||
};
|
||||
const res: FbApiResult = await addTripNode(FB_TOKEN_STORE.get(), this.tripId, d);
|
||||
this.saving = false;
|
||||
if (!res.ok) {
|
||||
this.note = res.error !== '' ? res.error : '没加上,请稍后再试。';
|
||||
return;
|
||||
}
|
||||
this.onSaved();
|
||||
}
|
||||
|
||||
/** 一组单选 chips(带标签) */
|
||||
@Builder
|
||||
chipRow(label: string, opts: FbNodeTypeOpt[], cur: string, onPick: (k: string) => void) {
|
||||
Column({ space: 6 }) {
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
Row({ space: 8 }) {
|
||||
ForEach(opts, (o: FbNodeTypeOpt) => {
|
||||
this.chip(o, cur === o.key, (): void => {
|
||||
onPick(o.key);
|
||||
})
|
||||
}, (o: FbNodeTypeOpt): string => 'cr_' + label + '_' + o.key)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/** 单个 chip */
|
||||
@Builder
|
||||
chip(o: FbNodeTypeOpt, on: boolean, onTap: () => void) {
|
||||
Text(o.icon + ' ' + o.label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(on ? '#FFFFFF' : FbColors.muted)
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.backgroundColor(on ? this.accent : FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.onClick((): void => {
|
||||
onTap();
|
||||
})
|
||||
}
|
||||
|
||||
/** 单行输入 */
|
||||
@Builder
|
||||
textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
||||
Row({ space: 8 }) {
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(44)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user