用户 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` 为空),真机尚未验收。
454 lines
17 KiB
Plaintext
454 lines
17 KiB
Plaintext
/**
|
||
* 家书 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();
|
||
}
|
||
}
|