Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbDayEditSheet.ets
T
laoyang b3e1330244 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` 为空),真机尚未验收。
2026-10-20 15:27:32 +08:00

454 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 「新建 / 编辑纪念日」浮层(鸿蒙端)
*
* 对应网页版 `templates/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();
}
}