From b3e1330244bd529e64b339cd3865db9592fe4030 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 20 Oct 2026 15:27:32 +0800 Subject: [PATCH] =?UTF-8?q?v65/v66=EF=BC=9A=E5=85=AD=E4=B8=AA=E7=A9=BA?= =?UTF-8?q?=E5=A3=B3=E6=A8=A1=E5=9D=97=E6=8E=A5=E7=BA=BF=20+=20=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E6=9C=8D=E5=8A=A1=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-20: 「做几个和 syncCalendar 差不多的卡片。另外其他的功能也逐步完成……旅程的 新建旅程啊,车辆管理、出行方式、节点设置……资源也点不进去……任务也点不 进去,也没法完成……纪念日、新增纪念日……财务的显示也有问题……笔记现在 也点不进去,记笔记也记不了。」 v65 —— 六个模块(任务 / 资源 / 纪念日 / 笔记 / 账本 / 旅程)原本是**同一套 空壳模板**复制出来的:`FbPrimaryButton` 没有 `onTap`、`ListItem` 没有 `onClick`、设置菜单的 `action` 全是空串。这一版把它们全部接上真实接口: · 任务:新建 / 编辑(`FbTaskEditSheet` + `FbTaskRepo`)、列表可点、勾完成 (走 `/tasks//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` 为空),真机尚未验收。 --- .../main/ets/components/FbDayEditSheet.ets | 453 ++++++++++++++++++ .../ets/components/FbLedgerEntrySheet.ets | 283 +++++++++++ entry/src/main/ets/components/FbNoteSheet.ets | 326 +++++++++++++ .../main/ets/components/FbResourceSheet.ets | 292 +++++++++++ .../main/ets/components/FbTaskEditSheet.ets | 356 ++++++++++++++ .../main/ets/components/FbTripNewSheet.ets | 265 ++++++++++ .../main/ets/components/FbTripNodeSheet.ets | 348 ++++++++++++++ entry/src/main/ets/data/FbCardData.ets | 245 ++++++++++ entry/src/main/ets/data/FbDayRepo.ets | 71 +++ entry/src/main/ets/data/FbLedgerRepo.ets | 35 ++ entry/src/main/ets/data/FbNoteRepo.ets | 53 ++ entry/src/main/ets/data/FbParse.ets | 78 ++- entry/src/main/ets/data/FbResourceRepo.ets | 50 ++ entry/src/main/ets/data/FbTaskRepo.ets | 76 +++ entry/src/main/ets/data/FbTripRepo.ets | 228 +++++++++ .../main/ets/entryability/EntryAbility.ets | 57 +++ .../ets/entryformability/FbFormAbility.ets | 92 ++++ entry/src/main/ets/model/DemoData.ets | 136 +++++- entry/src/main/ets/model/FbSettingsMenus.ets | 29 +- entry/src/main/ets/model/Types.ets | 95 +++- entry/src/main/ets/pages/card/FbCard2x2.ets | 157 ++++++ entry/src/main/ets/pages/card/FbCard2x4.ets | 162 +++++++ entry/src/main/ets/pages/card/FbCard4x4.ets | 235 +++++++++ entry/src/main/ets/pages/days/FbDaysPage.ets | 324 ++++++++++++- .../main/ets/pages/ledger/FbLedgerPage.ets | 147 ++++-- entry/src/main/ets/pages/misc/FbMiscPages.ets | 151 ++++-- .../ets/pages/resources/FbResourcesPage.ets | 164 +++++-- .../src/main/ets/pages/tasks/FbTasksPage.ets | 391 ++++++++++++++- .../src/main/ets/pages/trips/FbTripsPage.ets | 405 ++++++++++++++-- entry/src/main/module.json5 | 18 + .../main/resources/base/element/string.json | 22 +- .../resources/base/profile/form_config.json | 55 +++ 32 files changed, 5572 insertions(+), 227 deletions(-) create mode 100644 entry/src/main/ets/components/FbDayEditSheet.ets create mode 100644 entry/src/main/ets/components/FbLedgerEntrySheet.ets create mode 100644 entry/src/main/ets/components/FbNoteSheet.ets create mode 100644 entry/src/main/ets/components/FbResourceSheet.ets create mode 100644 entry/src/main/ets/components/FbTaskEditSheet.ets create mode 100644 entry/src/main/ets/components/FbTripNewSheet.ets create mode 100644 entry/src/main/ets/components/FbTripNodeSheet.ets create mode 100644 entry/src/main/ets/data/FbCardData.ets create mode 100644 entry/src/main/ets/data/FbDayRepo.ets create mode 100644 entry/src/main/ets/data/FbLedgerRepo.ets create mode 100644 entry/src/main/ets/data/FbNoteRepo.ets create mode 100644 entry/src/main/ets/data/FbResourceRepo.ets create mode 100644 entry/src/main/ets/data/FbTaskRepo.ets create mode 100644 entry/src/main/ets/data/FbTripRepo.ets create mode 100644 entry/src/main/ets/entryformability/FbFormAbility.ets create mode 100644 entry/src/main/ets/pages/card/FbCard2x2.ets create mode 100644 entry/src/main/ets/pages/card/FbCard2x4.ets create mode 100644 entry/src/main/ets/pages/card/FbCard4x4.ets create mode 100644 entry/src/main/resources/base/profile/form_config.json diff --git a/entry/src/main/ets/components/FbDayEditSheet.ets b/entry/src/main/ets/components/FbDayEditSheet.ets new file mode 100644 index 0000000..650ade2 --- /dev/null +++ b/entry/src/main/ets/components/FbDayEditSheet.ets @@ -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 { + 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 { + 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(); + } +} diff --git a/entry/src/main/ets/components/FbLedgerEntrySheet.ets b/entry/src/main/ets/components/FbLedgerEntrySheet.ets new file mode 100644 index 0000000..b216d08 --- /dev/null +++ b/entry/src/main/ets/components/FbLedgerEntrySheet.ets @@ -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 { + 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(); + } +} diff --git a/entry/src/main/ets/components/FbNoteSheet.ets b/entry/src/main/ets/components/FbNoteSheet.ets new file mode 100644 index 0000000..15dcde7 --- /dev/null +++ b/entry/src/main/ets/components/FbNoteSheet.ets @@ -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 { + 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 { + 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(); + } +} diff --git a/entry/src/main/ets/components/FbResourceSheet.ets b/entry/src/main/ets/components/FbResourceSheet.ets new file mode 100644 index 0000000..9e6c022 --- /dev/null +++ b/entry/src/main/ets/components/FbResourceSheet.ets @@ -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 { + 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 { + 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(); + } +} diff --git a/entry/src/main/ets/components/FbTaskEditSheet.ets b/entry/src/main/ets/components/FbTaskEditSheet.ets new file mode 100644 index 0000000..84e2e16 --- /dev/null +++ b/entry/src/main/ets/components/FbTaskEditSheet.ets @@ -0,0 +1,356 @@ +/** + * 家书 FamilyBook · 「新建 / 编辑任务」浮层(鸿蒙端) + * + * 对应网页版 `templates/tasks/_edit_form.php`。 + * + * 🔴 用户 2026-10-20:「新建任务、设置这些东西都这都没有。」 + * 端上「新建任务」按钮原来**没有 `onTap`**,列表项也没有 `onClick`。 + * + * ⚠️ 编辑态**不显示状态选择**:状态只走 `/tasks//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 { + 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) + } +} diff --git a/entry/src/main/ets/components/FbTripNewSheet.ets b/entry/src/main/ets/components/FbTripNewSheet.ets new file mode 100644 index 0000000..db924ae --- /dev/null +++ b/entry/src/main/ets/components/FbTripNewSheet.ets @@ -0,0 +1,265 @@ +/** + * 家书 FamilyBook · 「新建旅程」浮层(鸿蒙端) + * + * 对应网页版 `templates/trips/form.php`(新建那一路)。 + * + * 🔴 为什么单独做这一批:端上原来的「新建旅程」按钮**根本没接 `onTap`** + * (`FbTripsPage` 里 `FbPrimaryButton({ label: '新建旅程', ... })` 没有回调), + * 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。 + * + * ⚠️ 表单只放**网页版真有的三项**: + * 标题 / 简介 / 是否公开。**没有起止时间、没有城市** —— + * 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生** + * (见 `Trip::derived()`)。加个"时间"输入框只会让人以为填了有用。 + * + * ⚠️ 题图这一批**不做**:网页版走 `cover` 文件上传 + `cover_crop` 裁剪, + * 而服务端目前只有观影的 `/movies//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 { + 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) + } +} diff --git a/entry/src/main/ets/components/FbTripNodeSheet.ets b/entry/src/main/ets/components/FbTripNodeSheet.ets new file mode 100644 index 0000000..21d05b6 --- /dev/null +++ b/entry/src/main/ets/components/FbTripNodeSheet.ets @@ -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 { + 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) + } +} diff --git a/entry/src/main/ets/data/FbCardData.ets b/entry/src/main/ets/data/FbCardData.ets new file mode 100644 index 0000000..99f4150 --- /dev/null +++ b/entry/src/main/ets/data/FbCardData.ets @@ -0,0 +1,245 @@ +/** + * 家书 FamilyBook · 桌面服务卡片数据(鸿蒙端) + * + * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * ⇒ 参照 `SyncCalendar` 的 `CardDataService`:卡片**不直连数据库**, + * 而是把"卡片要显示的那几十个字段"先算成一份**扁平的**数据对象 + * (每个字段都是 string / number),再由 `formBindingData` 推给卡片渲染。 + * + * 为什么不让卡片自己去请求接口: + * ① 卡片的 ArkTS 是**受限子集**(不能随便 await、不能拿 Context 去读 preferences), + * 它只认 `LocalStorage` 里那几个标量; + * ② 逻辑放在这里 ⇒ 2×2 / 2×4 / 4×4 三种尺寸共用**同一份计算**, + * 不会出现"大卡说 5 条日程、小卡说 3 条"这种自相矛盾。 + * + * 🔴 数据来源只用 **`GET /api/v1/home` 一个接口**: + * 它一次给齐 日期/星期/农历/日子标记/今日日程/统计/最近纪念日, + * 与网页版首页**同源**(服务端 `home()` 的注释写明了这一点)。 + * 多打两个接口(calendar + days)只会让卡片刷新变慢、且口径可能与首页不一致。 + * + * ⚠️ 令牌与服务器地址: + * 卡片进程(FormExtensionAbility)与主应用**共享同一个沙箱**, + * 所以直接复用 `FbTokenStore` / `FbServerStore` 的 preferences 即可, + * **不要在卡片里另存一份**(那样用户在应用里"退出登录",卡片还带着老令牌)。 + */ + +import { common } from '@kit.AbilityKit'; +import { preferences } from '@kit.ArkData'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { FB_SERVER_STORE } from './FbServerStore'; +import { FB_TOKEN_STORE } from './FbTokenStore'; +import { fbApi, FbApiResult } from './FbHttp'; +import { parseHome } from './FbParse'; +import { FbCalEvent, FbHomeData, FbMark, FbRecentDay } from '../model/Types'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbCard'; + +/** 卡片上的一条日程(扁平化:卡片只认标量) */ +export class FbCardEvent { + /** ForEach 的 key(**必须唯一**,否则同名的两条只渲染第一条) */ + key: string = ''; + /** `09:00` / `全天` */ + time: string = ''; + title: string = ''; + /** 日历本色标(直接当左侧色条用) */ + color: string = '#A84A28'; +} + +/** 卡片上的一条纪念日倒计时 */ +export class FbCardDay { + key: string = ''; + title: string = ''; + /** 还有几天(0 = 就是今天) */ + days: number = 0; + /** 7 天内 ⇒ 卡片上用品牌色强调 */ + soon: boolean = false; +} + +/** + * 卡片整体数据。 + * + * ⚠️ **字段名就是 `LocalStorage` 的键名**(卡片页 `@LocalStorageProp('xxx')`), + * 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",不会报错。 + */ +export class FbCardData { + /** + * 状态:`ok` 正常 / `login` 未登录 / `noserver` 没填服务器 / + * `offline` 连不上(此时若有缓存则显示缓存) / `loading` 刚添加还没拉到。 + */ + state: string = 'loading'; + /** `10 月 20 日` */ + todayLabel: string = ''; + /** `星期二` */ + weekday: string = ''; + /** `九月初一`(空 = 算不出,卡片不画) */ + lunarShort: string = ''; + /** 日子标记拼成一行:`寒露 · 重阳节` */ + marks: string = ''; + /** 今日日程条数 */ + eventCount: number = 0; + /** 在做 / 待办 / 逾期(三个数字与首页概述卡同源) */ + doing: number = 0; + todo: number = 0; + overdue: number = 0; + /** `FbCardEvent[]` 的 JSON(卡片侧 `JSON.parse` 成数组再 ForEach) */ + eventsJson: string = '[]'; + /** `FbCardDay[]` 的 JSON */ + daysJson: string = '[]'; + /** 一行小字提示(未登录 / 离线 / 出错时给用户一个说法) */ + hint: string = ''; + /** `15:40 更新` */ + updatedAt: string = ''; +} + +/** 两位补零 */ +function pad2(v: number): string { + return v < 10 ? '0' + v.toString() : v.toString(); +} + +/** 当前时刻 `HH:mm`(卡片右上角"更新时间") */ +function clockText(): string { + const d: Date = new Date(); + return pad2(d.getHours()) + ':' + pad2(d.getMinutes()); +} + +/** 把接口载荷铺进卡片数据 */ +function applyHome(home: FbHomeData, out: FbCardData): void { + out.todayLabel = home.todayLabel; + out.weekday = home.weekday; + out.lunarShort = home.lunarShort; + out.eventCount = home.todayEvents.length; + out.doing = home.stat.doing; + out.todo = home.stat.todo; + out.overdue = home.stat.overdue; + // 日子标记:最多两个(卡片上那一行放三个就会折行,反而看不清) + let marks: string = ''; + let n: number = 0; + for (const mk of home.marks as FbMark[]) { + if (n >= 2) { + break; + } + marks += (marks === '' ? '' : ' · ') + mk.word; + n++; + } + out.marks = marks; + // 今日日程:最多 6 条(4×4 也只放得下 5~6 行) + const events: FbCardEvent[] = []; + let idx: number = 0; + for (const e of home.todayEvents as FbCalEvent[]) { + if (idx >= 6) { + break; + } + const it: FbCardEvent = new FbCardEvent(); + it.key = 'e' + idx.toString(); + it.time = e.allDay || e.time === '' ? '全天' : e.time; + it.title = e.title === '' ? '(无标题)' : e.title; + it.color = e.color !== '' ? e.color : '#A84A28'; + events.push(it); + idx++; + } + out.eventsJson = JSON.stringify(events); + // 最近纪念日:最多 3 条(服务端已按"最近的在前"排好,**不要再排**) + const days: FbCardDay[] = []; + let di: number = 0; + for (const rd of home.recentDays as FbRecentDay[]) { + if (di >= 3) { + break; + } + const it: FbCardDay = new FbCardDay(); + it.key = 'd' + di.toString(); + it.title = rd.title; + it.days = rd.days; + it.soon = rd.days <= 7; + days.push(it); + di++; + } + out.daysJson = JSON.stringify(days); +} + +/** 缓存用的 preferences(卡片断网时显示上次成功的数据) */ +const CACHE_STORE: string = 'fb_card'; +const CACHE_KEY: string = 'home_json'; + +async function saveCache(ctx: common.Context, payload: string): Promise { + try { + const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE); + await store.put(CACHE_KEY, payload); + // ⚠️ 必须 flush:卡片进程随时可能被回收,不 flush 等于没写 + await store.flush(); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '卡片缓存写入失败:%{public}s', e.message); + } +} + +async function loadCache(ctx: common.Context): Promise { + try { + const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE); + const v: preferences.ValueType = await store.get(CACHE_KEY, ''); + return typeof v === 'string' ? v : ''; + } catch (err) { + return ''; + } +} + +/** + * 组装一份卡片数据(异步)。 + * + * ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。 + * 所有失败都翻译成 `state` + `hint`,由卡片自己画一句人话。 + */ +export async function fbCardBuild(ctx: common.Context): Promise { + const out: FbCardData = new FbCardData(); + out.updatedAt = clockText(); + try { + await FB_SERVER_STORE.init(ctx); + await FB_TOKEN_STORE.init(ctx); + } catch (err) { + hilog.warn(DOMAIN, TAG, '卡片环境预加载失败:%{public}s', JSON.stringify(err)); + } + if (FB_SERVER_STORE.get() === '') { + out.state = 'noserver'; + out.hint = '先在「家书」里填写服务器地址'; + return out; + } + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + out.state = 'login'; + out.hint = '先在「家书」里登录'; + return out; + } + /* 🔴 `authProbe: true`:卡片是**后台**刷新,401 时**绝不能**触发全局"踢回登录页" + (那个回调会把令牌清掉 —— 用户在应用里用得好好的,桌面卡片一刷新把他踢下线, + 这是最说不清的 bug)。这里自己处理"登录失效"的提示即可。 + ⚠️ 代价:`authProbe` 会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */ + const r: FbApiResult = await fbApi('/api/v1/home', { token: token, authProbe: true }); + if (r.ok) { + applyHome(parseHome(r.data), out); + out.state = 'ok'; + out.hint = ''; + await saveCache(ctx, r.data); + return out; + } + const cached: string = await loadCache(ctx); + if (cached !== '') { + applyHome(parseHome(cached), out); + out.state = 'offline'; + out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据'; + return out; + } + out.state = 'offline'; + out.hint = r.status === 401 ? '登录已失效,请重新登录' + : (r.error !== '' ? r.error : '连不上服务器'); + return out; +} + +/** + * 供卡片页判断"这条日程是不是第一行"之类的**纯函数**已经不需要了 —— + * 卡片直接按数组顺序画。这里只留一个给测试/守卫用的口径函数: + * 卡片上**最多**显示几条日程(大小卡共用同一个上限,避免"两卡数字不一致")。 + */ +export function fbCardMaxEvents(): number { + return 6; +} diff --git a/entry/src/main/ets/data/FbDayRepo.ets b/entry/src/main/ets/data/FbDayRepo.ets new file mode 100644 index 0000000..a031520 --- /dev/null +++ b/entry/src/main/ets/data/FbDayRepo.ets @@ -0,0 +1,71 @@ +/** + * 家书 FamilyBook · 纪念日的**写操作**(鸿蒙端) + * + * 对应服务端 `app/ApiHandler.php` 的 `days()`: + * POST /api/v1/days 新建 + * PATCH /api/v1/days/ 改 + * DELETE /api/v1/days/ 删 + * + * 字段契约(服务端 `annivData()`): + * title / repeat_type(solar|lunar|once)/ month / day / is_leap / kind / note / person_ids[] + * + * 🔴 用户 2026-10-20:「纪念日、新增纪念日、设置相关的功能。」 + * ⇒ 端上「添加」按钮**没有 `onTap`**、列表行**没有 `onClick`**,一件都没接。 + * + * ⚠️⚠️ **存储契约里没有"年"**:纪念日只存 `month` + `day` + * (见 `app/Schema.php` 的 `anniversaries` 表)。所以端上不能给"选个日期"就完事 —— + * 年份会被丢掉。界面上只挑「几月几号」,别让用户以为存了具体某一年。 + * + * ⚠️⚠️ **`person_ids` 必须每次都给**(哪怕是空数组): + * 服务端在请求里**没有** `person_ids` 这个键时会去库里补(v65 修的数据丢失), + * 但"补"是兜底不是契约。端上既然知道当前选的是谁,就照实发,别依赖兜底。 + */ + +import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; + +/** 新建/编辑纪念日提交的字段 */ +export interface FbDayDraft { + title: string; + /** `solar` / `lunar` / `once` */ + repeatType: string; + /** 1-12 */ + month: number; + /** 1-31 */ + day: number; + /** 分类名(自由文本,服务端不校验;空串 = 不分类) */ + kind: string; + note: string; + /** 关联人(0 个 = 全家的事) */ + personIds: number[]; +} + +/** 把 draft 摊成请求体(`arkts-no-untyped-obj-literals` 要求逐键赋值) */ +function bodyOf(d: FbDayDraft): Record { + const body: Record = {}; + body['title'] = d.title; + body['repeat_type'] = d.repeatType; + body['month'] = d.month; + body['day'] = d.day; + body['kind'] = d.kind; + body['note'] = d.note; + body['person_ids'] = d.personIds; + return body; +} + +/** 新建纪念日 */ +export async function createDay(token: string, d: FbDayDraft): Promise { + const opts: FbApiOptions = { method: 'POST', token: token, body: bodyOf(d) }; + return await fbApi('/api/v1/days', opts); +} + +/** 改纪念日 */ +export async function updateDay(token: string, id: number, d: FbDayDraft): Promise { + const opts: FbApiOptions = { method: 'PATCH', token: token, body: bodyOf(d) }; + return await fbApi('/api/v1/days/' + id.toString(), opts); +} + +/** 删纪念日 */ +export async function deleteDay(token: string, id: number): Promise { + const opts: FbApiOptions = { method: 'DELETE', token: token }; + return await fbApi('/api/v1/days/' + id.toString(), opts); +} diff --git a/entry/src/main/ets/data/FbLedgerRepo.ets b/entry/src/main/ets/data/FbLedgerRepo.ets new file mode 100644 index 0000000..72f5365 --- /dev/null +++ b/entry/src/main/ets/data/FbLedgerRepo.ets @@ -0,0 +1,35 @@ +/** + * 家书 FamilyBook · 账本的**写操作**(鸿蒙端) + * + * 对应服务端 `app/ApiHandler.php` 的 `ledger()`: + * POST /api/v1/ledger 新建账本(name) + * POST /api/v1/ledger//entries 记一笔(kind / amount / title / note / person_id) + * DELETE /api/v1/ledger/ 删账本 + * + * 🔴 用户 2026-10-20:「还有这个财务的显示也有问题。」 + * ⇒ 端上「记一笔」按钮**没有 `onTap`**、账本切换**不重拉明细**、 + * 资产/负债是**端上自己按正负号重新加**的(与净资产口径不一致)。见 `FbLedgerPage`。 + * + * ⚠️ **金额传「元」的字符串**:服务端 `Ledger::toCents()` 负责转分 + * (App 与网页表单同一口径)。这里**不要**先转分再传 —— 会双重换算,差 100 倍。 + * + * ⚠️ **没有"改一笔"和"删一笔"的接口**(`Api::` 路由只解析到第 3 段, + * `/ledger//entries/` 的第 4 段会被丢掉)。所以端上只做"记一笔", + * 改了金额请去网页端操作 —— 这一条要显式写在界面上,别让用户以为存不上。 + */ + +import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; + +/** 记一笔 */ +export async function addLedgerEntry(token: string, ledgerId: number, kind: string, + amountYuan: string, title: string, note: string): Promise { + /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须 + `Record = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */ + const body: Record = {}; + body['kind'] = kind; + body['amount'] = amountYuan; + body['title'] = title; + body['note'] = note; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/ledger/' + ledgerId.toString() + '/entries', opts); +} diff --git a/entry/src/main/ets/data/FbNoteRepo.ets b/entry/src/main/ets/data/FbNoteRepo.ets new file mode 100644 index 0000000..9c2dcba --- /dev/null +++ b/entry/src/main/ets/data/FbNoteRepo.ets @@ -0,0 +1,53 @@ +/** + * 家书 FamilyBook · 笔记的**写操作**(鸿蒙端) + * + * 对应服务端 `app/ApiHandler.php` 的 `notes()`: + * POST /api/v1/notes 新建(title / body 或 blocks / tags) + * PATCH /api/v1/notes/ 改 + * DELETE /api/v1/notes/ 删(**只限作者**) + * + * 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」 + * ⇒ 端上「记一条」按钮没有 `onTap`、卡片没有 `onClick`。 + * + * ⚠️⚠️ 正文的键是 **`body`(纯文本)**,不是 `blocks`: + * `Note` 那边真正的存储是 `note_blocks` 若干块,服务端 `notes()` 收到 `body` + * 会包成一个 `text` 块(见 `ApiHandler::noteBlocksIn` 的说明)。 + * v65 之前接口传的是 `body` 但 `Note` 只读 `blocks` —— 正文被静默丢掉; + * **现在两边对齐了**:端上传 `body`,服务端转成块。 + * ⇒ 端上不要自己去拼 `blocks`(那是网页图文混排的形态,手机上先支持纯文本)。 + * + * ⚠️ `PATCH` 时若**不传** `body`/`blocks`,服务端会沿用库里的块(不会清空)。 + * 这里仍然每次都传 `body`:用户看到的就是那个文本框,保存即整段替换。 + */ + +import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; + +/** 新建笔记 */ +export async function createNote(token: string, title: string, body: string, + tags: string[]): Promise { + /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须 + `Record = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */ + const payload: Record = {}; + payload['title'] = title; + payload['body'] = body; + payload['tags'] = tags; + const opts: FbApiOptions = { method: 'POST', token: token, body: payload }; + return await fbApi('/api/v1/notes', opts); +} + +/** 改笔记(标题 / 正文 / 标签) */ +export async function updateNote(token: string, id: number, title: string, body: string, + tags: string[]): Promise { + const payload: Record = {}; + payload['title'] = title; + payload['body'] = body; + payload['tags'] = tags; + const opts: FbApiOptions = { method: 'PATCH', token: token, body: payload }; + return await fbApi('/api/v1/notes/' + id.toString(), opts); +} + +/** 删笔记(服务端只允许作者删) */ +export async function deleteNote(token: string, id: number): Promise { + const opts: FbApiOptions = { method: 'DELETE', token: token }; + return await fbApi('/api/v1/notes/' + id.toString(), opts); +} diff --git a/entry/src/main/ets/data/FbParse.ets b/entry/src/main/ets/data/FbParse.ets index 5ab0403..85aa861 100644 --- a/entry/src/main/ets/data/FbParse.ets +++ b/entry/src/main/ets/data/FbParse.ets @@ -123,6 +123,10 @@ interface RawDay { month?: number; day?: number; is_leap?: number; person_id?: number | null; person_name?: string | null; source?: string; born?: string | null; + /** v65:关联人 id 数组(服务端列表/单条都会带) */ + person_ids?: number[] | null; + /** v65:备注 */ + note?: string | null; } interface RawTask { @@ -201,6 +205,10 @@ interface RawNote { id?: number; title?: string; is_shared?: number; updated_at?: string; author_name?: string | null; avatar?: string | null; avatar_color?: string | null; is_mine?: number; blocks?: RawNoteBlock[]; + /** v65:列表里服务端已经算好的摘要 */ + excerpt?: string | null; + /** v65:标签数组(`Note::attachChildren` 挂上的) */ + tags?: string[] | null; } interface RawNoteBlock { @@ -262,6 +270,12 @@ export function parseTrips(json: string): FbTripItem[] { dateRange: dateRangeText(s(r.starts_at), s(r.ends_at), dayCount), nodeCount: n(r.node_count), doneCount: 0, + /* 题图 / 城市 / 简介 / 同行人数 —— 对齐网页版列表卡(`templates/trips/_list_body.php`)。 + ⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */ + coverKey: s(r.cover_key), + city: s(r.city) !== '' ? s(r.city) : cities.join(' · '), + summary: s(r.summary), + attendeeCount: n(r.attendee_count), }; out.push(it); } @@ -280,6 +294,7 @@ export function parseDays(json: string): FbAnniversaryItem[] { if (n(r.person_id) > 0) { people.push(chip(s(r.person_name), '#A84A28', n(r.person_id))); } + const src: string = s(r.source) !== '' ? s(r.source) : 'custom'; const it: FbAnniversaryItem = { id: n(r.id), title: s(r.title), @@ -288,6 +303,15 @@ export function parseDays(json: string): FbAnniversaryItem[] { kind: kindCn(s(r.kind)), people: people, yearly: s(r.repeat_type) !== 'once', + source: src, + month: m, + day: d, + repeatType: s(r.repeat_type) !== '' ? s(r.repeat_type) : 'solar', + /* ⚠️ 服务端 `kind` 是**自由文本**(用户在设置里能自己加分类名) + ⇒ 这个原始值要留着回填,别只留映射后的类别码。 */ + rawKind: s(r.kind), + note: s(r.note), + personIds: idList(r.person_ids), }; out.push(it); } @@ -339,6 +363,10 @@ function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem assignee: s(t.assignee_name), childDone: n(t.child_done), childTotal: n(t.child_total), + /* v65:新建/编辑/搬看板要用到的三个**原始**字段(原来只派生了显示文案) */ + boardId: n(t.board_id), + note: s(t.note), + dueAt: due, }; return it; } @@ -455,15 +483,18 @@ export function parseResources(json: string): FbResourceItem[] { const rows: RawResource[] = asArray(json); const out: FbResourceItem[] = []; for (const r of rows) { + /* ⚠️ 服务端 `resources` 表**没有 category 列**(见 `app/Schema.php`)—— + 只有 title / content / owner_id。所以端上按「归属人」分组/筛选, + 不按"证件/资料"那种并不存在的类别(v65 之前那两个胶囊永远筛出空列表)。 */ + const perm: string = s(r.perm); const it: FbResourceItem = { id: n(r.id), name: s(r.title), - // 资源没有"类别"字段(服务端的 content 是自由文本), - // ⇒ 用「谁的」来分类更贴近实际用法(Web 端列表也是按归属分组) - kind: 'other', ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name), - fileCount: 0, - shared: s(r.perm) !== 'owner', + ownerId: n(r.owner_id), + perm: perm !== '' ? perm : 'read', + content: s(r.content), + shared: perm !== 'owner', updatedAt: datePart(s(r.updated_at)), }; out.push(it); @@ -502,19 +533,52 @@ export function parseNotes(json: string): FbNoteItem[] { const rows: RawNote[] = arr(root['items'] as RawNote[]); const out: FbNoteItem[] = []; for (const r of rows) { + /* ⚠️ 服务端列表已带 `blocks` / `tags` / `excerpt`(`Note::attachChildren`), + 这里把它们都取出来 —— 原来只拿 blocks 算了个摘要,正文和标签全丢了。 */ + const body: string = noteBodyOf(r.blocks); + const tags: string[] = []; + for (const t of arr(r.tags)) { + tags.push(s(t)); + } + const exc: string = s(r.excerpt); out.push({ id: n(r.id), title: s(r.title), - excerpt: firstTextOf(r.blocks), - tags: [], + excerpt: exc !== '' ? exc : body, + tags: tags, updatedAt: datePart(s(r.updated_at)), shared: n(r.is_shared) !== 0, attachCount: 0, + body: body, + mine: n(r.is_mine) !== 0, }); } return out; } +/** + * `note_blocks` → 一段纯文本。 + * + * ⚠️ 图片块的内容是**附件 key**(不是正文),拼进来会变成一串乱码 key + * ⇒ 记成「[图片]」,用户一眼知道那里有张图。 + */ +function noteBodyOf(blocks: RawNoteBlock[] | undefined): string { + const rows: RawNoteBlock[] = arr(blocks); + const parts: string[] = []; + for (const b of rows) { + const kind: string = s(b.kind); + if (kind === 'image') { + parts.push('[图片]'); + continue; + } + const txt: string = s(b.content).trim(); + if (txt !== '') { + parts.push(txt); + } + } + return parts.join('\n\n'); +} + /** 通知 → FbNotificationItem[] */ export function parseNotifications(json: string): FbNotificationItem[] { const root: Record | null = asObject(json); diff --git a/entry/src/main/ets/data/FbResourceRepo.ets b/entry/src/main/ets/data/FbResourceRepo.ets new file mode 100644 index 0000000..ab58c8b --- /dev/null +++ b/entry/src/main/ets/data/FbResourceRepo.ets @@ -0,0 +1,50 @@ +/** + * 家书 FamilyBook · 资源的**写操作**(鸿蒙端) + * + * 对应服务端 `app/ApiHandler.php` 的 `resources()`: + * POST /api/v1/resources 新建(title / content) + * PATCH /api/v1/resources/ 改(title / content / category) + * DELETE /api/v1/resources/ 删 + * + * 🔴 用户 2026-10-20:「资源也是啊,新增加资源,资源也点不进去。 + * 设置也没有任何的东西。」 + * ⇒ 端上「添加」按钮**没有 `onTap`**、列表项**没有 `onClick`** —— 两件都没接。 + * + * ⚠️⚠️ **服务端只认 title / content 两个字段**(其它字段会被静默忽略): + * · `Resource::create()` 的 INSERT 只有 `title` / `content`; + * · `resources` 表**没有 category 列**(见 `app/Schema.php`), + * 接口里那个 `category` 参数是历史遗留,写下去不会落库; + * · 归属人(`owner_id`)由接口固定成**当前登录的人**,没法从端上指定; + * · **附件与共享没有 API**(`Attachment::list/save` 只有网页在用)。 + * ⇒ 端上就只做「标题 + 正文」,别在界面上摆出家能选归属人/传附件的假入口 —— + * 用户填了、保存了、却没落库,比没做更糟。 + */ + +import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; + +/** 新建资源(归属人固定是自己,见文件头) */ +export async function createResource(token: string, title: string, content: string): Promise { + /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须 + `Record = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */ + const body: Record = {}; + body['title'] = title; + body['content'] = content; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/resources', opts); +} + +/** 改资源(标题 / 正文) */ +export async function updateResource(token: string, id: number, title: string, + content: string): Promise { + const body: Record = {}; + body['title'] = title; + body['content'] = content; + const opts: FbApiOptions = { method: 'PATCH', token: token, body: body }; + return await fbApi('/api/v1/resources/' + id.toString(), opts); +} + +/** 删资源 */ +export async function deleteResource(token: string, id: number): Promise { + const opts: FbApiOptions = { method: 'DELETE', token: token }; + return await fbApi('/api/v1/resources/' + id.toString(), opts); +} diff --git a/entry/src/main/ets/data/FbTaskRepo.ets b/entry/src/main/ets/data/FbTaskRepo.ets new file mode 100644 index 0000000..baeb542 --- /dev/null +++ b/entry/src/main/ets/data/FbTaskRepo.ets @@ -0,0 +1,76 @@ +/** + * 家书 FamilyBook · 任务的**写操作**(鸿蒙端) + * + * 对应服务端 `app/ApiHandler.php` 的 `tasks()`: + * POST /api/v1/tasks 新建(title / note / due_at / board_id / status) + * PATCH /api/v1/tasks/ 改(标题/备注/截止/看板/负责人/分类) + * POST /api/v1/tasks//status 改状态(todo / doing / done) + * DELETE /api/v1/tasks/ 删 + * + * 🔴 用户 2026-10-20:「任务也是一样啊,任务也点不进去,也没法那个完成, + * 新建任务、设置这些东西都这都没有。」 + * ⇒ 端上原来这三件事**一件都没接**(按钮没 onTap、列表项没 onClick)。 + * + * ⚠️ **状态只走 `/status` 子动作**:服务端 `PATCH /tasks/` 里 + * `'status' => (string)$t['status']` 是**原样回填**,显式忽略请求里的 status + * (见 ApiHandler 那行注释「状态只走 /status 子动作」)。 + * 所以端上 toggle 完成必须打 `/status`,打 PATCH 会"看起来成功但状态没变"。 + */ + +import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; + +/** 新建/编辑任务提交的字段 */ +export interface FbTaskDraft { + title: string; + note: string; + /** `YYYY-MM-DD HH:MM`;空串 = 没有期限 */ + dueAt: string; + /** 看板 id(0 = 不归指定看板 → 服务端落到「其他」那一列) */ + boardId: number; + /** todo / doing / done(只在**新建**时生效;改状态走 `setTaskStatus`) */ + status: string; +} + +/** 新建任务 */ +export async function createTask(token: string, d: FbTaskDraft): Promise { + /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须 + `Record = {}` 逐键赋值(见 FbTripRepo 同一说明)。 */ + const body: Record = {}; + body['title'] = d.title; + body['note'] = d.note; + body['due_at'] = d.dueAt; + body['board_id'] = d.boardId; + body['status'] = d.status; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/tasks', opts); +} + +/** + * 改任务(标题 / 备注 / 截止 / 看板)。 + * + * ⚠️ **不带 status**:服务端 PATCH 会忽略它(见文件头说明), + * 带上只会让人以为"改成功了",实际状态没动。 + */ +export async function updateTask(token: string, id: number, d: FbTaskDraft): Promise { + const body: Record = {}; + body['title'] = d.title; + body['note'] = d.note; + body['due_at'] = d.dueAt; + body['board_id'] = d.boardId; + const opts: FbApiOptions = { method: 'PATCH', token: token, body: body }; + return await fbApi('/api/v1/tasks/' + id.toString(), opts); +} + +/** 改状态(勾完成 / 取消完成 / 标记进行中) */ +export async function setTaskStatus(token: string, id: number, status: string): Promise { + const body: Record = {}; + body['status'] = status; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/tasks/' + id.toString() + '/status', opts); +} + +/** 删任务 */ +export async function deleteTask(token: string, id: number): Promise { + const opts: FbApiOptions = { method: 'DELETE', token: token }; + return await fbApi('/api/v1/tasks/' + id.toString(), opts); +} diff --git a/entry/src/main/ets/data/FbTripRepo.ets b/entry/src/main/ets/data/FbTripRepo.ets new file mode 100644 index 0000000..6b5ce95 --- /dev/null +++ b/entry/src/main/ets/data/FbTripRepo.ets @@ -0,0 +1,228 @@ +/** + * 家书 FamilyBook · 旅程的**写操作**(鸿蒙端) + * + * 与 `FbMovieRepo` 的分工不同:那边要管本地库事务(观影数据量大、要离线看), + * 这边**直接走 API**、成功后让页面重新 `loadModule()` 拉一遍。 + * 为什么这样更合适: + * · 旅程的量级小(几十趟),重新拉一次的代价可以忽略; + * · 本地库这边只有一份"列表缓存",没有节点表 —— 自己维护一套写库 + * 容易和同步下来的数据打架(本地改了、下次同步又被覆盖)。 + * + * 服务端契约(`app/ApiHandler.php` 的 `trips()`,与网页端同一套): + * POST /api/v1/trips 新建(title / note / summary / is_public) + * PATCH /api/v1/trips/ 改 + * DELETE /api/v1/trips/ 删 + * GET /api/v1/trips//nodes 行程节点 + * POST /api/v1/trips//nodes 加一段行程 + * + * ⚠️ **城市与起止时间不在这里传**:服务端 `Trip::create()` 显式忽略它们, + * 一律从行程节点的 `from_city` / `to_city` / `starts_at` **派生** + * (见 `Trip::derived()`)。端上跟着这个口径走 —— 新建旅程只填 + * 名字 / 简介 / 是否公开,城市与时间等加了行程自然就有了。 + */ + +import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; +import { FbTripNode } from '../model/Types'; + +/** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */ +export interface FbTripDraft { + title: string; + /** 简介(网页版是 textarea#t-sum,列表卡截 80 字显示) */ + summary: string; + /** 是否公开:不公开的旅程**不出现在列表里**(服务端按 is_public=1 过滤) */ + isPublic: boolean; +} + +/** 新增一段行程时提交的字段(与网页版 node_form.php 的核心几项对齐) */ +export interface FbTripNodeDraft { + title: string; + place: string; + /** ISO 本地时间串(`2026-10-20T09:30`),空串 = 不填 */ + startsAt: string; + endsAt: string; + /** 节点类型 key(见 `Trip::typeWord`;默认 other) */ + nodeType: string; + /** 交通方式 key(见 `Trip::TRAVEL_MODES`;空串 = 未填) */ + travelMode: string; + /** 'plan' 计划 / 'actual' 实际 */ + category: string; + detail: string; +} + +/** + * 新建旅程。 + * + * @returns 成功时 `ok=true`,`data` 是新旅程的 JSON + */ +export async function createTrip(token: string, d: FbTripDraft): Promise { + /* ⚠️ 请求体必须用 `Record = {}` **逐键赋值**,不能写字面量 —— + ArkTS 的 `arkts-no-untyped-obj-literals` 只认显式 class/interface, + `Record<…>` 不算(与 `FbMovieRepo.saveMovie` 同一写法)。 */ + const body: Record = {}; + body['title'] = d.title; + body['summary'] = d.summary; + // ⚠️ 布尔直接发:服务端 `Api::bool('is_public')` 认 true/false。 + body['is_public'] = d.isPublic; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/trips', opts); +} + +/** 改旅程(标题 / 简介 / 是否公开) */ +export async function updateTrip(token: string, id: number, d: FbTripDraft): Promise { + const body: Record = {}; + body['title'] = d.title; + body['summary'] = d.summary; + body['is_public'] = d.isPublic; + const opts: FbApiOptions = { method: 'PATCH', token: token, body: body }; + return await fbApi('/api/v1/trips/' + id.toString(), opts); +} + +/** 删除旅程(连带它的行程节点由服务端处理) */ +export async function deleteTrip(token: string, id: number): Promise { + const opts: FbApiOptions = { method: 'DELETE', token: token }; + return await fbApi('/api/v1/trips/' + id.toString(), opts); +} + +/** + * 拉某趟旅程的**行程节点**。 + * + * 🔴 这是端上原先最大的缺口:`FbTripsPage.nodes` 一直是空数组, + * 所以每趟旅程展开后永远显示"这个旅程还没有排行程"—— + * 用户看到的"旅程下面没有行程"就是这一条。 + * + * 服务端返回的每条节点字段比 `FbTripNode` 多(含 detail / from_city / + * to_city / travel_mode / members…),这里只取列表要显示的几项。 + */ +export async function fetchTripNodes(token: string, tripId: number): Promise { + const opts: FbApiOptions = { method: 'GET', token: token }; + const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts); + if (!res.ok) { + return []; + } + return parseNodes(res.data); +} + +/** 解析节点数组(容错:字段缺失给空串,不让一条脏数据把整趟旅程弄没) */ +export function parseNodes(json: string): FbTripNode[] { + if (json === '') { + return []; + } + let raw: Object; + try { + raw = JSON.parse(json) as Object; + } catch (err) { + return []; + } + if (!Array.isArray(raw)) { + return []; + } + const rows: Object[] = raw as Object[]; + const out: FbTripNode[] = []; + for (const r of rows) { + const o: Record = r as Record; + out.push({ + id: numOf(o, 'id'), + title: strOf(o, 'title'), + when: spanOf(o), + place: placeOf(o), + // ⚠️ 服务端节点的主字段是 `category`(plan/actual), + // 而 `stage` 只在某些查询里出现 ⇒ 两个都看,别只看一个。 + stage: strOf(o, 'stage') !== '' ? strOf(o, 'stage') : strOf(o, 'category'), + status: strOf(o, 'status'), + mode: strOf(o, 'travel_mode'), + expenseCent: numOf(o, 'expense_cent') + }); + } + return out; +} + +/** 加一段行程 */ +export async function addTripNode(token: string, tripId: number, d: FbTripNodeDraft): Promise { + /* 同上:逐键赋值(ArkTS 不允许 `Record` 的字面量初始化) */ + const body: Record = {}; + body['title'] = d.title; + body['place'] = d.place; + body['detail'] = d.detail; + body['starts_at'] = d.startsAt; + body['ends_at'] = d.endsAt; + body['node_type'] = d.nodeType; + body['travel_mode'] = d.travelMode; + body['category'] = d.category; + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts); +} + +/** + * 删一段行程。 + * + * ⚠️ 走 **query** 传节点 id,不走 `/trips/1/nodes/5`: + * API 路由只解析到「资源/ id / 子动作」三段(见 `app/Api.php` 的 + * `$sub` 取值),第 4 段会被**丢掉** ⇒ 写成路径形式服务端收不到。 + */ +export async function deleteTripNode(token: string, tripId: number, nodeId: number): Promise { + const query: Record = {}; + query['node_id'] = nodeId.toString(); + const opts: FbApiOptions = { method: 'DELETE', token: token, query: query }; + return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts); +} + +// ============================================================ +// 解析小工具(ArkTS 要求显式类型,别用 any) +// ============================================================ + +function strOf(o: Record, k: string): string { + const v: Object | undefined = o[k]; + if (typeof v === 'string') { + return v; + } + if (typeof v === 'number') { + return v.toString(); + } + return ''; +} + +function numOf(o: Record, k: string): number { + const v: Object | undefined = o[k]; + if (typeof v === 'number') { + return v; + } + if (typeof v === 'string') { + const n: number = Number.parseFloat(v); + return Number.isNaN(n) ? 0 : n; + } + return 0; +} + +/** 节点时间文案:`starts_at` 有就用它,否则用 `ends_at`;都没有给空串 */ +function spanOf(o: Record): string { + const a: string = strOf(o, 'starts_at') !== '' ? strOf(o, 'starts_at') : strOf(o, 'ends_at'); + if (a === '') { + return ''; + } + return cnShort(a); +} + +/** `2026-10-20T09:30:00` → `10-20 09:30`(列表里一行够用) */ +function cnShort(iso: string): string { + const t: string = iso.replace('T', ' '); + if (t.length < 16) { + return t; + } + const md: string = t.substring(5, 10); + const hm: string = t.substring(11, 16); + return md + ' ' + hm; +} + +/** 地点:优先 `place`,没有就拼「出发地 → 到达地」 */ +function placeOf(o: Record): string { + const p: string = strOf(o, 'place'); + if (p !== '') { + return p; + } + const from: string = strOf(o, 'from_city'); + const to: string = strOf(o, 'to_city'); + if (from !== '' && to !== '') { + return from + ' → ' + to; + } + return from !== '' ? from : to; +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index f56c58b..3df25d0 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -12,6 +12,8 @@ import { FbDb } from '../data/FbDb'; import { FB_MODULE_TOKEN } from '../data/FbModuleRepo'; import { FB_KEY_STORE } from '../data/FbKeyStore'; import { FB_ATTACH } from '../data/FbAttachQueue'; +import { FB_MODULES } from '../model/Modules'; +import { fbAppState } from '../common/Store'; const DOMAIN: number = 0x0000; @@ -28,6 +30,9 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onCreate'); + // 🔴 v66:先摆正"从哪进来"的模块(桌面卡片可能直接指向日程/任务), + // 再去做一堆异步预加载 —— 模块名是同步的,不依赖下面任何东西。 + this.applyWantModule(want); // 🔴 预加载 API 令牌进内存。 // 页面里需要**同步**判断"有没有登录"(决定要不要发起同步), // 所以必须在页面出现之前把令牌读进内存(见 FbTokenStore 的说明)。 @@ -100,6 +105,58 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onDestroy'); } + /** + * 🔴 v66(用户 2026-10-20「做几个和 syncCalendar 差不多的卡片」): + * **从桌面卡片点进来**时,直接落到对应模块。 + * + * 卡片上的 `postCardAction({ action: 'router', params: { fbModule: 'calendar' } })` + * 会把 `fbModule` 合并进启动 Want 的 `parameters`;这里读出来切模块。 + * + * ⚠️ 为什么在 Ability 里切、而不是在 `Index.aboutToAppear` 里切: + * `onCreate` 早于 `onWindowStageCreate → loadContent`,此时改 + * `fbAppState().activeModule`,**首帧**就是目标模块,不会先闪一下首页; + * 若放到页面的 `aboutToAppear`,启动检查(验令牌)是异步的, + * 时机不好掐,容易"先看到首页再跳走"。 + * + * ⚠️ 只认 `FB_MODULES` 里有的 key:卡片参数是**外部输入**, + * 拼错/伪造的 key 直接忽略(切到一个不存在的模块会渲染成空白页)。 + */ + private applyWantModule(want: Want): void { + const p: Record | undefined = want.parameters; + if (p === undefined) { + return; + } + const raw: Object | undefined = p['fbModule']; + const key: string = typeof raw === 'string' ? raw : ''; + if (key === '') { + return; + } + let known: boolean = false; + for (const m of FB_MODULES) { + if (m.key === key) { + known = true; + break; + } + } + if (!known) { + hilog.warn(DOMAIN, 'FamilyBook', '卡片带来的模块名不认识(已忽略):%{public}s', key); + return; + } + fbAppState().switchModule(key); + hilog.info(DOMAIN, 'FamilyBook', '从卡片进入模块:%{public}s', key); + } + + /** + * 应用**已经在运行**时再从卡片点进来(热启动)走这里。 + * + * ⚠️ 没有它的话,第二次点卡片看起来"什么也没发生" —— 系统只是把已有的 + * 窗口提到前台,`onCreate` 不会再跑一遍。 + */ + onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { + hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onNewWant'); + this.applyWantModule(want); + } + onWindowStageCreate(windowStage: window.WindowStage): void { // ① 先跟随系统深浅色。必须在取窗口之前调用,否则会读到尚未更新的色彩配置。 try { diff --git a/entry/src/main/ets/entryformability/FbFormAbility.ets b/entry/src/main/ets/entryformability/FbFormAbility.ets new file mode 100644 index 0000000..9ee5f7a --- /dev/null +++ b/entry/src/main/ets/entryformability/FbFormAbility.ets @@ -0,0 +1,92 @@ +/** + * 家书 FamilyBook · 服务卡片提供方(鸿蒙端 FormExtensionAbility) + * + * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * 参照 `SyncCalendar` 的 `EntryFormAbility`: + * · `onAddForm` —— 添加到桌面时先塞一份"空壳",再异步推真实数据; + * · `onUpdateForm` —— 系统定时刷新(`form_config.json` 里 `updateDuration: 1` ⇒ 30 分钟); + * · `onFormEvent` —— 卡片上的「刷新」按钮(`action: 'message'`); + * · `onRemoveForm` —— 移出桌面。 + * + * 🔴 三个必须记住的约束: + * ① **生命周期回调返回后进程只活约 10 秒**(官方文档),所以回调里**不能做长任务**。 + * 这里的做法与 SyncCalendar 一致:立刻返回/用本地缓存,真实的网络请求 + * 放在 `.then()` 里异步推 —— 就算被回收,也只是"这次没刷成", + * 下一次系统刷新(30 分钟)还会重试。**卡片数据不是关键数据,允许慢一拍。** + * ② `onAddForm` **必须同步返回**一个 `FormBindingData`(不能是 Promise), + * 否则桌面直接显示"卡片加载失败"。 + * ③ 数据构建失败**绝不能抛**:抛出去的表现是桌面上一张白卡, + * 用户完全不知道发生了什么。⇒ 失败也返回"带一句人话"的壳(见 `FbCardData.hint`)。 + */ + +import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit'; +import { Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { FbCardData, fbCardBuild } from '../data/FbCardData'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbForm'; + +export default class FbFormAbility extends FormExtensionAbility { + /** + * 重新算一遍并推给指定卡片。 + * + * ⚠️ 三种尺寸(2×2 / 2×4 / 4×4)共用这一份数据:多算一次就多打一次接口, + * 没有必要 —— 它们只是**排版不同**,数字必须是同一个。 + */ + private push(formId: string): void { + fbCardBuild(this.context).then((data: FbCardData): void => { + const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data); + formProvider.updateForm(formId, binding).then((): void => { + hilog.info(DOMAIN, TAG, '卡片 %{public}s 已更新(%{public}s)', formId, data.state); + }).catch((err: BusinessError): void => { + // 卡片可能已经被用户删了 —— 打一条日志就够,不要重试 + hilog.warn(DOMAIN, TAG, '卡片 %{public}s 更新失败:%{public}s', formId, err.message); + }); + }).catch((err: BusinessError): void => { + hilog.error(DOMAIN, TAG, '卡片数据构建失败:%{public}s', err.message); + }); + } + + /** 从 Want 里取卡片 id(`ohos.extra.param.key.form_identity`) */ + private formIdOf(want: Want): string { + if (want.parameters === undefined) { + return ''; + } + const raw: Object | undefined = want.parameters['ohos.extra.param.key.form_identity']; + return typeof raw === 'string' ? raw : ''; + } + + onAddForm(want: Want): formBindingData.FormBindingData { + const formId: string = this.formIdOf(want); + hilog.info(DOMAIN, TAG, '添加卡片:%{public}s', formId); + if (formId !== '') { + // 异步补真实数据(不 await:见文件头约束 ①) + this.push(formId); + } + // 先给一份"正在取"的壳 —— 卡片上会显示这天/这周的空状态,而不是白屏 + const shell: FbCardData = new FbCardData(); + shell.state = 'loading'; + shell.hint = '正在取数据…'; + return formBindingData.createFormBindingData(shell); + } + + onUpdateForm(formId: string): void { + hilog.info(DOMAIN, TAG, '系统定时刷新:%{public}s', formId); + this.push(formId); + } + + onFormEvent(formId: string, message: string): void { + /* 卡片上的按钮都走 `action: 'message'`,`message` 是 JSON 字符串。 + ⚠️ 不解析 JSON:不同版本系统给的形态可能是 `{params:{...}}` 或直接 `{...}`, + 子串匹配更稳(与 SyncCalendar 同一个取舍)。 */ + hilog.info(DOMAIN, TAG, '卡片事件:%{public}s %{public}s', formId, message); + this.push(formId); + } + + onRemoveForm(formId: string): void { + // 本卡片不落任何"每张卡"的状态(没有翻页偏移之类),所以只需记一条日志 + hilog.info(DOMAIN, TAG, '移除卡片:%{public}s', formId); + } +} diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index 6d39df3..997b2a5 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -124,10 +124,26 @@ export const FB_DEMO_CINEMAS: string[] = ['家里', '万达影城·李沧店', ' // ============================================================ export const FB_DEMO_TRIPS: FbTripItem[] = [ - { id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03', nodeCount: 8, doneCount: 8 }, - { id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16', nodeCount: 6, doneCount: 6 }, - { id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04', nodeCount: 12, doneCount: 12 }, - { id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24', nodeCount: 0, doneCount: 0 } + { + id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03', + nodeCount: 8, doneCount: 8, coverKey: '', city: '青岛', summary: '海边住了两晚,早上赶海、下午爬山。', + attendeeCount: 3 + }, + { + id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16', + nodeCount: 6, doneCount: 6, coverKey: '', city: '烟台', summary: '带孩子去看海,吃了顿海鲜。', + attendeeCount: 4 + }, + { + id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04', + nodeCount: 12, doneCount: 12, coverKey: '', city: '西安', summary: '兵马俑、城墙、回民街。', + attendeeCount: 3 + }, + { + id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24', + nodeCount: 0, doneCount: 0, coverKey: '', city: '哈尔滨', summary: '', + attendeeCount: 0 + } ]; /** 一段旅程的行程(trips/show 时间轴;stage 分「计划 / 实际」) */ @@ -172,13 +188,41 @@ export const FB_DEMO_EVENTS_SOON: FbCalEvent[] = [ // ============================================================ export const FB_DEMO_TASKS: FbTaskItem[] = [ - { id: 1, title: '交物业费', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '10-13 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0 }, - { id: 2, title: '预约明年体检', status: 'doing', board: '家人健康', boardColor: '#5E7A3C', dueText: '10-20 前', overdue: false, assignee: '爱人', childDone: 1, childTotal: 3 }, - { id: 3, title: '整理去年旅行照片', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0 }, - { id: 4, title: '换季衣物收纳', status: 'todo', board: '家务', boardColor: '#A8574F', dueText: '10-09 前', overdue: true, assignee: '爱人', childDone: 0, childTotal: 0 }, - { id: 5, title: '给儿子买秋季校服', status: 'todo', board: '孩子', boardColor: '#4A7C82', dueText: '10-15 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0 }, - { id: 6, title: '车保养', status: 'todo', board: '车与房', boardColor: '#5566A8', dueText: '', overdue: false, assignee: '', childDone: 0, childTotal: 0 }, - { id: 7, title: '读完《置身事内》', status: 'done', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0 } + { + id: 1, title: '交物业费', status: 'doing', board: '家务', boardColor: '#A8574F', + dueText: '10-13 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0, + boardId: 1, note: '', dueAt: '2026-10-13 18:00' + }, + { + id: 2, title: '预约明年体检', status: 'doing', board: '家人健康', boardColor: '#5E7A3C', + dueText: '10-20 前', overdue: false, assignee: '爱人', childDone: 1, childTotal: 3, + boardId: 2, note: '', dueAt: '2026-10-20 18:00' + }, + { + id: 3, title: '整理去年旅行照片', status: 'doing', board: '家务', boardColor: '#A8574F', + dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0, + boardId: 1, note: '', dueAt: '' + }, + { + id: 4, title: '换季衣物收纳', status: 'todo', board: '家务', boardColor: '#A8574F', + dueText: '10-09 前', overdue: true, assignee: '爱人', childDone: 0, childTotal: 0, + boardId: 1, note: '', dueAt: '2026-10-09 18:00' + }, + { + id: 5, title: '给儿子买秋季校服', status: 'todo', board: '孩子', boardColor: '#4A7C82', + dueText: '10-15 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0, + boardId: 3, note: '', dueAt: '2026-10-15 18:00' + }, + { + id: 6, title: '车保养', status: 'todo', board: '车与房', boardColor: '#5566A8', + dueText: '', overdue: false, assignee: '', childDone: 0, childTotal: 0, + boardId: 4, note: '', dueAt: '' + }, + { + id: 7, title: '读完《置身事内》', status: 'done', board: '家务', boardColor: '#A8574F', + dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0, + boardId: 1, note: '', dueAt: '' + } ]; /** 任务看板(tasks/settings) */ @@ -224,12 +268,30 @@ export const FB_DEMO_LEDGER_ENTRIES: FbLedgerEntry[] = [ // ============================================================ export const FB_DEMO_RESOURCES: FbResourceItem[] = [ - { id: 1, name: '户口本', kind: 'document', ownerName: '全家', fileCount: 2, shared: true, updatedAt: '2026-09-14' }, - { id: 2, name: '房产证', kind: 'document', ownerName: '我们', fileCount: 3, shared: false, updatedAt: '2026-07-02' }, - { id: 3, name: '儿子学籍资料', kind: 'material', ownerName: '儿子', fileCount: 6, shared: true, updatedAt: '2026-09-01' }, - { id: 4, name: '全家保险合同', kind: 'document', ownerName: '全家', fileCount: 4, shared: true, updatedAt: '2026-08-12' }, - { id: 5, name: '装修图纸', kind: 'material', ownerName: '我', fileCount: 12, shared: false, updatedAt: '2026-05-19' }, - { id: 6, name: '车险单', kind: 'document', ownerName: '我', fileCount: 1, shared: false, updatedAt: '2026-04-08' } + { + id: 1, name: '户口本', ownerName: '全家', ownerId: 1, perm: 'owner', + content: '放在书房第二个抽屉。编号 3702…', shared: false, updatedAt: '2026-09-14' + }, + { + id: 2, name: '房产证', ownerName: '我们', ownerId: 1, perm: 'owner', + content: '', shared: false, updatedAt: '2026-07-02' + }, + { + id: 3, name: '儿子学籍资料', ownerName: '儿子', ownerId: 2, perm: 'write', + content: '学籍号在班主任那里。', shared: true, updatedAt: '2026-09-01' + }, + { + id: 4, name: '全家保险合同', ownerName: '全家', ownerId: 1, perm: 'owner', + content: '每年 8 月续保。', shared: false, updatedAt: '2026-08-12' + }, + { + id: 5, name: '装修图纸', ownerName: '我', ownerId: 1, perm: 'owner', + content: '', shared: false, updatedAt: '2026-05-19' + }, + { + id: 6, name: '车险单', ownerName: '我', ownerId: 1, perm: 'read', + content: '', shared: true, updatedAt: '2026-04-08' + } ]; // ============================================================ @@ -255,11 +317,31 @@ export const FB_DEMO_BOOKS: FbBookItem[] = [ // ============================================================ export const FB_DEMO_NOTES: FbNoteItem[] = [ - { id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…', tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2 }, - { id: 2, title: '读书摘抄 · 置身事内', excerpt: '「地方政府的行为,要从它面对的制度约束去理解。」……', tags: ['读书'], updatedAt: '10-10 22:40', shared: true, attachCount: 0 }, - { id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…', tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1 }, - { id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…', tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6 }, - { id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…', tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0 } + { + id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…', + tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2, + body: '1. 换推拉门(断桥铝,考虑隔音)\n2. 加一组置物架\n3. 补两盆绿萝', mine: true + }, + { + id: 2, title: '读书摘抄 · 置身事内', excerpt: '「地方政府的行为,要从它面对的制度约束去理解。」……', + tags: ['读书'], updatedAt: '10-10 22:40', shared: true, attachCount: 0, + body: '「地方政府的行为,要从它面对的制度约束去理解。」', mine: true + }, + { + id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…', + tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1, + body: '周一 16:30 数学\n周三 17:00 游泳\n周六 09:00 钢琴', mine: false + }, + { + id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…', + tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6, + body: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218', mine: true + }, + { + id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…', + tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0, + body: '拖车绳、搭电线、应急灯、保温毯、常用药', mine: true + } ]; // ============================================================ @@ -298,8 +380,16 @@ export function fbGroupTask(list: FbTaskItem[]): FbGroup[] { } /** 按 kind 分组建资源(证件 / 资料 / 其他) */ +/** + * 按**归属人**分组(资源挂在某人名下)。 + * + * 🔴 v65:原来按"证件 / 资料"分组 —— 但服务端 `resources` 表根本没有类别列 + * (`app/Schema.php` 只有 title / content / owner_id),解析时只能全填 `other` + * ⇒ 列表永远只出现一个「其他」组,两个筛选胶囊点了永远是空列表。 + * 改成按归属人分组:这是真实存在的字段,也正好是网页版列表的读法。 + */ export function fbGroupResource(list: FbResourceItem[]): FbGroup[] { - return fbGroupBy(list, (it: FbResourceItem): string => fbResourceKindName(it.kind)); + return fbGroupBy(list, (it: FbResourceItem): string => it.ownerName); } /** 按 status 分组建书架(在读 / 想读 / 读完) */ diff --git a/entry/src/main/ets/model/FbSettingsMenus.ets b/entry/src/main/ets/model/FbSettingsMenus.ets index cd335e3..1a91ea6 100644 --- a/entry/src/main/ets/model/FbSettingsMenus.ets +++ b/entry/src/main/ets/model/FbSettingsMenus.ets @@ -41,9 +41,13 @@ export const FB_SET_BOOKS: FbSettingItem[] = [ { icon: 'share', label: '微信读书导入', hint: '同步微信读书的书单', action: '' } ]; -/** 纪念日(templates/days/_settings_menu.php) */ +/** 纪念日(templates/days/_settings_menu.php) + * 🔴 v65:原来 `action === ''` ⇒ 画灰、点了不响应。 + * 分类名在服务端是**自由文本**(`anniv_kinds` 那份清单没有 API), + * 所以端上做的是「按已经出现过的分类筛」—— + * 增删改分类本身仍在网页端「纪念日 → 设置」里。 */ export const FB_SET_DAYS: FbSettingItem[] = [ - { icon: 'filter', label: '纪念日分类', hint: '全家共用一套分类', action: '' } + { icon: 'filter', label: '纪念日分类', hint: '按分类挑着看', action: 'daykinds' } ]; /** 账本(templates/ledger/_settings_menu.php) */ @@ -52,10 +56,16 @@ export const FB_SET_LEDGER: FbSettingItem[] = [ { icon: 'edit', label: '账本名字', hint: '改这本账本的名字', action: '' } ]; -/** 任务(templates/tasks/_settings_menu.php) */ +/** 任务(templates/tasks/_settings_menu.php) + * 🔴 v65(用户 2026-10-20「设置这些东西都这都没有」):两条都接上了。 + * · taskdone → 只看已完成(网页版是 `?view=done` 换视图) + * · taskboards → 看板列表(手机宽放不下多列看板,这里只做"挑一个看板"; + * 增删改看板仍在网页端 `templates/tasks/boards.php`) + * ⚠️ 之所以不把两条合成一条:"已完成"是**状态**筛选、"分类与看板"是**归属**筛选, + * 点进去看到的列表不是同一份,合起来必有一条永远进不去。 */ export const FB_SET_TASKS: FbSettingItem[] = [ - { icon: 'tasks', label: '分类与看板', hint: '管分类、管看板', action: '' }, - { icon: 'check', label: '已完成', hint: '看已经完成的任务', action: '' } + { icon: 'tasks', label: '分类与看板', hint: '按看板找任务', action: 'taskboards' }, + { icon: 'check', label: '已完成', hint: '看已经完成的任务', action: 'taskdone' } ]; /** 旅程(templates/trips/_settings_menu.php) */ @@ -65,7 +75,12 @@ export const FB_SET_TRIPS: FbSettingItem[] = [ { icon: 'dot', label: '节点类型', hint: '行程节点的类型', action: '' } ]; -/** 资源(网页版没有独立菜单;这里只放已有的入口) */ +/** 资源(网页版没有独立菜单;这里只放端上真做得到的两个筛选) + * + * 🔴 v65:原来两条是「格式筛选」(`action === ''` ⇒ 点了不响应), + * 而且端上的"格式"根本不存在 —— 服务端 `resources` 表没有类别列。 + * 改成按**权限/归属**筛:`perm` 与 `owner_id` 都是服务端真给的字段。 */ export const FB_SET_RESOURCES: FbSettingItem[] = [ - { icon: 'filter', label: '格式筛选', hint: '只看某类资源', action: '' } + { icon: 'share', label: '共享给我的', hint: '别人名下、共享给我看的', action: 'resourceShared' }, + { icon: 'people', label: '我名下的', hint: '挂在自己名下的资源', action: 'resourceMine' } ]; \ No newline at end of file diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index 0e2db75..66e70dc 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -435,6 +435,25 @@ export interface FbTripItem { nodeCount: number; /** 已完成段数 */ doneCount: number; + /** + * 题图(`trip_details.cover_key` 的**原始键**,如 `covers/ab12.jpg`)。 + * + * ⚠️ 存原始键、渲染时才拼完整地址(见 `data/FbHttp.fbMediaUrl`)—— + * 与 `FbPersonItem.avatar` 同一套。存拼好的地址会在换服务器/换令牌后失效。 + * ⚠️ 空串 = 这趟旅程没设题图,卡片显示占位图标(网页版是 🧳)。 + */ + coverKey: string; + /** + * 城市串(服务端派生,如「青岛 · 威海 → 济南」)。 + * + * 与 `sub` 的区别:`sub` 是"城市或天数"的合并文案(历史字段,保持兼容), + * 这个字段**只放城市**,供卡片单独显示「📍 青岛」那一行。 + */ + city: string; + /** 简介(网页版列表卡截 80 字显示) */ + summary: string; + /** 同行人数(所有行程参与人的合集,与网页版一致) */ + attendeeCount: number; } /** 旅程里的一段行程(trips/show 的时间轴节点) */ @@ -545,6 +564,27 @@ export interface FbTaskItem { /** 子任务完成数 / 总数 */ childDone: number; childTotal: number; + /** 看板 id(0 = 还没归到看板)。 + * 🔴 v65:新建任务/搬看板都要传它;原来只存了**看板名**,端上没法回填。 */ + boardId: number; + /** 备注正文(详情浮层显示、编辑时回填) */ + note: string; + /** 截止时间 ISO 原文(`2026-10-25 18:00`;空 = 没有期限) */ + dueAt: string; +} + +/** + * 任务看板选项(新建/编辑任务时挑"归到哪个看板")。 + * + * ⚠️ 服务端 `GET /tasks` 的 board 视图里自带 `boards: [{id,name,color,tasks}]`, + * 但端上 `parseTasks` 把结果**摊平**成任务列表、丢掉了 boards 那一层。 + * ⇒ 这里从已加载的任务里**反推**一份(同 boardId 只留一条)。 + * 等哪天要「新增看板」时,再改成正经解析 `boards`。 + */ +export interface FbBoardOpt { + id: number; + name: string; + color: string; } // ============================================================ @@ -565,6 +605,26 @@ export interface FbAnniversaryItem { people: FbKinChip[]; /** 是否每年重复 */ yearly: boolean; + /** + * 数据来源:`custom`(自己添加的)/ `birthday_solar` / `birthday_lunar`(名册生日派生)。 + * + * 🔴 v65:派生出来的生日行 **`id` 是 0**(见 `Anniversary::birthdays()`), + * 拿它去 PATCH `/days/0` 必然 404;而且"改生日"该去名册改。 + * ⇒ 端上只允许编辑/删除 `custom` 的条目,其余点开时明说去哪儿改。 + */ + source: string; + /** 月(1-12)—— 编辑要回填 */ + month: number; + /** 日(1-31)—— 编辑要回填 */ + day: number; + /** `solar` / `lunar` / `once` */ + repeatType: string; + /** 原始的 kind **文本**(如「结婚纪念日」)—— 上面那个 `kind` 是映射后的类别码 */ + rawKind: string; + /** 备注 */ + note: string; + /** 关联人 id(自定义条目才有;服务端 v65 起列表里会带) */ + personIds: number[]; } // ============================================================ @@ -624,15 +684,22 @@ export interface FbResourceItem { id: number; /** 名称,如「户口本」 */ name: string; - /** 类别:document(证件)/ material(资料)/ other */ - kind: string; - /** 归属人(空表示全家) */ + /** 归属人显示名 */ ownerName: string; - /** 附件数 */ - fileCount: number; - /** 是否共享给我 */ + /** 归属人 id(`POST /resources` 只能挂自己名下;这里留着是为了以后能选人) */ + ownerId: number; + /** + * 我的权限:`owner` / `write` / `read`。 + * + * 🔴 v65:服务端 `Resource::all` 的 SELECT 里就有这个 CASE WHEN, + * 但端上原来**扔掉了** ⇒ 只读共享来的资源也画成可编辑的样子。 + */ + perm: string; + /** 正文(证件编号、有效期、放在哪…)—— 详情要显示、编辑要回填 */ + content: string; + /** 是否共享来的(`perm !== 'owner'`) */ shared: boolean; - /** 更新时间 */ + /** 更新时间(只取日期部分) */ updatedAt: string; } @@ -677,6 +744,17 @@ export interface FbNoteItem { shared: boolean; /** 附件数 */ attachCount: number; + /** + * 正文**纯文本**(把 `note_blocks` 里 text 块拼起来,图片块记成「[图片]」)。 + * + * 🔴 v65(用户 2026-10-20「笔记现在也点不进去,记笔记也记不了」): + * 服务端 `/notes` 列表其实**已经带 `blocks`**(`Note::attachChildren` 一次性挂上), + * 只是端上原来只用它算了个摘要扔掉 ⇒ 点进详情时正文没了。 + * 这里存下来,详情直接显示,不必再打一次 `/notes/`。 + */ + body: string; + /** 是不是我写的(不是的话只能看,服务端也只让作者删) */ + mine: boolean; } // ============================================================ @@ -701,8 +779,7 @@ export interface FbNotificationItem { // ============================================================ /** 一个分组(名册按分组、导航按分类、日历按日历本…都用它) */ -export interface FbGroup { - /** 分组键(唯一) */ +export interface FbGroup { /** 分组键(唯一) */ key: string; /** 分组标题 */ title: string; diff --git a/entry/src/main/ets/pages/card/FbCard2x2.ets b/entry/src/main/ets/pages/card/FbCard2x2.ets new file mode 100644 index 0000000..fb535be --- /dev/null +++ b/entry/src/main/ets/pages/card/FbCard2x2.ets @@ -0,0 +1,157 @@ +/** + * 家书 FamilyBook · 2×2 服务卡片(今日概览) + * + * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * + * 这块卡只回答一句话:**今天有几件事、最要紧的那件是什么**。 + * 所以版面是「日期 → 大数字 → 一行提示」,不做列表(2×2 放列表只会挤成蚂蚁)。 + * + * ⚠️ 卡片里**不做任何计算**(不排序、不筛选、不拼日期): + * 全部由 `data/FbCardData.ets` 算好塞进 `LocalStorage`(原因见那边的文件头)。 + * 这里只负责画 —— 三种尺寸共用同一份数据,数字必须一致。 + */ + +import { FbCardDay, FbCardEvent } from '../../data/FbCardData'; + +let storage2x2 = new LocalStorage(); + +@Entry(storage2x2) +@Component +struct FbCard2x2 { + @LocalStorageProp('state') state: string = 'loading'; + @LocalStorageProp('todayLabel') todayLabel: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; + @LocalStorageProp('lunarShort') lunarShort: string = ''; + @LocalStorageProp('marks') marks: string = ''; + @LocalStorageProp('eventCount') eventCount: number = 0; + @LocalStorageProp('doing') doing: number = 0; + @LocalStorageProp('todo') todo: number = 0; + @LocalStorageProp('overdue') overdue: number = 0; + @LocalStorageProp('eventsJson') eventsJson: string = '[]'; + @LocalStorageProp('daysJson') daysJson: string = '[]'; + @LocalStorageProp('hint') hint: string = ''; + @LocalStorageProp('updatedAt') updatedAt: string = ''; + + /** 副标题:`星期二 九月初一`(缺哪个就不占位) */ + private subLine(): string { + const parts: string[] = []; + if (this.weekday !== '') { + parts.push(this.weekday); + } + if (this.lunarShort !== '') { + parts.push(this.lunarShort); + } + return parts.join(' '); + } + + /** + * 最下面那一行:优先"最近的纪念日倒计时",其次"今天第一条日程"。 + * + * ⚠️ 不显示"共 N 条"这种废话 —— 大数字已经说了。 + */ + private footLine(): string { + if (this.hint !== '') { + return this.hint; + } + const days = JSON.parse(this.daysJson) as FbCardDay[]; + if (days.length > 0) { + const d: FbCardDay = days[0]; + return d.days === 0 ? `${d.title} 就是今天` : `${d.title} 还有 ${d.days} 天`; + } + const evs = JSON.parse(this.eventsJson) as FbCardEvent[]; + if (evs.length > 0) { + return `${evs[0].time} ${evs[0].title}`; + } + return ''; + } + + /** 右下角小字:逾期优先(那是要人马上处理的) */ + private statLine(): string { + if (this.overdue > 0) { + return `逾期 ${this.overdue}`; + } + if (this.todo > 0) { + return `待办 ${this.todo}`; + } + return this.updatedAt; + } + + build() { + Column() { + // ---- 抬头:日期 + 日子标记 ---- + Row() { + Column({ space: 1 }) { + Text(this.todayLabel === '' ? '家书' : this.todayLabel) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#2C231D') + .maxLines(1) + Text(this.subLine()) + .fontSize(10) + .fontColor('#A8988A') + .maxLines(1) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + if (this.marks !== '') { + Text(this.marks) + .fontSize(9) + .fontColor('#A84A28') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .backgroundColor('#F7EFE6') + .borderRadius(4) + } + } + .width('100%') + + Blank() + + // ---- 大数字 ---- + Row({ space: 6 }) { + Text(`${this.eventCount}`) + .fontSize(34) + .fontWeight(FontWeight.Bold) + .fontColor('#A84A28') + Column({ space: 1 }) { + Text('件日程') + .fontSize(11) + .fontColor('#2C231D') + Text(this.statLine()) + .fontSize(10) + .fontColor(this.overdue > 0 ? '#C0533F' : '#A8988A') + } + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .alignItems(VerticalAlign.Bottom) + + Blank() + + // ---- 一行提示 ---- + Text(this.footLine()) + .fontSize(10) + .fontColor(this.hint !== '' ? '#C0533F' : '#7E6B5A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + .width('100%') + .height('100%') + .padding(12) + .backgroundColor('#FFFFFF') + .borderRadius(16) + .alignItems(HorizontalAlign.Start) + // 整卡点击 → 打开日程(这张卡的主角就是"今天有几件事") + .onClick((): void => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar' } + }); + }) + } +} + diff --git a/entry/src/main/ets/pages/card/FbCard2x4.ets b/entry/src/main/ets/pages/card/FbCard2x4.ets new file mode 100644 index 0000000..eaff229 --- /dev/null +++ b/entry/src/main/ets/pages/card/FbCard2x4.ets @@ -0,0 +1,162 @@ +/** + * 家书 FamilyBook · 2×4 服务卡片(今日日程 · 左右分栏) + * + * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * + * 左边一列是"今天怎么样"(日期 / 农历 / 件数 / 待办),右边是**今天的前三条日程**。 + * 2×2 只能给个数字,说不出"是什么事";这块卡就是补那一步。 + * + * ⚠️ 与 2×2 / 4×4 **同一份数据**(`data/FbCardData.ets` 算一次,三卡共用), + * 所以件数一定一致 —— 不要在卡片里自己算。 + */ + +import { FbCardDay, FbCardEvent } from '../../data/FbCardData'; + +let storage2x4 = new LocalStorage(); + +@Entry(storage2x4) +@Component +struct FbCard2x4 { + @LocalStorageProp('state') state: string = 'loading'; + @LocalStorageProp('todayLabel') todayLabel: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; + @LocalStorageProp('lunarShort') lunarShort: string = ''; + @LocalStorageProp('eventCount') eventCount: number = 0; + @LocalStorageProp('doing') doing: number = 0; + @LocalStorageProp('todo') todo: number = 0; + @LocalStorageProp('overdue') overdue: number = 0; + @LocalStorageProp('eventsJson') eventsJson: string = '[]'; + @LocalStorageProp('hint') hint: string = ''; + + private subLine(): string { + const parts: string[] = []; + if (this.weekday !== '') { + parts.push(this.weekday); + } + if (this.lunarShort !== '') { + parts.push(this.lunarShort); + } + return parts.join(' '); + } + + /** 右侧最多三条(再多 2×4 的高度放不下,会被裁掉半行) */ + private top3(): FbCardEvent[] { + const all = JSON.parse(this.eventsJson) as FbCardEvent[]; + const out: FbCardEvent[] = []; + for (let i = 0; i < all.length && i < 3; i++) { + out.push(all[i]); + } + return out; + } + + build() { + Row({ space: 10 }) { + // ---- 左栏:今天的"账" ---- + Column({ space: 2 }) { + Text(this.todayLabel === '' ? '家书' : this.todayLabel) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor('#2C231D') + .maxLines(1) + Text(this.subLine()) + .fontSize(10) + .fontColor('#A8988A') + .maxLines(1) + + Blank() + + Row({ space: 5 }) { + Text(`${this.eventCount}`) + .fontSize(30) + .fontWeight(FontWeight.Bold) + .fontColor('#A84A28') + Text('件日程') + .fontSize(11) + .fontColor('#2C231D') + .margin({ bottom: 4 }) + } + .alignItems(VerticalAlign.Bottom) + + Text(this.overdue > 0 ? `逾期 ${this.overdue} 件` : `待办 ${this.todo} 件`) + .fontSize(10) + .fontColor(this.overdue > 0 ? '#C0533F' : '#A8988A') + .maxLines(1) + } + .width('38%') + .height('100%') + .alignItems(HorizontalAlign.Start) + + // 分隔线(视觉上把"账"和"事"分开) + Divider() + .vertical(true) + .strokeWidth(0.5) + .color('#EFE6D8') + .height('86%') + + // ---- 右栏:今天的事 ---- + Column({ space: 0 }) { + Text('今天') + .fontSize(10) + .fontColor('#A8988A') + .width('100%') + .margin({ bottom: 5 }) + + if (this.hint !== '') { + Text(this.hint) + .fontSize(11) + .fontColor('#C0533F') + .maxLines(2) + .width('100%') + } else if (this.top3().length === 0) { + Text('今天没有安排') + .fontSize(11) + .fontColor('#A8988A') + .width('100%') + } else { + ForEach(this.top3(), (e: FbCardEvent) => { + Row({ space: 6 }) { + Column() + .width(3) + .height(13) + .borderRadius(2) + .backgroundColor(e.color) + .flexShrink(0) + Text(e.time) + .fontSize(10) + .fontColor('#7E6B5A') + .width(34) + .flexShrink(0) + Text(e.title) + .fontSize(11) + .fontColor('#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .height(20) + .alignItems(VerticalAlign.Center) + }, (e: FbCardEvent) => e.key) + } + Blank() + } + .layoutWeight(1) + .height('100%') + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .padding({ left: 14, right: 14, top: 12, bottom: 12 }) + .backgroundColor('#FFFFFF') + .borderRadius(16) + .alignItems(VerticalAlign.Top) + .onClick((): void => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar' } + }); + }) + } +} + diff --git a/entry/src/main/ets/pages/card/FbCard4x4.ets b/entry/src/main/ets/pages/card/FbCard4x4.ets new file mode 100644 index 0000000..17a097f --- /dev/null +++ b/entry/src/main/ets/pages/card/FbCard4x4.ets @@ -0,0 +1,235 @@ +/** + * 家书 FamilyBook · 4×4 服务卡片(今日全景) + * + * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * + * 这块是"不用打开应用就能看完"的那一块: + * 抬头(日期/农历)→ 四个数字(日程/在做/待办/逾期)→ 今日日程 → 最近纪念日。 + * 四块内容的信息密度正好对上 4×4;再塞(任务详情、往年的今天)就会被裁。 + * + * ⚠️ 右上角那个 ↻ 是"手动刷新": + * 动态卡片**不能自己发起网络请求**,只能 `postCardAction({action:'message'})` + * 把消息发给 `FbFormAbility.onFormEvent`,由那边重新取数再推回来。 + */ + +import { FbCardDay, FbCardEvent } from '../../data/FbCardData'; + +let storage4x4 = new LocalStorage(); + +@Entry(storage4x4) +@Component +struct FbCard4x4 { + @LocalStorageProp('state') state: string = 'loading'; + @LocalStorageProp('todayLabel') todayLabel: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; + @LocalStorageProp('lunarShort') lunarShort: string = ''; + @LocalStorageProp('marks') marks: string = ''; + @LocalStorageProp('eventCount') eventCount: number = 0; + @LocalStorageProp('doing') doing: number = 0; + @LocalStorageProp('todo') todo: number = 0; + @LocalStorageProp('overdue') overdue: number = 0; + @LocalStorageProp('eventsJson') eventsJson: string = '[]'; + @LocalStorageProp('daysJson') daysJson: string = '[]'; + @LocalStorageProp('hint') hint: string = ''; + @LocalStorageProp('updatedAt') updatedAt: string = ''; + + private subLine(): string { + const parts: string[] = []; + if (this.weekday !== '') { + parts.push(this.weekday); + } + if (this.lunarShort !== '') { + parts.push(this.lunarShort); + } + return parts.join(' '); + } + + /** 今日日程最多画 5 条(第 6 条开始一定被裁) */ + private events(): FbCardEvent[] { + const all = JSON.parse(this.eventsJson) as FbCardEvent[]; + const out: FbCardEvent[] = []; + for (let i = 0; i < all.length && i < 5; i++) { + out.push(all[i]); + } + return out; + } + + /** 最近纪念日最多 2 条(第三行会与"今日日程"抢高度) */ + private days(): FbCardDay[] { + const all = JSON.parse(this.daysJson) as FbCardDay[]; + const out: FbCardDay[] = []; + for (let i = 0; i < all.length && i < 2; i++) { + out.push(all[i]); + } + return out; + } + + /** 手动刷新(走 message,见文件头) */ + private refresh(): void { + postCardAction(this, { + action: 'message', + params: { msg: 'refresh' } + }); + } + + /** 一个数字格(日程 / 在做 / 待办 / 逾期) */ + @Builder + statCell(label: string, value: number, color: string) { + Column({ space: 1 }) { + Text(`${value}`) + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor(color) + .maxLines(1) + Text(label) + .fontSize(9) + .fontColor('#A8988A') + .maxLines(1) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + build() { + Column() { + // ---- 抬头 ---- + Row({ space: 6 }) { + Column({ space: 1 }) { + Text(this.todayLabel === '' ? '家书' : this.todayLabel) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor('#2C231D') + .maxLines(1) + Text(this.subLine() + (this.marks !== '' ? ' ' + this.marks : '')) + .fontSize(10) + .fontColor('#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text('↻') + .fontSize(14) + .fontColor('#7E6B5A') + .width(26) + .height(26) + .textAlign(TextAlign.Center) + .backgroundColor('#F5EFE4') + .borderRadius(13) + .onClick((): void => { + this.refresh(); + }) + } + .width('100%') + + // ---- 四个数字 ---- + Row() { + this.statCell('日程', this.eventCount, '#A84A28') + this.statCell('在做', this.doing, '#2C231D') + this.statCell('待办', this.todo, '#2C231D') + this.statCell('逾期', this.overdue, this.overdue > 0 ? '#C0533F' : '#A8988A') + } + .width('100%') + .margin({ top: 8 }) + + Divider() + .strokeWidth(0.5) + .color('#EFE6D8') + .margin({ top: 8, bottom: 6 }) + + // ---- 今日日程 ---- + Text('今日日程') + .fontSize(10) + .fontColor('#A8988A') + .width('100%') + + Column({ space: 0 }) { + if (this.hint !== '') { + Text(this.hint) + .fontSize(11) + .fontColor('#C0533F') + .maxLines(2) + .width('100%') + } else if (this.events().length === 0) { + Text('今天没有安排') + .fontSize(11) + .fontColor('#A8988A') + .width('100%') + } else { + ForEach(this.events(), (e: FbCardEvent) => { + Row({ space: 6 }) { + Column() + .width(3) + .height(13) + .borderRadius(2) + .backgroundColor(e.color) + .flexShrink(0) + Text(e.time) + .fontSize(10) + .fontColor('#7E6B5A') + .width(34) + .flexShrink(0) + Text(e.title) + .fontSize(11) + .fontColor('#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .height(21) + .alignItems(VerticalAlign.Center) + }, (e: FbCardEvent) => e.key) + } + } + .width('100%') + .margin({ top: 3 }) + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + // ---- 最近纪念日 ---- + if (this.days().length > 0) { + Divider() + .strokeWidth(0.5) + .color('#EFE6D8') + .margin({ top: 4, bottom: 5 }) + + ForEach(this.days(), (d: FbCardDay) => { + Row({ space: 6 }) { + Text(d.days === 0 ? '今天' : `${d.days} 天`) + .fontSize(10) + .fontColor(d.soon ? '#A84A28' : '#A8988A') + .width(38) + .flexShrink(0) + Text(d.title) + .fontSize(11) + .fontColor('#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .height(18) + .alignItems(VerticalAlign.Center) + }, (d: FbCardDay) => d.key) + } + } + .width('100%') + .height('100%') + .padding({ left: 14, right: 14, top: 12, bottom: 10 }) + .backgroundColor('#FFFFFF') + .borderRadius(16) + .alignItems(HorizontalAlign.Start) + .onClick((): void => { + // 整卡点击 → 打开日程(卡片上"今日日程"是主角) + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar' } + }); + }) + } +} + + diff --git a/entry/src/main/ets/pages/days/FbDaysPage.ets b/entry/src/main/ets/pages/days/FbDaysPage.ets index 06c25b6..d488659 100644 --- a/entry/src/main/ets/pages/days/FbDaysPage.ets +++ b/entry/src/main/ets/pages/days/FbDaysPage.ets @@ -10,12 +10,16 @@ * 因为「还有几天」是这个模块唯一真正要一眼看到的信息。 */ -import { FbColors, FbMetric, FbTint, FB_MASK } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbAnniversaryItem, FbKinChip } from '../../model/Types'; import { parseDays } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; +import { FbSheetRing } from '../../components/FbSheetRing'; +import { FbSheetClose } from '../../components/FbSheetClose'; +import { FbDayEditSheet } from '../../components/FbDayEditSheet'; +import { FbRosterPerson, FbPersonRepo } from '../../data/FbPersonRepo'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; @@ -45,8 +49,29 @@ export struct FbDaysPage { @Local settingsOpen: boolean = false; @Local all: FbAnniversaryItem[] = []; - /** 类别筛选('' = 全部) */ + /** 列表筛选('' = 全部 / 'birthday' = 生日 / 'custom' = 自己加的) */ @Local kindFilter: string = ''; + /** + * 编辑 / 新建浮层是否打开。 + * + * 🔴 v65(用户 2026-10-20):「纪念日、新增纪念日、设置相关的功能。」 + * ⇒ 原来「添加」这颗按钮**没有 `onTap`**、列表行**没有 `onClick`**。 + */ + @Local editOpen: boolean = false; + @Local editingId: number = 0; + @Local editingTitle: string = ''; + @Local editingKind: string = ''; + @Local editingNote: string = ''; + @Local editingRepeat: string = 'solar'; + @Local editingMonth: number = 1; + @Local editingDay: number = 1; + @Local editingPersonIds: number[] = []; + /** 名册(编辑浮层里选关联人用) */ + @Local roster: FbRosterPerson[] = []; + /** 「按分类看」浮层(设置菜单里那一条) */ + @Local kindSheetOpen: boolean = false; + /** 一行提示(点了派生生日、或操作失败时) */ + @Local hint: string = ''; /** * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。 @@ -64,6 +89,14 @@ export struct FbDaysPage { } private backOnce(): boolean { + if (this.editOpen) { + this.editOpen = false; + return true; + } + if (this.kindSheetOpen) { + this.kindSheetOpen = false; + return true; + } if (this.settingsOpen) { this.settingsOpen = false; return true; @@ -74,6 +107,16 @@ export struct FbDaysPage { aboutToAppear(): void { FB_BACK.register('days', (): boolean => this.backOnce()); this.load(); + this.loadRoster(); + } + + /** 读名册(选关联人用)。失败不阻断页面:只是"关联人"点开没人可选 */ + private async loadRoster(): Promise { + try { + this.roster = await FbPersonRepo.inst().list(); + } catch (err) { + this.roster = []; + } } private async load(): Promise { @@ -101,6 +144,73 @@ export struct FbDaysPage { fbAppState().sheetCount = this.settingsOpen ? 1 : 0; } + /** 编辑浮层开着 ⇒ 底部悬浮快捷栏让位(与设置浮层不会同时开) */ + @Monitor('editOpen') + onEditChanged(): void { + fbAppState().sheetCount = this.editOpen ? 1 : 0; + } + + /** 「按分类看」浮层同理 */ + @Monitor('kindSheetOpen') + onKindSheetChanged(): void { + fbAppState().sheetCount = this.kindSheetOpen ? 1 : 0; + } + + /** 打开「新建」 */ + private openNew(): void { + const today: Date = new Date(); + this.editingId = 0; + this.editingTitle = ''; + this.editingKind = ''; + this.editingNote = ''; + this.editingRepeat = 'solar'; + this.editingMonth = today.getMonth() + 1; + this.editingDay = today.getDate(); + this.editingPersonIds = []; + this.hint = ''; + this.editOpen = true; + } + + /** + * 打开某条(点列表行)。 + * + * ⚠️ **只有自己添加的能改**:名册生日派生出来的那些行 `id` 是 0, + * 拿它去打 `/days/0` 必然 404;而且改生日该去名册里改(那里是唯一真值)。 + * ⇒ 派生行点开只给一句提示,不去打开一个注定失败的编辑框。 + */ + private openEdit(a: FbAnniversaryItem): void { + if (a.source !== 'custom' || a.id <= 0) { + this.hint = '「' + a.title + '」是从名册里的生日自动来的 —— 要改请去名册改那个人的生日。'; + return; + } + this.hint = ''; + this.editingId = a.id; + this.editingTitle = a.title; + this.editingKind = a.rawKind; + this.editingNote = a.note; + this.editingRepeat = a.repeatType; + this.editingMonth = a.month; + this.editingDay = a.day; + this.editingPersonIds = a.personIds.slice(); + this.editOpen = true; + } + + /** 数据里出现过的分类名(去重),给「按分类看」用 */ + private kindNames(): string[] { + const out: string[] = []; + for (const a of this.all) { + if (a.rawKind !== '' && out.indexOf(a.rawKind) < 0) { + out.push(a.rawKind); + } + } + return out; + } + + /** 某个分类名下的条数 */ + private countOfKind(name: string): number { + return this.all.filter((a: FbAnniversaryItem): boolean => a.rawKind === name).length; + } + build() { // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) @@ -124,7 +234,11 @@ export struct FbDaysPage { FbPrimaryButton({ label: '添加', bg: this.accent, - icon: $r('app.media.fb_ic_plus') + icon: $r('app.media.fb_ic_plus'), + /* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */ + onTap: (): void => { + this.openNew(); + } }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 @@ -140,6 +254,16 @@ export struct FbDaysPage { .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) + // 一行提示(点了派生生日 / 操作失败)—— 就地说明,不弹 toast + if (this.hint !== '') { + Text(this.hint) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 }) + .maxLines(3) + } + Column({ space: 0 }) { // ---- 即将到来(30 天内)---- @@ -175,8 +299,6 @@ export struct FbDaysPage { .onDidScroll(fbOnHScroll) .scrollBar(BarState.Off) .width('100%') - // 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏 - .padding({ bottom: FB_DOCK_RESERVE }) } } .padding(FbMetric.cardPad) @@ -186,11 +308,33 @@ export struct FbDaysPage { .alignItems(HorizontalAlign.Start) // ---- 全部纪念日 ---- + /* ⚠️ 筛选维度用「来源」(自己加的 / 名册生日派生)而不是"节日/纪念日": + 服务端的 `kind` 是**自由文本**(用户在设置里能自建分类名), + 原来那三个胶囊拿 'anniversary' / 'holiday' 去比, + 而自定义条目存的是「结婚纪念日」这种原文 ⇒ 点了就是空列表。 */ Row({ space: 8 }) { this.kindChip('全部', '') this.kindChip('生日', 'birthday') - this.kindChip('纪念日', 'anniversary') - this.kindChip('节日', 'holiday') + this.kindChip('自己加的', 'custom') + /* 从「设置 → 按分类看」筛过来时,这里给一个能取消的胶囊 */ + if (this.kindFilter.startsWith('kind:')) { + Row({ space: 4 }) { + Text('分类:' + this.kindFilter.substring(5)) + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + Image($r('app.media.fb_ic_close')) + .width(10) + .height(10) + .renderMode(ImageRenderMode.Template) + .colorFilter(fbTint(this.accent)) + } + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(fbAlpha(this.accent, 0.1)) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.kindFilter = ''; + }) + } } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 14, bottom: 8 }) @@ -216,6 +360,11 @@ export struct FbDaysPage { .width('100%') } .width('100%') + // 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏 + /* 🔴 v65 修正:这行原来被贴在了**「即将到来」那条横向 Scroll** 上 + (内层横滑组件吃了一个 100+vp 的底部内边距 ⇒ 那块卡片底下多一截空白, + 而页面真正需要的留白反而没有)。挪回到页面根 `Column` 上。 */ + .padding({ bottom: FB_DOCK_RESERVE }) } .width('100%') .height('100%') @@ -248,12 +397,143 @@ export struct FbDaysPage { this.settingsOpen = false; }, onPick: (action: string): void => { - // 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应) - // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 + /* v65:这条原来 `action === ''`(画灰、点了不响应)。 + 「分类」在服务端是**自由文本**,「分类列表」本身没有 API, + 所以这里能做的、也确实是用户想要的,是"按已经出现过的分类挑一个看"。 */ + if (action === 'daykinds') { + this.kindSheetOpen = true; + this.settingsOpen = false; + return; + } } }) .transition(fbSheetTransition()) } + + // ---- 新建 / 编辑纪念日浮层(v65)---- + if (this.editOpen) { + FbDayEditSheet({ + accent: this.accent, + editingId: this.editingId, + editingTitle: this.editingTitle, + editingKind: this.editingKind, + editingNote: this.editingNote, + editingRepeat: this.editingRepeat, + editingMonth: this.editingMonth, + editingDay: this.editingDay, + editingPersonIds: this.editingPersonIds, + roster: this.roster, + onClose: (): void => { + this.editOpen = false; + }, + onSaved: (): void => { + this.editOpen = false; + this.load(); + }, + onDeleted: (): void => { + this.editOpen = false; + this.load(); + } + }) + .transition(fbSheetTransition()) + } + + // ---- 「按分类看」浮层(v65)---- + if (this.kindSheetOpen) { + /* 遮罩与卡片必须**叠**(`Stack`),不能"排" —— 见 FbDayEditSheet 同一说明 */ + Stack({ alignContent: Alignment.Center }) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .onClick((): void => { + this.kindSheetOpen = false; + }) + + 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) + Text('挑一个分类,只看它下面的纪念日') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + + FbSheetClose({ + onClose: (): void => { + this.kindSheetOpen = false; + }, + color: this.accent + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Scroll() { + Column({ space: 2 }) { + if (this.kindNames().length === 0) { + Text('还没有带分类的纪念日') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .padding({ top: 12, bottom: 12 }) + } else { + ForEach(this.kindNames(), (k: string) => { + Row({ space: 8 }) { + Text(k) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.countOfKind(k).toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .width('100%') + .padding({ top: 11, bottom: 11 }) + .onClick((): void => { + /* ⚠️ 真的去筛(不是只弹一句"有 N 条"): + 值前缀 `kind:` 表示"按分类原文筛",见 `filtered()`。 */ + this.kindFilter = 'kind:' + k; + this.hint = ''; + this.kindSheetOpen = false; + }) + }, (k: string): string => 'ks_' + k) + } + } + .width('100%') + } + .constraintSize({ maxHeight: 320 }) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + + Text('分类名在网页端「纪念日 → 设置 → 纪念日分类」里配;App 这边只按已有分类筛。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .maxLines(3) + } + .width('100%') + .padding({ left: 18, right: 18, top: 18, bottom: 18 }) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + .transition(fbSheetTransition()) + } } .width('100%') .height('100%') @@ -324,7 +604,12 @@ export struct FbDaysPage { .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) - FbTag({ text: this.kindName(a.kind), tone: this.kindTone(a.kind) }) + /* ⚠️ 显示**服务端原文**(用户自建的分类名,如「结婚纪念日」); + 老数据/派生行没有 kind 时才回落到映射出来的类别名。 */ + FbTag({ + text: a.rawKind !== '' ? a.rawKind : this.kindName(a.kind), + tone: this.kindTone(a.kind) + }) } .width('100%') .alignItems(VerticalAlign.Center) @@ -362,6 +647,11 @@ export struct FbDaysPage { } } .width('100%') + /* 🔴 v65:整行可点 → 打开编辑(派生生日给一句提示,见 `openEdit`)。 + 原来这里**没有 `onClick`** ⇒ 用户说"这些都需要增加"。 */ + .onClick((): void => { + this.openEdit(a); + }) } @Builder @@ -429,6 +719,18 @@ export struct FbDaysPage { if (this.kindFilter.length === 0) { return this.all; } - return this.all.filter((a: FbAnniversaryItem): boolean => a.kind === this.kindFilter); + if (this.kindFilter === 'custom') { + // 「自己加的」= 服务端 `source === 'custom'`(派生生日是 birthday_solar/lunar) + return this.all.filter((a: FbAnniversaryItem): boolean => a.source === 'custom'); + } + if (this.kindFilter === 'birthday') { + return this.all.filter((a: FbAnniversaryItem): boolean => a.kind === 'birthday'); + } + // `kind:<分类原文>` —— 按用户自建的分类名筛 + if (this.kindFilter.startsWith('kind:')) { + const name: string = this.kindFilter.substring(5); + return this.all.filter((a: FbAnniversaryItem): boolean => a.rawKind === name); + } + return this.all; } } \ No newline at end of file diff --git a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets index 64dfc07..456c2da 100644 --- a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets +++ b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets @@ -25,6 +25,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; +import { FbLedgerEntrySheet } from '../../components/FbLedgerEntrySheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_LEDGER } from '../../model/FbSettingsMenus'; @@ -60,6 +61,22 @@ export struct FbLedgerPage { @Local syncNote: string = ''; /** 当前账本 id(0 = 用第一本) */ @Local bookId: number = 0; + /** + * 当前这本账的**服务端汇总**(分)。 + * + * 🔴 v65(用户 2026-10-20「还有这个财务的显示也有问题」): + * 原来「资产 / 负债」是端上**自己按金额正负号重新加**的, + * 而服务端的口径是「负债取绝对值」(`Ledger::summary`)—— + * 于是一条填成 `-500` 的欠款会同时满足"是负债"与"是负数", + * 端上加出来是 `-500`、服务端算出来是 `500` ⇒ + * **净资产 ≠ 资产 − 负债**,三个数字互相矛盾。 + * ⇒ 三个数字一律用服务端给的(`summary` 里有 asset / debt / net)。 + */ + @Local netNow: number = 0; + @Local assetNow: number = 0; + @Local debtNow: number = 0; + /** 「记一笔」浮层是否打开 */ + @Local entryOpen: boolean = false; /** * 页面出现:**两级加载**。 @@ -78,6 +95,10 @@ export struct FbLedgerPage { } private backOnce(): boolean { + if (this.entryOpen) { + this.entryOpen = false; + return true; + } if (this.settingsOpen) { this.settingsOpen = false; return true; @@ -117,14 +138,11 @@ export struct FbLedgerPage { parseLedgerEntries, (d: FbLedgerDetail): void => { this.entries = d.entries; - // ⚠️ 净资产写回**这本账**(页面的 `netCent()` 是从 `currentBook()` 上取的)。 - // 概览接口不返回 net,所以这里补上 —— 不新增页面字段, - // 避免和已有的 `netCent()` 方法重名(实测撞过一次)。 - for (const bk of this.books) { - if (bk.id === id) { - bk.netCent = d.netCent; - } - } + /* ⚠️ 净资产/资产/负债**都用服务端那份**(见 `netNow` 的说明)。 + 原来写回 `books[i].netCent` 还有一个隐患:改数组元素不一定会触发刷新。 */ + this.netNow = d.netCent; + this.assetNow = d.assetCent; + this.debtNow = d.debtCent; }, (note: string): void => { this.syncNote = note; @@ -143,6 +161,12 @@ export struct FbLedgerPage { fbAppState().sheetCount = this.settingsOpen ? 1 : 0; } + /** 「记一笔」浮层开着 ⇒ 底部悬浮快捷栏让位(与设置浮层不会同时开) */ + @Monitor('entryOpen') + onEntrySheetChanged(): void { + fbAppState().sheetCount = this.entryOpen ? 1 : 0; + } + build() { // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) @@ -170,7 +194,15 @@ export struct FbLedgerPage { FbPrimaryButton({ label: '记一笔', bg: this.accent, - icon: $r('app.media.fb_ic_plus') + icon: $r('app.media.fb_ic_plus'), + /* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */ + onTap: (): void => { + if (this.currentId() <= 0) { + this.syncNote = '还没有账本 —— 先在网页端建一本,或者在设置里新建。'; + return; + } + this.entryOpen = true; + } }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 @@ -186,6 +218,16 @@ export struct FbLedgerPage { .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) + // 一行提示(同步失败 / 没账本时点「记一笔」) + if (this.syncNote !== '') { + Text(this.syncNote) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 }) + .maxLines(3) + } + // ---- 账本切换(横向滚动条)---- // ⚠️ 这层 `Scroll` **必须保留**:它是**横向**滚动(账本多时排不下)。 // 它与"页面纵向滚动"是两码事,不能被拍平(拍平会整条挤在一起)。 @@ -210,7 +252,11 @@ export struct FbLedgerPage { .borderRadius(FbMetric.radiusPill) .alignItems(VerticalAlign.Center) .onClick(() => { + /* 🔴 v65:切账本必须**重拉明细**。 + 原来只改了 `bookId`,`entries` 还是上一本的 + ⇒ 标题换了、账目和净资产仍旧是别的账本的(用户说的"显示有问题")。 */ this.bookId = b.id; + this.loadEntries(b.id); }) }, (b: FbLedgerBook): string => 'bk_' + b.id.toString()) } @@ -337,8 +383,6 @@ export struct FbLedgerPage { }, (e: FbLedgerEntry): string => 'le_' + e.id.toString()) } .width('100%') - // 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏 - .padding({ bottom: FB_DOCK_RESERVE }) } .padding(FbMetric.cardPad) .backgroundColor(FbColors.surface) @@ -353,6 +397,11 @@ export struct FbLedgerPage { .width('100%') } .width('100%') + // 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏 + /* 🔴 v65 修正:这行原来被贴在了**每个类别分组的内层 Column** 上 + (内层吃了一个 100+vp 的底部内边距 ⇒ 每组账目下面多一截空白, + 而页面真正需要的留白反而没有)。挪回页面根 `Column`。 */ + .padding({ bottom: FB_DOCK_RESERVE }) } .width('100%') .height('100%') @@ -385,8 +434,27 @@ export struct FbLedgerPage { this.settingsOpen = false; }, onPick: (action: string): void => { - // 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应) - // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 + /* 「共享给谁」「账本名字」**都没有 API**(服务端只开了 + 建账本 / 记一笔 / 删账本),所以这里保持"暂未开放"的灰态, + 不接一个点了报错的入口。 */ + } + }) + .transition(fbSheetTransition()) + } + + // ---- 记一笔浮层(v65)---- + if (this.entryOpen) { + FbLedgerEntrySheet({ + accent: this.accent, + ledgerId: this.currentId(), + ledgerName: this.currentBook().name, + onClose: (): void => { + this.entryOpen = false; + }, + onSaved: (): void => { + this.entryOpen = false; + // 重拉明细:金额与汇总都在里面,端上拼不出来 + this.loadEntries(this.currentId()); } }) .transition(fbSheetTransition()) @@ -421,18 +489,13 @@ export struct FbLedgerPage { .alignItems(HorizontalAlign.Start) .layoutWeight(1) - Text(fbMoney(e.amountCent)) + /* ⚠️ 金额按**服务器的负债语义**显示(`Ledger::isDebt` 同一份判断): + 负债类(贷款 / 信用卡 / 欠款)一律显示成负号 —— 用户可能填的是正数 + (「我欠 5000」),直接显示 `5000` 会让人以为是资产。 */ + Text(fbMoney(this.shownCent(e))) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) - .fontColor(e.amountCent < 0 ? FbColors.red : FbColors.ink) - - // 「盘点」:Web 端是每行一个入口,端上保留成一个轻按钮 - Text('盘点') - .fontSize(10) - .fontColor(this.accent) - .padding({ left: 8, right: 8, top: 4, bottom: 4 }) - .backgroundColor(fbAlpha(this.accent, 0.08)) - .borderRadius(FbMetric.radiusPill) + .fontColor(this.shownCent(e) < 0 ? FbColors.red : FbColors.ink) } .width('100%') .padding({ top: 11, bottom: 11 }) @@ -465,37 +528,43 @@ export struct FbLedgerPage { } private netCent(): number { - return this.currentBook().netCent; + return this.netNow; } + /** 总资产(分)—— **服务端口径**(`Ledger::summary`),不是端上按正负号重加 */ private assetCent(): number { - let sum: number = 0; - for (const e of this.entries) { - if (e.amountCent > 0) { - sum = sum + e.amountCent; - } - } - return sum; + return this.assetNow; } + /** 总负债(分,正数)—— 服务端按绝对值计入,见 `netNow` 的说明 */ private debtCent(): number { - let sum: number = 0; - for (const e of this.entries) { - if (e.amountCent < 0) { - sum = sum + e.amountCent; - } - } - return sum; + return this.debtNow; } private sumOf(items: FbLedgerEntry[]): number { let sum: number = 0; for (const e of items) { - sum = sum + e.amountCent; + sum = sum + this.shownCent(e); } return sum; } + /** 这个类别是不是负债(与 `Ledger::KINDS` 的第 2 列一致) */ + private isDebtKind(kind: string): boolean { + return kind === 'loan' || kind === 'credit' || kind === 'owe'; + } + + /** + * 一条账目**该显示多少分**。 + * + * ⚠️ 负债类一律取负:用户可能把欠款填成正数,直接显示会看成资产。 + * 服务端 `summary` 也是这个口径(负债按绝对值计入总负债)。 + */ + private shownCent(e: FbLedgerEntry): number { + const v: number = e.amountCent; + return this.isDebtKind(e.kind) ? -Math.abs(v) : v; + } + private groups(): FbGroup[] { const keys: string[] = []; const buckets: FbLedgerEntry[][] = []; diff --git a/entry/src/main/ets/pages/misc/FbMiscPages.ets b/entry/src/main/ets/pages/misc/FbMiscPages.ets index 44e3d3a..1cb09d1 100644 --- a/entry/src/main/ets/pages/misc/FbMiscPages.ets +++ b/entry/src/main/ets/pages/misc/FbMiscPages.ets @@ -19,6 +19,9 @@ import { loadModule } from '../../data/FbModuleLoad'; import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { FbNoteSheet } from '../../components/FbNoteSheet'; +import { FB_BACK } from '../../common/FbBack'; +import { fbSheetTransition } from '../../common/FbAnim'; // ============================================================ // 笔记 @@ -40,15 +43,65 @@ export struct FbNotesPage { @Local all: FbNoteItem[] = []; /** 顶部提示 */ @Local syncNote: string = ''; + /** + * 笔记浮层是否打开(新建 / 看 / 改 / 删)。 + * + * 🔴 v65(用户 2026-10-20):「笔记现在也点不进去,记笔记也记不了。」 + * ⇒ 原来「记一条」按钮**没有 `onTap`**、卡片**没有 `onClick`**。 + */ + @Local sheetOpen: boolean = false; + @Local sheetId: number = 0; + @Local sheetTitle: string = ''; + @Local sheetBody: string = ''; + @Local sheetTags: string = ''; + @Local sheetMine: boolean = true; /** * 页面出现:读缓存 → 拉接口(本地优先)。 * ⚠️ 不自己开库:`EntryAbility.onCreate` 已统一打开。 */ aboutToAppear(): void { + FB_BACK.register('notes', (): boolean => this.backOnce()); this.load(); } + aboutToDisappear(): void { + FB_BACK.unregister('notes'); + } + + /** 返回手势:先关浮层(true = 已消费) */ + private backOnce(): boolean { + if (this.sheetOpen) { + this.sheetOpen = false; + return true; + } + return false; + } + + /** 浮层开着 ⇒ 底部悬浮快捷栏让位 */ + @Monitor('sheetOpen') + onSheetChanged(): void { + fbAppState().sheetCount = this.sheetOpen ? 1 : 0; + } + + private openNew(): void { + this.sheetId = 0; + this.sheetTitle = ''; + this.sheetBody = ''; + this.sheetTags = ''; + this.sheetMine = true; + this.sheetOpen = true; + } + + private openNote(n: FbNoteItem): void { + this.sheetId = n.id; + this.sheetTitle = n.title; + this.sheetBody = n.body; + this.sheetTags = n.tags.join(' '); + this.sheetMine = n.mine; + this.sheetOpen = true; + } + private async load(): Promise { await loadModule('notes', parseNotes, (rows: FbNoteItem[]): void => { @@ -60,29 +113,36 @@ export struct FbNotesPage { } build() { - Scroll() { - Column() { - this.topBar() + /* 🔴 v65:外层补一层 `Stack` —— 笔记浮层要浮在页面之上 + (本页原来是 `Scroll` 直接作根,没有可以放浮层的容器)。 */ + Stack({ alignContent: Alignment.Center }) { + Scroll() { + Column() { + this.topBar() - // 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「笔记」) - Row({ space: 8 }) { - Column({ space: 2 }) { - Text(this.all.length.toString() + ' 条 · 分块 + 标签') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) + // 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「笔记」) + Row({ space: 8 }) { + Column({ space: 2 }) { + Text(this.all.length.toString() + ' 条 · 分块 + 标签') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + FbPrimaryButton({ + label: '记一条', + bg: this.accent, + icon: $r('app.media.fb_ic_plus'), + /* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */ + onTap: (): void => { + this.openNew(); + } + }) } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - - FbPrimaryButton({ - label: '记一条', - bg: this.accent, - icon: $r('app.media.fb_ic_plus') - }) - } - .width('100%') - .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 }) - .alignItems(VerticalAlign.Center) + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 }) + .alignItems(VerticalAlign.Center) if (this.all.length === 0) { FbEmptyBox({ @@ -114,18 +174,45 @@ export struct FbNotesPage { .width('100%') // 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏 .padding({ bottom: FB_DOCK_RESERVE }) + } + .width('100%') + .height('100%') + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .align(Alignment.TopStart) + // 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets + .onDidScroll(fbOnScroll) + + // 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断) + // 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets + .onTouch(fbOnTouch) + + // ---- 笔记浮层:新建 / 看 / 改 / 删(v65)---- + if (this.sheetOpen) { + FbNoteSheet({ + accent: this.accent, + editingId: this.sheetId, + editingTitle: this.sheetTitle, + editingBody: this.sheetBody, + editingTags: this.sheetTags, + mine: this.sheetMine, + onClose: (): void => { + this.sheetOpen = false; + }, + onSaved: (): void => { + this.sheetOpen = false; + this.load(); + }, + onDeleted: (): void => { + this.sheetOpen = false; + this.load(); + } + }) + .transition(fbSheetTransition()) + } } .width('100%') .height('100%') - .scrollBar(BarState.Off) - .edgeEffect(EdgeEffect.Spring) - .align(Alignment.TopStart) - // 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets - .onDidScroll(fbOnScroll) - - // 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断) - // 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets - .onTouch(fbOnTouch) } @Builder @@ -180,6 +267,10 @@ export struct FbNotesPage { .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .alignItems(HorizontalAlign.Start) + /* 🔴 v65:整卡可点 → 打开看/改(原来**没有 `onClick`** ⇒ "点不进去") */ + .onClick((): void => { + this.openNote(n); + }) } } diff --git a/entry/src/main/ets/pages/resources/FbResourcesPage.ets b/entry/src/main/ets/pages/resources/FbResourcesPage.ets index 4d745f6..2bbebad 100644 --- a/entry/src/main/ets/pages/resources/FbResourcesPage.ets +++ b/entry/src/main/ets/pages/resources/FbResourcesPage.ets @@ -19,6 +19,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; +import { FbResourceSheet } from '../../components/FbResourceSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus'; @@ -44,8 +45,24 @@ export struct FbResourcesPage { @Local settingsOpen: boolean = false; @Local all: FbResourceItem[] = []; - /** 当前筛选类别('' = 全部) */ - @Local kindFilter: string = ''; + /** + * 当前筛选范围:'' = 全部 / 'shared' = 共享给我的 / 'mine' = 我名下的。 + * + * 🔴 v65:原来这里是「证件 / 资料」两个类别 —— 服务端 `resources` 表 + * **没有类别列**(`app/Schema.php` 只有 title / content / owner_id), + * 解析时 kind 只能全填 `other` ⇒ 这两个胶囊点了永远是空列表。 + * 改成按**权限/归属**筛:这两个维度服务端真的给(`perm` / `owner_id`)。 + */ + @Local scopeFilter: string = ''; + + /** 资源浮层(新建 / 查看 / 编辑 / 删除)是否打开 */ + @Local sheetOpen: boolean = false; + /** 浮层里那条(0 = 新建) */ + @Local sheetId: number = 0; + @Local sheetTitle: string = ''; + @Local sheetContent: string = ''; + @Local sheetOwner: string = ''; + @Local sheetCanWrite: boolean = true; /** * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。 @@ -63,6 +80,10 @@ export struct FbResourcesPage { } private backOnce(): boolean { + if (this.sheetOpen) { + this.sheetOpen = false; + return true; + } if (this.settingsOpen) { this.settingsOpen = false; return true; @@ -100,6 +121,34 @@ export struct FbResourcesPage { fbAppState().sheetCount = this.settingsOpen ? 1 : 0; } + /** 资源浮层开着 ⇒ 底部悬浮快捷栏同样让位(两个浮层不会同时开) */ + @Monitor('sheetOpen') + onResourceSheetChanged(): void { + fbAppState().sheetCount = this.sheetOpen ? 1 : 0; + } + + /** 打开「新增资源」 */ + private openNew(): void { + this.sheetId = 0; + this.sheetTitle = ''; + this.sheetContent = ''; + this.sheetOwner = ''; + this.sheetCanWrite = true; + this.sheetOpen = true; + } + + /** 打开某条资源(只读共享进来的锁输入框,但内容照看) */ + private openDetail(r: FbResourceItem): void { + this.sheetId = r.id; + this.sheetTitle = r.name; + this.sheetContent = r.content; + this.sheetOwner = r.ownerName; + /* `perm` 是服务端 `Resource::all` 里算好的:owner / write / read。 + ⚠️ 只有 owner 与 write 能改(服务端 `Resource::canWrite` 同一口径)。 */ + this.sheetCanWrite = r.perm === 'owner' || r.perm === 'write'; + this.sheetOpen = true; + } + build() { // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) @@ -126,7 +175,12 @@ export struct FbResourcesPage { FbPrimaryButton({ label: '添加', bg: this.accent, - icon: $r('app.media.fb_ic_plus') + icon: $r('app.media.fb_ic_plus'), + /* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— + 用户说的"新增加资源"没完成就是这条。 */ + onTap: (): void => { + this.openNew(); + } }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 @@ -142,11 +196,11 @@ export struct FbResourcesPage { .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) - // ---- 类别筛选 ---- + // ---- 筛选(全部 / 共享给我 / 我名下)---- Row({ space: 8 }) { - this.kindChip('全部', '') - this.kindChip('证件', 'document') - this.kindChip('资料', 'material') + this.scopeChip('全部', '') + this.scopeChip('共享给我', 'shared') + this.scopeChip('我名下', 'mine') Blank() Text('共享给我 ' + this.sharedCount().toString() + ' 项') .fontSize(FbMetric.fontTiny) @@ -216,8 +270,41 @@ export struct FbResourcesPage { this.settingsOpen = false; }, onPick: (action: string): void => { - // 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应) - // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 + /* v65:两条都接上了(原来都是空串 ⇒ 画灰、点了不响应) */ + if (action === 'resourceShared') { + this.scopeFilter = 'shared'; + this.settingsOpen = false; + return; + } + if (action === 'resourceMine') { + this.scopeFilter = 'mine'; + this.settingsOpen = false; + return; + } + } + }) + .transition(fbSheetTransition()) + } + + // ---- 资源浮层:新建 / 查看 / 编辑 / 删除(v65)---- + if (this.sheetOpen) { + FbResourceSheet({ + accent: this.accent, + editingId: this.sheetId, + editingTitle: this.sheetTitle, + editingContent: this.sheetContent, + ownerName: this.sheetOwner, + canWrite: this.sheetCanWrite, + onClose: (): void => { + this.sheetOpen = false; + }, + onSaved: (): void => { + this.sheetOpen = false; + this.load(); + }, + onDeleted: (): void => { + this.sheetOpen = false; + this.load(); } }) .transition(fbSheetTransition()) @@ -227,13 +314,13 @@ export struct FbResourcesPage { .height('100%') } - /** 一项资源卡 */ + /** 一项资源卡(整卡可点 → 打开详情/编辑) */ @Builder resourceCard(r: FbResourceItem) { Row({ space: 12 }) { - // 类别图标底 + // 图标底:共享来的用「共享」图标,自己名下的用「锁」 Stack({ alignContent: Alignment.Center }) { - Image(this.kindIcon(r.kind)) + Image(r.shared ? $r('app.media.fb_ic_share') : $r('app.media.fb_ic_lock')) .width(19) .height(19) .renderMode(ImageRenderMode.Template) @@ -254,26 +341,26 @@ export struct FbResourcesPage { .textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) if (r.shared) { - FbTag({ text: '已共享', tone: 'green' }) + FbTag({ text: r.perm === 'write' ? '可改' : '只读', tone: 'green' }) } } .width('100%') .alignItems(VerticalAlign.Center) + // 正文预览:资源的正文就是"编号、有效期、放在哪" —— 比附件数有用得多 + if (r.content !== '') { + Text(r.content) + .fontSize(10) + .fontColor(FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + Row({ space: 10 }) { Text('归属 ' + r.ownerName) .fontSize(10) .fontColor(FbColors.muted) - Row({ space: 3 }) { - Image($r('app.media.fb_ic_resources')) - .width(11) - .height(11) - .renderMode(ImageRenderMode.Template) - .colorFilter(fbTint(FbTint.faint)) - Text(r.fileCount.toString() + ' 个附件') - .fontSize(10) - .fontColor(FbColors.faint) - } Text(r.updatedAt) .fontSize(10) .fontColor(FbColors.faint) @@ -295,18 +382,23 @@ export struct FbResourcesPage { .borderRadius(FbMetric.radius) .margin({ bottom: 8 }) .alignItems(VerticalAlign.Center) + /* 🔴 v65:整卡可点 → 打开详情(原来列表项**没有 `onClick`** ⇒ "点不进去") */ + .onClick((): void => { + this.openDetail(r); + }) } + /** 筛选胶囊(全部 / 共享给我 / 我名下) */ @Builder - kindChip(label: string, key: string) { + scopeChip(label: string, key: string) { Text(label) .fontSize(FbMetric.fontSmall) - .fontColor(this.kindFilter === key ? Color.White : FbColors.muted) - .backgroundColor(this.kindFilter === key ? this.accent : FbColors.inputBg) + .fontColor(this.scopeFilter === key ? Color.White : FbColors.muted) + .backgroundColor(this.scopeFilter === key ? this.accent : FbColors.inputBg) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .borderRadius(FbMetric.radiusPill) .onClick(() => { - this.kindFilter = key; + this.scopeFilter = key; }) } @@ -327,21 +419,15 @@ export struct FbResourcesPage { .backgroundColor(FbColors.paper) } - private kindIcon(kind: string): Resource { - if (kind === 'document') { - return $r('app.media.fb_ic_lock'); - } - if (kind === 'material') { - return $r('app.media.fb_ic_book'); - } - return $r('app.media.fb_ic_resources'); - } - private filtered(): FbResourceItem[] { - if (this.kindFilter.length === 0) { + if (this.scopeFilter.length === 0) { return this.all; } - return this.all.filter((r: FbResourceItem): boolean => r.kind === this.kindFilter); + if (this.scopeFilter === 'shared') { + // 共享给我的 = 权限不是 owner 的那些 + return this.all.filter((r: FbResourceItem): boolean => r.shared); + } + return this.all.filter((r: FbResourceItem): boolean => !r.shared); } private groups(): FbGroup[] { diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets index 8eaf856..05b1ee2 100644 --- a/entry/src/main/ets/pages/tasks/FbTasksPage.ets +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -9,9 +9,9 @@ * 看板名仍保留(卡片上那个小色块标签),点它可以切到别的看板。 */ -import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; -import { FbGroup, FbTaskItem } from '../../model/Types'; +import { FbBoardOpt, FbGroup, FbTaskItem } from '../../model/Types'; import { fbGroupTask } from '../../model/DemoData'; import { parseTasks } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; @@ -20,8 +20,14 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; +import { FbSheetRing } from '../../components/FbSheetRing'; +import { FbSheetClose } from '../../components/FbSheetClose'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_TASKS } from '../../model/FbSettingsMenus'; +import { FbTaskEditSheet } from '../../components/FbTaskEditSheet'; +import { setTaskStatus } from '../../data/FbTaskRepo'; +import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; +import { FbApiResult } from '../../data/FbHttp'; @ComponentV2 export struct FbTasksPage { @@ -33,7 +39,6 @@ export struct FbTasksPage { @BuilderParam topBar: () => void; @Param app: FbAppState = fbAppState(); - private boards: string[] = []; /** 模块主题色(任务 = 赭红) */ private accent: string = '#A8574F'; /** @@ -48,6 +53,36 @@ export struct FbTasksPage { @Local tasks: FbTaskItem[] = []; /** 当前筛选的看板('' = 全部) */ @Local boardFilter: string = ''; + /** + * 状态筛选('' = 全部;'done' = 只看已完成)。 + * + * 🔴 v65:设置菜单里的「已完成」原来 `action === ''` ⇒ 点了不响应。 + * 网页版是 `?view=done`(`Task::doneList`),端上不换接口, + * 直接在已拉到的整份列表上筛(数据本来就是全量,没必要多打一次网络)。 + */ + @Local statusFilter: string = ''; + /** + * 新建 / 编辑任务浮层是否打开。 + * + * 🔴 v65(用户 2026-10-20):「任务也是一样啊,任务也点不进去,也没法那个完成, + * 新建任务、设置这些东西都这都没有。」 + * ⇒ 原来「新建任务」按钮**没有 `onTap`**、列表项**没有 `onClick`**, + * 勾完成的那个圈也**没有热区** —— 三件事一件都没接。现在都接上了。 + */ + @Local editOpen: boolean = false; + /** 正在编辑的任务(0 = 新建) */ + @Local editingId: number = 0; + @Local editingTitle: string = ''; + @Local editingNote: string = ''; + /** `2026-10-25 18:00`;空 = 没有期限 */ + @Local editingDueAt: string = ''; + @Local editingBoardId: number = 0; + /** 正在改状态的那条(防连点打重复请求) */ + @Local busyId: number = 0; + /** 「分类与看板」浮层是否打开 */ + @Local boardSheetOpen: boolean = false; + /** 操作失败时的一行红字(如勾完成失败) */ + @Local opNote: string = ''; /** * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。 @@ -65,6 +100,16 @@ export struct FbTasksPage { } private backOnce(): boolean { + /* v65:返回手势要**先关新的浮层**,再关设置菜单 —— + 顺序按"后打开的在前",否则按一次返回会跳关两层。 */ + if (this.editOpen) { + this.editOpen = false; + return true; + } + if (this.boardSheetOpen) { + this.boardSheetOpen = false; + return true; + } if (this.settingsOpen) { this.settingsOpen = false; return true; @@ -102,6 +147,103 @@ export struct FbTasksPage { fbAppState().sheetCount = this.settingsOpen ? 1 : 0; } + /** 新建/编辑浮层开着 ⇒ 底部悬浮快捷栏也要让位(与 `settingsOpen` 不会同时开) */ + @Monitor('editOpen') + onEditChanged(): void { + fbAppState().sheetCount = this.editOpen ? 1 : 0; + } + + /** 「分类与看板」浮层同理 */ + @Monitor('boardSheetOpen') + onBoardSheetChanged(): void { + fbAppState().sheetCount = this.boardSheetOpen ? 1 : 0; + } + + /** 打开「新建」浮层(清空回填字段) */ + private openNew(): void { + this.editingId = 0; + this.editingTitle = ''; + this.editingNote = ''; + this.editingDueAt = ''; + this.editingBoardId = 0; + this.opNote = ''; + this.editOpen = true; + } + + /** 打开「编辑」浮层(把这条任务的字段回填进去) */ + private openEdit(t: FbTaskItem): void { + this.editingId = t.id; + this.editingTitle = t.title; + this.editingNote = t.note; + this.editingDueAt = t.dueAt; + this.editingBoardId = t.boardId; + this.opNote = ''; + this.editOpen = true; + } + + /** + * 勾选 / 取消完成。 + * + * 🔴 **必须走 `/tasks//status`**:服务端 `PATCH /tasks/` 会把 + * 请求里的 status **原样忽略**(见 `FbTaskRepo` 文件头的说明) + * ⇒ 走 PATCH 会"看起来保存成功,但状态没变"。 + * + * ⚠️ 已完成 → 取消完成统一回 `todo`(不回 `doing`:用户只想撤销一次误勾, + * 把它标成"进行中"是替用户做了他没表达的决定)。 + */ + private async toggleDone(t: FbTaskItem): Promise { + if (this.busyId !== 0) { + return; + } + this.busyId = t.id; + this.opNote = ''; + const next: string = t.status === 'done' ? 'todo' : 'done'; + const res: FbApiResult = await setTaskStatus(FB_TOKEN_STORE.get(), t.id, next); + this.busyId = 0; + if (!res.ok) { + this.opNote = res.error !== '' ? res.error : '改状态失败,请稍后再试。'; + return; + } + // 本地先把这条改掉(列表立刻有反馈),再重拉一遍对齐服务端派生字段(逾期等) + this.tasks = this.tasks.map((x: FbTaskItem): FbTaskItem => { + if (x.id !== t.id) { + return x; + } + const y: FbTaskItem = { + id: x.id, title: x.title, status: next, board: x.board, boardColor: x.boardColor, + dueText: x.dueText, overdue: next === 'done' ? false : x.overdue, + assignee: x.assignee, childDone: x.childDone, childTotal: x.childTotal, + boardId: x.boardId, note: x.note, dueAt: x.dueAt + }; + return y; + }); + this.load(); + } + + /** 看板列表(从已加载的任务里反推:`/tasks` 的 boards 里没任务的那些画出来也没用) */ + private boardOpts(): FbBoardOpt[] { + const out: FbBoardOpt[] = []; + const seen: number[] = []; + for (const t of this.tasks) { + if (seen.indexOf(t.boardId) >= 0) { + continue; + } + seen.push(t.boardId); + out.push({ id: t.boardId, name: t.board, color: t.boardColor }); + } + // 没归看板的「其他」放最后(它不是一个真的看板) + return out.sort((a: FbBoardOpt, b: FbBoardOpt): number => { + const ka: number = a.id === 0 ? 999999 : a.id; + const kb: number = b.id === 0 ? 999999 : b.id; + return ka - kb; + }); + } + + /** 某个看板下的任务数(「分类与看板」浮层里显示) */ + private countOf(boardName: string): number { + return this.tasks.filter((t: FbTaskItem): boolean => t.board === boardName).length; + } + build() { // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) @@ -129,7 +271,11 @@ export struct FbTasksPage { FbPrimaryButton({ label: '新建任务', bg: this.accent, - icon: $r('app.media.fb_ic_plus') + icon: $r('app.media.fb_ic_plus'), + /* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */ + onTap: (): void => { + this.openNew(); + } }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 @@ -145,13 +291,43 @@ export struct FbTasksPage { .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) + // 操作失败的一行红字(如勾完成失败)—— 不弹 toast,就地说明 + if (this.opNote !== '') { + Text(this.opNote) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.red) + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 }) + } + // ---- 看板筛选条(横向滚动)---- Scroll() { Row({ space: 8 }) { this.boardChip('全部', '') - ForEach(this.boards, (b: string) => { - this.boardChip(b, b) - }, (b: string): string => 'bd_' + b) + /* v65:看板从已加载的任务里反推(原来这个数组**从来没被赋值** ⇒ + 这条筛选条永远是空的,只剩一个「全部」) */ + ForEach(this.boardOpts(), (b: FbBoardOpt) => { + this.boardChip(b.name, b.name) + }, (b: FbBoardOpt): string => 'bd_' + b.id.toString()) + // 「只看已完成」是设置菜单打开的筛选 ⇒ 这里给个能取消的胶囊 + if (this.statusFilter === 'done') { + Row({ space: 4 }) { + Text('只看已完成') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + Image($r('app.media.fb_ic_close')) + .width(10) + .height(10) + .renderMode(ImageRenderMode.Template) + .colorFilter(fbTint(this.accent)) + } + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(fbAlpha(this.accent, 0.1)) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.statusFilter = ''; + }) + } } .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad }) } @@ -222,17 +398,155 @@ export struct FbTasksPage { this.settingsOpen = false; }, onPick: (action: string): void => { - // 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应) - // ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 + /* v65:这两条原来 `action === ''`(画灰、点了不响应)。 + 现在都接上了: + · taskdone → 只看已完成(对应网页版 `?view=done`) + · taskboards → 看板列表(挑一个看板只看它;增删改看板仍在网页端) */ + if (action === 'taskdone') { + this.statusFilter = 'done'; + this.settingsOpen = false; + return; + } + if (action === 'taskboards') { + this.boardSheetOpen = true; + this.settingsOpen = false; + return; + } } }) .transition(fbSheetTransition()) } + + // ---- 新建 / 编辑任务浮层(v65)---- + if (this.editOpen) { + FbTaskEditSheet({ + accent: this.accent, + boards: this.boardOpts(), + editingId: this.editingId, + editingTitle: this.editingTitle, + editingNote: this.editingNote, + editingDueAt: this.editingDueAt, + editingBoardId: this.editingBoardId, + onClose: (): void => { + this.editOpen = false; + }, + onSaved: (): void => { + this.editOpen = false; + // 服务端会派生逾期/看板色,保存后重拉一遍 + this.load(); + } + }) + .transition(fbSheetTransition()) + } + + // ---- 「分类与看板」浮层(v65)---- + if (this.boardSheetOpen) { + /* 🔴 遮罩与卡片必须**叠**(`Stack`),不能当 `Column` 的子项"排" —— + 排会把卡片挤出屏幕(本项目 2026-10-20 真机事故,见 FbTaskEditSheet 同一说明)。 */ + Stack({ alignContent: Alignment.Center }) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .onClick((): void => { + this.boardSheetOpen = false; + }) + + 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) + Text('挑一个看板,只看它的任务') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + + FbSheetClose({ + onClose: (): void => { + this.boardSheetOpen = false; + }, + color: this.accent + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Scroll() { + Column({ space: 2 }) { + this.boardRow('全部', '', '', this.tasks.length) + ForEach(this.boardOpts(), (b: FbBoardOpt) => { + this.boardRow(b.name, b.name, b.color, this.countOf(b.name)) + }, (b: FbBoardOpt): string => 'bs_' + b.id.toString()) + } + .width('100%') + } + .constraintSize({ maxHeight: 340 }) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + + Text('增删改看板、改分类,仍在网页端「任务 → 分类与看板」里做。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .maxLines(2) + } + .width('100%') + .padding({ left: 18, right: 18, top: 18, bottom: 18 }) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + .transition(fbSheetTransition()) + } } .width('100%') .height('100%') } + /** 「分类与看板」浮层里的一行:色点 + 名字 + 条数(点它切筛选) */ + @Builder + boardRow(label: string, key: string, color: string, count: number) { + Row({ space: 8 }) { + FbDot({ dia: 8, fillC: color !== '' ? color : this.accent }) + Text(label) + .fontSize(FbMetric.fontSmall) + .fontColor(this.boardFilter === key ? this.accent : FbColors.ink) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(count.toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + if (this.boardFilter === key) { + Image($r('app.media.fb_ic_check')) + .width(12) + .height(12) + .renderMode(ImageRenderMode.Template) + .colorFilter(fbTint(this.accent)) + } + } + .width('100%') + .padding({ left: 6, right: 6, top: 11, bottom: 11 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.boardFilter = key; + this.boardSheetOpen = false; + }) + } + /** 看板筛选胶囊 */ @Builder boardChip(label: string, key: string) { @@ -265,17 +579,30 @@ export struct FbTasksPage { .backgroundColor(FbColors.paper) } - /** 一张任务卡 */ + /** 一张任务卡(整卡可点 → 打开编辑;左边的圈单独点 → 勾完成) */ @Builder taskCard(t: FbTaskItem) { Column({ space: 8 }) { Row({ space: 10 }) { - // 状态圈:完成=实心+勾、进行中=实心+白点、待办=空心 - FbCheckCircle({ - dia: 16, - done: t.status === 'done', - doing: t.status === 'doing', - color: this.accent + /* 状态圈 + 一块透明热区。 + 🔴 v65:原来这个圈只有 16vp 且**没有 `onClick`** ⇒ + 「没法那个完成」就是这条 —— 点它什么都不发生。 + ⇒ 外面包一层 32×32 的热区(手指点得中),并接上改状态。 + ⚠️ 内层有点击时父级的整卡点击**不会**再触发(ArkUI 由最内层消费), + 所以勾完成不会顺带弹出编辑浮层。 */ + Stack({ alignContent: Alignment.Center }) { + FbCheckCircle({ + dia: 16, + done: t.status === 'done', + doing: t.status === 'doing', + color: this.accent + }) + } + .width(32) + .height(32) + .flexShrink(0) + .onClick((): void => { + this.toggleDone(t); }) Text(t.title) @@ -336,16 +663,42 @@ export struct FbTasksPage { .borderRadius(FbMetric.radius) .margin({ bottom: 8 }) .alignItems(HorizontalAlign.Start) + /* 🔴 v65:整卡可点 → 打开编辑(原来列表项**没有 `onClick`** ⇒ "点不进去") */ + .onClick((): void => { + this.openEdit(t); + }) } private filtered(): FbTaskItem[] { - if (this.boardFilter.length === 0) { - return this.tasks; + let list: FbTaskItem[] = this.tasks; + if (this.boardFilter.length > 0) { + list = list.filter((t: FbTaskItem): boolean => t.board === this.boardFilter); } - return this.tasks.filter((t: FbTaskItem): boolean => t.board === this.boardFilter); + /* v65:设置里的「已完成」(对应网页版 `?view=done`)。 + ⚠️ 只看已完成时**不再按状态分组**(一份列表里只有一个组,分组头就没意义了), + 见 `groups()`。 */ + if (this.statusFilter === 'done') { + list = list.filter((t: FbTaskItem): boolean => t.status === 'done'); + } + return list; } private groups(): FbGroup[] { + if (this.statusFilter === 'done') { + const items: FbTaskItem[] = this.filtered(); + if (items.length === 0) { + return []; + } + const one: FbGroup = { + key: 'done', + title: '已完成', + items: items, + count: items.length.toString() + ' 项' + }; + const only: FbGroup[] = []; + only.push(one); + return only; + } return fbGroupTask(this.filtered()); } diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets index d4cc86f..5fbfd1a 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -12,7 +12,7 @@ * 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。 */ -import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbTripItem, FbTripNode } from '../../model/Types'; import { fbMoney } from '../../model/DemoData'; @@ -25,6 +25,12 @@ import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_TRIPS } from '../../model/FbSettingsMenus'; +/* v65:题图要走媒体代理(`Image()` 加不了自定义请求头,令牌得用 URL query 带过去) */ +import { fbImageSrc } from '../../data/FbAttachQueue'; +import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; +import { fetchTripNodes } from '../../data/FbTripRepo'; +import { FbTripNewSheet } from '../../components/FbTripNewSheet'; +import { FbTripNodeSheet } from '../../components/FbTripNodeSheet'; @ComponentV2 export struct FbTripsPage { @@ -66,6 +72,27 @@ export struct FbTripsPage { private accent: string = '#3F7D5A'; /** 当前展开的旅程 id(0 = 都没展开) */ @Local expandedId: number = 0; + /** + * 「新建旅程」浮层是否打开(v65)。 + * + * 🔴 原先这颗按钮**没有 `onTap`** ⇒ 点了什么都不发生(用户: + * 「新建旅程啊…这些东西都还没完成」)。现在接上了。 + */ + @Local newOpen: boolean = false; + /** 正在编辑的旅程(0 = 新建)。点卡片的「编辑」用 */ + @Local editingId: number = 0; + @Local editingTitle: string = ''; + @Local editingSummary: string = ''; + /** 行程节点正在加载(展开时拉 `/trips//nodes`) */ + @Local nodesLoading: boolean = false; + /** 节点加载失败的说明(空 = 没失败) */ + @Local nodesNote: string = ''; + /** 哪一趟旅程的节点是「当前这份 `nodes`」(避免 A 的节点画到 B 上) */ + @Local nodesTripId: number = 0; + /** 题图加载失败集合(一张图坏了不该反复重试,会让列表一直闪) */ + @Local coverFailedIds: number[] = []; + /** 「加一段行程」浮层是否打开,以及它属于哪一趟旅程(0 = 关着) */ + @Local nodeSheetTripId: number = 0; /** * 页面出现:先缓存、再网络(与观影/名册同一套"本地优先"流程)。 @@ -84,10 +111,25 @@ export struct FbTripsPage { } private backOnce(): boolean { + /* v65:返回手势要**先关新的浮层**,再关设置菜单 —— + 顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 */ + if (this.nodeSheetTripId > 0) { + this.nodeSheetTripId = 0; + return true; + } + if (this.newOpen) { + this.newOpen = false; + return true; + } if (this.settingsOpen) { this.settingsOpen = false; return true; } + // 行程展开着时,返回先收起它(否则用户只能靠再点一次卡片) + if (this.expandedId !== 0) { + this.expandedId = 0; + return true; + } return false; } @@ -119,6 +161,23 @@ export struct FbTripsPage { fbAppState().sheetCount = this.settingsOpen ? 1 : 0; } + /** + * 新建/编辑浮层开着 ⇒ 底部悬浮快捷栏也要让位。 + * + * ⚠️ 与 `settingsOpen` 各管一个 @Monitor:两个浮层**不会同时开** + * (新建时设置菜单已关),所以这里直接按 `newOpen` 赋值即可。 + */ + @Monitor('newOpen') + onNewChanged(): void { + fbAppState().sheetCount = this.newOpen ? 1 : 0; + } + + /** 「加一段行程」浮层同样要让悬浮栏让位 */ + @Monitor('nodeSheetTripId') + onNodeSheetChanged(): void { + fbAppState().sheetCount = this.nodeSheetTripId > 0 ? 1 : 0; + } + build() { // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) @@ -144,7 +203,15 @@ export struct FbTripsPage { FbPrimaryButton({ label: '新建旅程', bg: this.accent, - icon: $r('app.media.fb_ic_plus') + icon: $r('app.media.fb_ic_plus'), + /* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设, + 点了什么都不发生(用户报的"新建旅程没完成"就是这条)。 */ + onTap: (): void => { + this.editingId = 0; + this.editingTitle = ''; + this.editingSummary = ''; + this.newOpen = true; + } }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 @@ -221,92 +288,258 @@ export struct FbTripsPage { }) .transition(fbSheetTransition()) } + + // ---- 新建 / 编辑旅程浮层(v65)---- + if (this.newOpen) { + FbTripNewSheet({ + accent: this.accent, + editingId: this.editingId, + editingTitle: this.editingTitle, + editingSummary: this.editingSummary, + onClose: (): void => { + this.newOpen = false; + }, + onSaved: (): void => { + this.newOpen = false; + // 保存后重新拉一遍列表(服务端的城市/时间是派生的,本地拼不出来) + this.expandedId = 0; + this.load(); + } + }) + .transition(fbSheetTransition()) + } + + // ---- 加一段行程浮层(v65)---- + if (this.nodeSheetTripId > 0) { + FbTripNodeSheet({ + tripId: this.nodeSheetTripId, + accent: this.accent, + onClose: (): void => { + this.nodeSheetTripId = 0; + }, + onSaved: (): void => { + const id: number = this.nodeSheetTripId; + this.nodeSheetTripId = 0; + // 加完重拉节点(服务端会重算派生时间,列表也要跟着变) + this.load(); + this.loadNodes(id); + } + }) + .transition(fbSheetTransition()) + } } .width('100%') .height('100%') } - /** 一张旅程卡:标题 + 时间 + 进度;展开后接行程时间轴 */ + /** + * 一张旅程卡:**题图 + 标题 + 时间/城市/段数 + 简介 + 同行人**。 + * + * 🔴 v65(用户 2026-10-20):「旅程的列表也不太对呀,应该有,题图啊、标题啊、 + * 然后城市啊,应该跟网页版的题图是类似的。」 + * ⇒ 照 `templates/trips/_list_body.php` 的 `trip-card` 重做: + * 网页版是**横排**(左题图 132×96 + 右 `trip-main`)。 + * 手机上按同样比例缩到 96×72(4:3),右边留足文字宽度。 + * + * ⚠️ 题图比例:网页版列表卡是 132×96 ≈ 4:3,而**详情页**的 hero 是 16:9。 + * 这里按列表卡的 4:3 走(与用户要的"跟网页版类似"一致)。 + */ @Builder tripCard(t: FbTripItem) { Column({ space: 0 }) { - // 卡头 - Row({ space: 10 }) { - // 左侧色条(模块色的竖条,一眼区分旅程) - Column() - .width(4) - .height(38) - .borderRadius(2) - .backgroundColor(this.accent) + // ---- 卡头:左题图 + 右内容(横排,同网页版)---- + Row({ space: 12 }) { + // 题图:有图放真图,没图放占位(网页版是 🧳) + Stack({ alignContent: Alignment.Center }) { + if (t.coverKey !== '') { + Image(fbImageSrc(t.coverKey, FB_TOKEN_STORE.get())) + .width('100%') + .height('100%') + .objectFit(ImageFit.Cover) + // ⚠️ 不给 alt 图标:旅程封面是实景照,失败时给纯色底比给个 + // "行李箱图标"更不刺眼(网页版失败时也是底色调) + .onError((): void => { + this.markCoverFailed(t.id); + }) + } + // 有图但加载失败时,也别留一块透明 —— 统一给底色 + 图标 + if (t.coverKey === '' || this.coverFailed(t.id)) { + Column({ space: 4 }) { + Image($r('app.media.fb_ic_trips')) + .width(22) + .height(22) + .renderMode(ImageRenderMode.Template) + .colorFilter(fbTint(FbTint.faint)) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .backgroundColor('#F1E7D6') + } + } + .width(96) + .height(72) + .borderRadius(FbMetric.radiusS) + .clip(true) + .flexShrink(0) - Column({ space: 3 }) { + // 右侧:标题 / meta / 简介 + Column({ space: 4 }) { Text(t.title) .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) - .maxLines(1) + .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + + // meta 一行:时间 · 📍城市 · N 段行程 + Row({ space: 10 }) { + if (t.dateRange !== '') { + Text(t.dateRange) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(1) + } + if (t.city !== '') { + Text('📍 ' + t.city) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(1) + .layoutWeight(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } else { + Column().layoutWeight(1) + } + } + .width('100%') + + // 段数 + 同行人数(网页版是一排 meta,这里小屏分两行更清楚) Row({ space: 8 }) { - Text(t.dateRange) + Text(t.nodeCount.toString() + ' 段行程') + .fontSize(FbMetric.fontTiny) + .fontColor(t.nodeCount > 0 ? this.accent : FbColors.faint) + if (t.doneCount > 0) { + Text('已完成 ' + t.doneCount.toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + if (t.attendeeCount > 0) { + Text('· ' + t.attendeeCount.toString() + ' 人同行') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + } + .width('100%') + + // 简介:网页版截 80 字,这里两行截断(手机一行更窄) + if (t.summary !== '') { + Text(t.summary) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) - Text(t.sub) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + .lineHeight(17) } } .alignItems(HorizontalAlign.Start) .layoutWeight(1) - - Column({ space: 3 }) { - if (t.nodeCount > 0) { - Text(t.doneCount.toString() + '/' + t.nodeCount.toString()) - .fontSize(FbMetric.fontSmall) - .fontWeight(FontWeight.Medium) - .fontColor(this.accent) - Text('行程') - .fontSize(10) - .fontColor(FbColors.faint) - } else { - FbTag({ text: '还没行程', tone: 'muted' }) - } + .flexShrink(1) + } + .width('100%') + .padding(FbMetric.cardPad) + .alignItems(VerticalAlign.Top) + .onClick((): void => { + // 点卡片主体 = 展开/收起行程(网页版是"进详情",手机上一屏放不下, + // 就地展开更顺手;编辑走下面那颗小按钮) + const was: boolean = this.isExpanded(t.id); + this.expandedId = was ? 0 : t.id; + if (!was) { + this.loadNodes(t.id); } - .alignItems(HorizontalAlign.End) + }) + // ---- 卡尾操作条:编辑(网页版进详情页才有编辑,这里给个显式入口)---- + Row({ space: 14 }) { + Text(this.isExpanded(t.id) ? '收起行程' : '查看行程') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + Column().layoutWeight(1) + Text('编辑') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.openEdit(t); + }) Image($r('app.media.fb_ic_forward')) - .width(14) - .height(14) + .width(12) + .height(12) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(FbTint.faint)) .rotate({ angle: this.isExpanded(t.id) ? 90 : 0 }) } .width('100%') - .padding(FbMetric.cardPad) + .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 12 }) .alignItems(VerticalAlign.Center) - .onClick(() => { - // 展开/收起:只改本地状态,不发请求 - this.expandedId = this.isExpanded(t.id) ? 0 : t.id; - }) - // 展开区:行程时间轴 + // ---- 展开区:行程时间轴 ---- if (this.isExpanded(t.id)) { Divider() .color(FbColors.line2) .margin({ left: FbMetric.cardPad, right: FbMetric.cardPad }) - if (this.nodes.length === 0) { - Column() { - Text('这个旅程还没有排行程') + if (this.nodesLoading) { + Row({ space: 8 }) { + LoadingProgress() + .width(16) + .height(16) + .color(this.accent) + Text('正在取行程…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } .width('100%') .padding(FbMetric.cardPad) + .alignItems(VerticalAlign.Center) + } else if (this.nodesNote !== '') { + /* 拉失败时**说实话**,不假装"还没排行程" —— + 后者会让用户以为自己的行程丢了。 */ + Column({ space: 6 }) { + Text(this.nodesNote) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + Text('点这里重试') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + .onClick((): void => { + this.loadNodes(t.id); + }) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding(FbMetric.cardPad) + } else if (this.nodes.length === 0) { + Column({ space: 8 }) { + Text('这个旅程还没有排行程') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .width('100%') + this.addNodeBtn(t.id) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding(FbMetric.cardPad) } else { Column({ space: 0 }) { // 分两段列:「实际」(已发生的)在前,「计划」在后 —— 与 Web 端顺序一致 this.stageBlock('实际', 'actual') this.stageBlock('计划', 'plan') + // 已有节点时,加号排在最后(像网页版时间轴的末尾) + this.addNodeBtn(t.id) } .width('100%') .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: FbMetric.cardPad }) @@ -320,6 +553,94 @@ export struct FbTripsPage { .alignItems(HorizontalAlign.Start) } + /** 打开编辑浮层(预填当前值) */ + private openEdit(t: FbTripItem): void { + this.editingId = t.id; + this.editingTitle = t.title; + this.editingSummary = t.summary; + this.newOpen = true; + } + + /** 展开区底部那颗「+ 加一段行程」 */ + @Builder + addNodeBtn(tripId: number) { + Row({ space: 6 }) { + Image($r('app.media.fb_ic_plus')) + .width(13) + .height(13) + .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) + .margin({ top: 6 }) + .onClick((): void => { + this.nodeSheetTripId = tripId; + }) + } + + /** + * 拉某趟旅程的行程节点。 + * + * 🔴 v65:这是端上原先**最大**的缺口 —— `nodes` 一直是空数组, + * 所以每趟旅程展开后永远显示"还没有排行程"。 + * 用户原话:「现在这个旅程下面也没有这个行程啊」。 + * + * ⚠️ 三个状态要分清(否则用户会以为数据丢了): + * ① 正在拉 → 转圈;② 拉失败 → 明说"没取到" + 可重试; + * ③ 拉成功但确实没有 → 才说"还没排行程"。 + */ + private async loadNodes(tripId: number): Promise { + if (this.nodesLoading) { + return; + } + this.nodesLoading = true; + this.nodesNote = ''; + this.nodesTripId = tripId; + const rows: FbTripNode[] = await fetchTripNodes(FB_TOKEN_STORE.get(), tripId); + this.nodesLoading = false; + /* ⚠️ 用户可能在等待期间又点了别的旅程 ⇒ 只认"当前还展开着的那一趟", + 否则 A 的节点会画到 B 的展开区里。 */ + if (this.expandedId !== tripId) { + return; + } + this.nodes = rows; + /* 拉回来是空数组有两种可能:真的没有 / 请求失败。`fetchTripNodes` 失败也回 [], + 这里无法区分 ⇒ 由下面 `node_count` 兜一道: + 列表里说有 N 段、这里却是 0 ⇒ 那就是没取到,要明说。 */ + if (rows.length === 0) { + const cur: FbTripItem | null = this.tripById(tripId); + if (cur !== null && cur.nodeCount > 0) { + this.nodesNote = '行程没取到(网络或权限问题),列表里显示有 ' + + cur.nodeCount.toString() + ' 段。'; + } + } + } + + private tripById(id: number): FbTripItem | null { + for (const t of this.trips) { + if (t.id === id) { + return t; + } + } + return null; + } + + private markCoverFailed(id: number): void { + if (this.coverFailedIds.indexOf(id) < 0) { + this.coverFailedIds = this.coverFailedIds.concat([id]); + } + } + + private coverFailed(id: number): boolean { + return this.coverFailedIds.indexOf(id) >= 0; + } + /** 一段阶段块(实际 / 计划)+ 它下面的节点时间轴 */ @Builder stageBlock(title: string, stage: string) { diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 06a3c5a..8f96143 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -72,6 +72,24 @@ "resource": "$profile:backup_config" } ] + }, + // 🔴 v66(用户 2026-10-20「做几个和 syncCalendar 差不多的卡片」): + // 桌面服务卡片。三种尺寸(2×2 / 2×4 / 4×4)都登记在 `form_config.json`, + // 这里只声明**提供方**(FormExtensionAbility)。 + // ⚠️ `type` 必须是 `form`,`metadata` 的名字必须是 `ohos.extension.form`, + // 少一个桌面就把这个应用当成"没有卡片"。 + { + "name": "FbFormAbility", + "srcEntry": "./ets/entryformability/FbFormAbility.ets", + "description": "$string:form_ability_desc", + "type": "form", + "exported": true, + "metadata": [ + { + "name": "ohos.extension.form", + "resource": "$profile:form_config" + } + ] } ] } diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index afe142a..ce35db2 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -11,6 +11,26 @@ { "name": "EntryAbility_label", "value": "家书" + }, + { + "name": "form_ability_desc", + "value": "家书服务卡片" + }, + { + "name": "card_desc", + "value": "今天的日程与最近的纪念日" + }, + { + "name": "card_2x2_name", + "value": "家书 · 今天" + }, + { + "name": "card_2x4_name", + "value": "家书 · 今日日程" + }, + { + "name": "card_4x4_name", + "value": "家书 · 今日全景" } ] -} \ No newline at end of file +} diff --git a/entry/src/main/resources/base/profile/form_config.json b/entry/src/main/resources/base/profile/form_config.json new file mode 100644 index 0000000..2446f9b --- /dev/null +++ b/entry/src/main/resources/base/profile/form_config.json @@ -0,0 +1,55 @@ +{ + "forms": [ + { + "name": "FbCard2x2", + "displayName": "$string:card_2x2_name", + "description": "$string:card_desc", + "src": "./ets/pages/card/FbCard2x2.ets", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "light", + "uiSyntax": "arkts", + "isDefault": true, + "updateEnabled": true, + "updateDuration": 1, + "defaultDimension": "2*2", + "supportDimensions": ["2*2"] + }, + { + "name": "FbCard2x4", + "displayName": "$string:card_2x4_name", + "description": "$string:card_desc", + "src": "./ets/pages/card/FbCard2x4.ets", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "light", + "uiSyntax": "arkts", + "isDefault": false, + "updateEnabled": true, + "updateDuration": 1, + "defaultDimension": "2*4", + "supportDimensions": ["2*4"] + }, + { + "name": "FbCard4x4", + "displayName": "$string:card_4x4_name", + "description": "$string:card_desc", + "src": "./ets/pages/card/FbCard4x4.ets", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "light", + "uiSyntax": "arkts", + "isDefault": false, + "updateEnabled": true, + "updateDuration": 1, + "defaultDimension": "4*4", + "supportDimensions": ["4*4"] + } + ] +}