v65/v66:六个空壳模块接线 + 桌面服务卡片

用户 2026-10-20:
「做几个和 syncCalendar 差不多的卡片。另外其他的功能也逐步完成……旅程的
新建旅程啊,车辆管理、出行方式、节点设置……资源也点不进去……任务也点不
进去,也没法完成……纪念日、新增纪念日……财务的显示也有问题……笔记现在
也点不进去,记笔记也记不了。」

v65 —— 六个模块(任务 / 资源 / 纪念日 / 笔记 / 账本 / 旅程)原本是**同一套
空壳模板**复制出来的:`FbPrimaryButton` 没有 `onTap`、`ListItem` 没有
`onClick`、设置菜单的 `action` 全是空串。这一版把它们全部接上真实接口:

· 任务:新建 / 编辑(`FbTaskEditSheet` + `FbTaskRepo`)、列表可点、勾完成
  (走 `/tasks/<id>/status` —— 服务端 `PATCH` **显式忽略** status,走 Patch
  会得到"看起来成功的假成功")、按看板筛选
· 资源:新增 / 查看 / 改 / 删(`FbResourceSheet` + `FbResourceRepo`);
  过滤维度从**并不存在的**「证件 / 资料」改成按权限与归属
  (`resources` 表没有 category 列,那个筛选一直是筛空的)
· 纪念日:新增 / 编辑(`FbDayEditSheet` + `FbDayRepo`);派生生日
  (id=0、来自名册)只给提示不给编辑 —— 点开 `/days/0` 必然 404
· 笔记:记一条 / 打开 / 改(`FbNoteSheet` + `FbNoteRepo`),正文按 blocks 存
· 账本:记一笔(`FbLedgerEntrySheet` + `FbLedgerRepo`);修掉财务显示自相
  矛盾 —— 资产/负债改回**服务端 summary**(原来端上按金额正负号重算,与
  服务端"负债取绝对值"的口径打架,会算出 净资产 ≠ 资产 − 负债)
· 旅程:题图卡片(封面 + 标题 + 城市)+ 行程 + 新建旅程 + 新建节点

服务端同时修掉两处**静默数据丢失**(网页端因提交格式凑巧没暴露,只有走
API 的客户端会中招):
· 新建笔记传的是 `body`,而 `Note::create()` 只读 `blocks` ⇒ 新笔记只有标题
· 改笔记时 `blocks` 恒为空,而 `Note::update()` 是整组替换 ⇒ 只改标题就把
  正文全删光;另修 `annivPeopleOf()`(`find()` 的 SQL 不含 people,原来那个
  兜底恒为空 ⇒ 只改纪念日标题会清空关联人)

v66 —— 桌面服务卡片(用户选了「桌面服务卡片」这一项):
· `FbFormAbility`(FormExtensionAbility)+ `form_config.json` 三块尺寸:
  2×2 今日概览 / 2×4 左右分栏 / 4×4 今日全景
· `FbCardData` 只打 `/home` 一个接口(与网页首页同源),数据**扁平化**后
  由 `formBindingData` 推给卡片;断网时用上次成功的缓存并注明"离线"
· 卡片是后台进程 ⇒ 请求带 `authProbe: true`:401 **不能**触发全局"踢回
  登录页"(那会顺手把正在用应用的人踢下线,v62 那次事故同一个坑)
· 点卡片 → `postCardAction(router, fbModule)` → `EntryAbility` 读 Want 参数
  直接落到对应模块;补了 `onNewWant`(没有它,第二次点卡片"什么也没发生")
· 4×4 右上角 ↻ 走 `postAction(message)` → `onFormEvent` 重取数再推回

验证:`hvigorw assembleHap` BUILD SUCCESSFUL;打包后的 hap 里确认
`FbFormAbility`(type=form)与 `$profile:form_config` 都在,卡片代码进了
`ets/widgets.abc`。静态守卫 730 条全绿,反向验证 110 条注入全部能让守卫变红。
⚠️ 设备仍断连(`hdc list targets` 为空),真机尚未验收。
This commit is contained in:
2026-10-20 15:27:32 +08:00
parent 3b7238a418
commit b3e1330244
32 changed files with 5572 additions and 227 deletions
@@ -0,0 +1,453 @@
/**
* 家书 FamilyBook · 「新建 / 编辑纪念日」浮层(鸿蒙端)
*
* 对应网页版 `templates/days/form.php`(网页 2026-10-11 起也是**弹框**形态)。
*
* 🔴 用户 2026-10-20:「纪念日、新增纪念日、设置相关的功能。」
* ⇒ 端上「添加」按钮没有 `onTap`、列表行没有 `onClick` —— 都没有接。
*
* 字段照服务端 `annivData()` 的契约:名称 / 按什么重复 / 日期(月+日)/ 分类 / 备注 / 关联人。
*
* ⚠️⚠️ **只存月日,不存年**(`anniversaries` 表就那么设计的):
* 日期控件选完只取 `month` / `day`,年份丢掉。所以这里的文案是
* 「几月几号」而不是「哪一天」—— 别让用户以为它记住了年份。
*
* ⚠️ 分类是**自由文本**:服务端 `kind` 就是用户自己配的分类名
* (`Anniversary::kindsFull` 那份清单**没有 API**,端上取不到)。
* ⇒ 这里给几个预置名当快捷方式 + 一个可自己填的输入框;
* 编辑老数据时若分类不在预置里,也要照原样显示出来(不能一保存就被清空)。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FbDateTimeRow, FbDateTimePicker, fbToday } from './FbDateTimeField';
import { FbPersonPickSheet } from './FbPersonPickSheet';
import { FbRosterPerson } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createDay, updateDay, deleteDay, FbDayDraft } from '../data/FbDayRepo';
import { FbApiResult } from '../data/FbHttp';
/** 重复方式的键(与服务端 `Anniversary::REPEAT` 一字不差) */
const REPEAT_KEYS: string[] = ['solar', 'lunar', 'once'];
const REPEAT_LABELS: string[] = ['每年(阳历)', '每年(农历)', '只此一次'];
/** 预置分类(服务端 `PRESET_KINDS` 的同一份;用户还能自己填) */
const PRESET_KINDS: string[] = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
@ComponentV2
export struct FbDayEditSheet {
/** 模块主题色(纪念日 = 陶土橙) */
@Param accent: string = '#B4633C';
/** 正在编辑的纪念日 id(0 = 新建) */
@Param editingId: number = 0;
@Param editingTitle: string = '';
@Param editingKind: string = '';
@Param editingNote: string = '';
/** solar / lunar / once */
@Param editingRepeat: string = 'solar';
@Param editingMonth: number = 1;
@Param editingDay: number = 1;
@Param editingPersonIds: number[] = [];
/** 名册(选关联人用) */
@Param roster: FbRosterPerson[] = [];
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@Event onDeleted: () => void = (): void => {};
@Local title: string = '';
@Local kind: string = '';
@Local note: string = '';
@Local repeat: string = 'solar';
@Local month: number = 1;
@Local day: number = 1;
/** 日期控件的载体(`YYYY-MM-DD`;年份无意义,只借它拿月日) */
@Local pickDate: string = '';
@Local pickOpen: boolean = false;
@Local personIds: number[] = [];
@Local personPickOpen: boolean = false;
@Local saving: boolean = false;
@Local confirmingDelete: boolean = false;
@Local note2: string = '';
aboutToAppear(): void {
this.title = this.editingTitle;
this.kind = this.editingKind;
this.note = this.editingNote;
this.repeat = this.editingRepeat !== '' ? this.editingRepeat : 'solar';
this.month = this.editingMonth > 0 ? this.editingMonth : 1;
this.day = this.editingDay > 0 ? this.editingDay : 1;
this.personIds = this.editingPersonIds.slice();
/* 载体日期:用**今年**的这一天。年份只为了拼出一个合法 `YYYY-MM-DD`
给日期控件用,提交时只取月日(见文件头)。 */
const y: string = fbToday().substring(0, 4);
this.pickDate = y + '-' + this.pad2(this.month) + '-' + this.pad2(this.day);
}
private pad2(v: number): string {
return v < 10 ? ('0' + v.toString()) : v.toString();
}
/** 下标 → 重复方式中文名(越界给「每年(阳历)」,不抛错) */
private repeatLabel(idx: number): string {
return idx >= 0 && idx < REPEAT_LABELS.length ? REPEAT_LABELS[idx] : REPEAT_LABELS[0];
}
/** 已选的关联人显示名(「全家的事」= 一个都没选) */
private peopleText(): string {
if (this.personIds.length === 0) {
return '全家的事';
}
const names: string[] = [];
for (const id of this.personIds) {
const p: FbRosterPerson | undefined =
this.roster.find((x: FbRosterPerson): boolean => x.id === id);
if (p !== undefined) {
names.push(p.shown !== '' ? p.shown : p.name);
}
}
return names.length > 0 ? names.join('、') : ('已选 ' + this.personIds.length.toString() + ' 人');
}
/** 分类清单:预置 + 当前值(老数据里的自定义分类不能丢) */
private kinds(): string[] {
const out: string[] = [];
for (const k of PRESET_KINDS) {
out.push(k);
}
if (this.kind !== '' && out.indexOf(this.kind) < 0) {
out.push(this.kind);
}
return out;
}
build() {
/* 🔴🔴 根必须 `Stack`:遮罩与卡片要"叠",不能当 `Column` 子项"排"
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.editingId > 0 ? '编辑纪念日' : '新建纪念日')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text('只记几月几号,每年自动提醒')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
// ---- 名称 ----
Row({ space: 8 }) {
Text('叫什么')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(44)
TextInput({ text: this.title, placeholder: '如:结婚纪念日' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.title = v;
this.note2 = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 重复方式 ----
Column({ space: 6 }) {
Text('按什么重复')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 8 }) {
ForEach(REPEAT_KEYS, (k: string, idx: number) => {
Text(this.repeatLabel(idx))
.fontSize(FbMetric.fontTiny)
.fontColor(this.repeat === k ? '#FFFFFF' : FbColors.muted)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(this.repeat === k ? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.repeat = k;
})
}, (k: string): string => 'rp_' + k)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// ---- 日期(月 + 日)----
FbDateTimeRow({
label: '日期',
date: this.pickDate,
allDay: true,
accent: this.accent,
onTap: (): void => {
this.pickOpen = true;
}
})
// ---- 分类 ----
Column({ space: 6 }) {
Text('分类')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Scroll() {
Row({ space: 8 }) {
ForEach(this.kinds(), (k: string) => {
Text(k)
.fontSize(FbMetric.fontTiny)
.fontColor(this.kind === k ? '#FFFFFF' : FbColors.muted)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.kind === k ? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
// 再点一下 = 取消分类(不分类是合法状态)
this.kind = this.kind === k ? '' : k;
})
}, (k: string): string => 'kd_' + k)
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// ---- 关联人 ----
Row({ space: 8 }) {
Text('关联人')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(44)
Text(this.peopleText())
.fontSize(FbMetric.fontSmall)
.fontColor(this.personIds.length > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.personPickOpen = true;
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 备注 ----
Row({ space: 8 }) {
Text('备注')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(44)
TextArea({ text: this.note, placeholder: '想记的细节(可空)' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.layoutWeight(1)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.note = v;
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 400 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
if (this.note2 !== '') {
Text(this.note2)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
// ---- 底部:删除(左)+ 保存 ----
Row({ space: 10 }) {
if (this.editingId > 0) {
Text(this.confirmingDelete ? '真的删掉?' : '删除')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.height(44)
.padding({ left: 16, right: 16 })
.borderRadius(FbMetric.radiusS)
/* 🔴 v61:删除按钮统一红底白字。红值取自 color.json 的 `red` */
.backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86))
.onClick((): void => {
if (this.confirmingDelete) {
this.doDelete();
} else {
this.confirmingDelete = true;
}
})
}
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下来'))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
// ---- 日期选择(只取月日)----
if (this.pickOpen) {
FbDateTimePicker({
title: '几月几号',
date: this.pickDate,
allDay: true,
accent: this.accent,
onConfirm: (d: string, t: string): void => {
/* ⚠️ 只留月/日:服务端存的就是月日(见文件头)。
`d` 是 `YYYY-MM-DD`,第 6-7 位是月、9-10 位是日。 */
if (d.length >= 10) {
this.month = parseInt(d.substring(5, 7), 10);
this.day = parseInt(d.substring(8, 10), 10);
this.pickDate = d;
}
this.pickOpen = false;
},
onCancel: (): void => {
this.pickOpen = false;
}
})
}
// ---- 选关联人(复用观影那套"搜索 + 暂存 + 确定")----
if (this.personPickOpen) {
FbPersonPickSheet({
people: this.roster,
selected: this.personIds,
title: '这条纪念日和谁有关',
accent: this.accent,
onConfirm: (ids: number[]): void => {
this.personIds = ids;
this.personPickOpen = false;
},
onClose: (): void => {
this.personPickOpen = false;
}
})
}
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const t: string = this.title.trim();
if (t === '') {
this.note2 = '纪念日得有个名字(如「结婚纪念日」)。';
return;
}
this.saving = true;
this.note2 = '';
const d: FbDayDraft = {
title: t,
repeatType: this.repeat,
month: this.month,
day: this.day,
kind: this.kind,
note: this.note.trim(),
personIds: this.personIds
};
const token: string = FB_TOKEN_STORE.get();
const res: FbApiResult = this.editingId > 0
? await updateDay(token, this.editingId, d)
: await createDay(token, d);
this.saving = false;
if (!res.ok) {
this.note2 = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.onSaved();
}
private async doDelete(): Promise<void> {
if (this.saving) {
return;
}
this.saving = true;
this.note2 = '';
const res: FbApiResult = await deleteDay(FB_TOKEN_STORE.get(), this.editingId);
this.saving = false;
this.confirmingDelete = false;
if (!res.ok) {
this.note2 = res.error !== '' ? res.error : '删除失败,请稍后再试。';
return;
}
this.onDeleted();
}
}
@@ -0,0 +1,283 @@
/**
* 家书 FamilyBook · 「记一笔」浮层(鸿蒙端)
*
* 对应网页版 `templates/ledger/` 的记一笔表单。
*
* 🔴 用户 2026-10-20:「还有这个财务的显示也有问题。」
* ⇒ 端上「记一笔」按钮原来**没有 `onTap`** —— 点了什么都不发生。
*
* ⚠️ 金额输入的是**元**(可带两位小数),服务端 `Ledger::toCents()` 转分。
* 端上**不做**元→分换算(双重换算会差 100 倍)。
*
* ⚠️ 类别键必须与 `Ledger::KINDS` 一字不差 —— 写错了服务端会落到 `other`,
* 而且**负债**(贷款/信用卡/欠款)落到资产会让净资产算错。所以这里照抄那张表。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { addLedgerEntry } from '../data/FbLedgerRepo';
import { FbApiResult } from '../data/FbHttp';
/** 类别键(顺序与 `Ledger::KINDS` 一致) */
const KIND_KEYS: string[] = ['deposit', 'fund', 'stock', 'insurance', 'house', 'car',
'cash', 'loan', 'credit', 'owe', 'other'];
const KIND_NAMES: string[] = ['存款', '基金', '股票', '保险', '房子', '车子',
'现金', '贷款', '信用卡', '欠款', '其它'];
/** 哪些是负债(`Ledger::isDebt` 同一份判断) */
const DEBT_KEYS: string[] = ['loan', 'credit', 'owe'];
@ComponentV2
export struct FbLedgerEntrySheet {
/** 模块主题色(财务 = 橄榄绿) */
@Param accent: string = '#5E7A3C';
/** 记到哪本账 */
@Param ledgerId: number = 0;
@Param ledgerName: string = '';
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@Local kind: string = 'deposit';
@Local amount: string = '';
@Local title: string = '';
@Local note: string = '';
@Local saving: boolean = false;
@Local err: string = '';
private kindNameOf(k: string): string {
const i: number = KIND_KEYS.indexOf(k);
return i >= 0 ? KIND_NAMES[i] : '其它';
}
/** 下标 → 类别中文名(越界给「其它」,不抛错) */
private kindLabel(idx: number): string {
return idx >= 0 && idx < KIND_NAMES.length ? KIND_NAMES[idx] : '其它';
}
private isDebt(): boolean {
return DEBT_KEYS.indexOf(this.kind) >= 0;
}
build() {
/* 🔴🔴 根必须 `Stack`:遮罩与卡片要"叠",不能当 `Column` 子项"排"
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('记一笔')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text(this.ledgerName !== '' ? ('记到「' + this.ledgerName + '」') : '记到当前账本')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
// ---- 类别 ----
Column({ space: 6 }) {
Text('类别')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Scroll() {
Row({ space: 8 }) {
ForEach(KIND_KEYS, (k: string, idx: number) => {
Text(this.kindLabel(idx))
.fontSize(FbMetric.fontTiny)
.fontColor(this.kind === k ? '#FFFFFF' : FbColors.muted)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.kind === k ? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.kind = k;
})
}, (k: string): string => 'lk_' + k)
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// ---- 金额 ----
Row({ space: 8 }) {
Text('金额')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextInput({ text: this.amount, placeholder: '如 12500 或 860.50' })
.type(InputType.NUMBER_DECIMAL)
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(44)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.amount = v;
this.err = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 负债提示:用户容易把欠款填成正数又不知道对不对
if (this.isDebt()) {
Text('「' + this.kindNameOf(this.kind) + '」算负债 —— 填正数就行,'
+ '净资产会自动减去它(服务端按绝对值计入总负债)。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width('100%')
.maxLines(3)
}
// ---- 名称 ----
Row({ space: 8 }) {
Text('名称')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextInput({ text: this.title, placeholder: '如 工资卡 / 房贷' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.title = v;
this.err = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 备注 ----
Row({ space: 8 }) {
Text('备注')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextArea({ text: this.note, placeholder: '想记的细节(可空)' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.layoutWeight(1)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.note = v;
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
Text('已有账目的金额修改 / 删除还在网页端 —— 接口只开放了"记一笔"。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 400 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
if (this.err !== '') {
Text(this.err)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
Text(this.saving ? '记入中…' : '记下来')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const amt: string = this.amount.trim();
if (amt === '') {
this.err = '金额得填一个数。';
return;
}
this.saving = true;
this.err = '';
const res: FbApiResult = await addLedgerEntry(FB_TOKEN_STORE.get(), this.ledgerId,
this.kind, amt, this.title.trim(), this.note.trim());
this.saving = false;
if (!res.ok) {
this.err = res.error !== '' ? res.error : '没记上,请稍后再试。';
return;
}
this.onSaved();
}
}
@@ -0,0 +1,326 @@
/**
* 家书 FamilyBook · 笔记浮层(新建 / 看 / 改 / 删)(鸿蒙端)
*
* 对应网页版 `templates/notes/`(网页是"卡片 + 详情页",端上合成一个浮层:
* 卡片是一段短文,为它单开一屏不值当)。
*
* 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」
* ⇒ 端上既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、卡片无 `onClick`。
*
* ⚠️ 端上只支持**纯文本**(服务端 `body` → 一个 `text` 块):
* 网页那边可以图文混排(`note_blocks` 有 image 块),端上先不做 ——
* 图片块的内容是附件 key,硬拼进文本框会变成一串乱码。
* 所以已有笔记里的图片块在端上显示成「[图片]」(见 `parseNotes` 的 `noteBodyOf`)。
*
* ⚠️ **只读**:别人共享给我的笔记能看不能改(服务端 `Note::canWrite` 那一套)。
* 端上按 `mine` 判断 —— 保守一点宁可少给编辑,也不能给个点了报 403 的按钮。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createNote, updateNote, deleteNote } from '../data/FbNoteRepo';
import { FbApiResult } from '../data/FbHttp';
@ComponentV2
export struct FbNoteSheet {
/** 模块主题色(笔记 = 褐灰) */
@Param accent: string = '#7A6A4F';
/** 正在看/改的笔记 id(0 = 新建) */
@Param editingId: number = 0;
@Param editingTitle: string = '';
@Param editingBody: string = '';
@Param editingTags: string = '';
/** 是不是我写的(不是就只能看) */
@Param mine: boolean = true;
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@Event onDeleted: () => void = (): void => {};
@Local title: string = '';
@Local body: string = '';
/** 标签输入(用空格或逗号分隔,服务端会自己归一) */
@Local tags: string = '';
@Local saving: boolean = false;
@Local confirmingDelete: boolean = false;
@Local note: string = '';
aboutToAppear(): void {
this.title = this.editingTitle;
this.body = this.editingBody;
this.tags = this.editingTags;
}
/** 能不能改:新建时当然能;已有笔记只有自己写的能改 */
private editOk(): boolean {
return this.editingId <= 0 || this.mine;
}
/** 标签串 → 数组(逗号 / 顿号 / 空格 / # 都当分隔) */
private tagList(): string[] {
const norm: string = this.tags.replace(new RegExp('#', 'g'), ' ')
.replace(new RegExp(',', 'g'), ' ')
.replace(new RegExp(',', 'g'), ' ')
.replace(new RegExp('、', 'g'), ' ');
const parts: string[] = norm.split(' ');
const out: string[] = [];
for (const p of parts) {
const t: string = p.trim();
if (t !== '' && out.indexOf(t) < 0) {
out.push(t);
}
}
return out;
}
build() {
/* 🔴🔴 根必须 `Stack`:遮罩与卡片要"叠",不能当 `Column` 子项"排"
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.editingId > 0 ? (this.mine ? '笔记' : '别人共享的笔记') : '记一条')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text(this.editOk() ? '随手记点什么,按标签归类和检索' : '只读:只能看,改要作者来')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
// ---- 标题 ----
Row({ space: 8 }) {
Text('标题')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextInput({ text: this.title, placeholder: '给这条起个名字' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.enabled(this.editOk())
.onChange((v: string): void => {
this.title = v;
this.note = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 正文 ----
Row({ space: 8 }) {
Text('正文')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextArea({ text: this.body, placeholder: '想记的都写在这儿' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(200)
.layoutWeight(1)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.enabled(this.editOk())
.onChange((v: string): void => {
this.body = v;
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
// ---- 标签 ----
Row({ space: 8 }) {
Text('标签')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextInput({ text: this.tags, placeholder: '空格或逗号分隔,如:读书 摘抄' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.enabled(this.editOk())
.onChange((v: string): void => {
this.tags = v;
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 图片块在端上只能显示成「[图片]」—— 说清楚,别让人以为内容丢了
if (this.editingId > 0 && this.body.indexOf('[图片]') >= 0) {
Text('这条笔记里的图片在网页端看;App 这边先显示成「[图片]」。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(2)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 420 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
// ---- 底部:删除(左,只对自己写的)+ 保存 ----
Row({ space: 10 }) {
if (this.editingId > 0 && this.mine) {
Text(this.confirmingDelete ? '真的删掉?' : '删除')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.height(44)
.padding({ left: 16, right: 16 })
.borderRadius(FbMetric.radiusS)
/* 🔴 v61:删除按钮统一红底白字。红值取自 color.json 的 `red` */
.backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86))
.onClick((): void => {
if (this.confirmingDelete) {
this.doDelete();
} else {
this.confirmingDelete = true;
}
})
}
if (this.editOk()) {
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下来'))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
} else {
Text('知道了')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, 0.9))
.onClick((): void => {
this.onClose();
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const t: string = this.title.trim();
const b: string = this.body.trim();
if (t === '' && b === '') {
this.note = '标题和正文至少写一样(服务端不允许存一条空笔记)。';
return;
}
this.saving = true;
this.note = '';
/* ⚠️ 标题空着也能存(服务端允许纯正文笔记),但列表卡片上没标题会很难认
⇒ 用正文首行兜个标题(只影响显示,不改变用户输入)。 */
const useTitle: string = t !== '' ? t : b.substring(0, 20);
const token: string = FB_TOKEN_STORE.get();
const res: FbApiResult = this.editingId > 0
? await updateNote(token, this.editingId, useTitle, b, this.tagList())
: await createNote(token, useTitle, b, this.tagList());
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.onSaved();
}
private async doDelete(): Promise<void> {
if (this.saving) {
return;
}
this.saving = true;
this.note = '';
const res: FbApiResult = await deleteNote(FB_TOKEN_STORE.get(), this.editingId);
this.saving = false;
this.confirmingDelete = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '删除失败,请稍后再试。';
return;
}
this.onDeleted();
}
}
@@ -0,0 +1,292 @@
/**
* 家书 FamilyBook · 资源浮层(新建 / 查看 / 编辑 / 删除)(鸿蒙端)
*
* 对应网页版 `templates/resources/index.php` 里那个 `#resAddCard` 弹框
* (网页 2026-10-11 起「新建」与「编辑」**复用同一个弹框**,这里是同一套思路:
* 一个组件、`editingId` 为 0 就是新建)。
*
* 🔴 用户 2026-10-20:「资源也是啊,新增加资源,资源也点不进去。」
* ⇒ 端上原来既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、
* 列表项无 `onClick`。这个浮层把两件事一次补齐。
*
* ⚠️⚠️ 服务端只有 title / content(详见 `data/FbResourceRepo` 文件头):
* 归属人固定是当前登录的人、附件与共享**没有 API**。
* ⇒ 这里**故意不摆**「选归属人」「传附件」「共享给谁」这些入口 ——
* 摆了也存不进去,用户会以为存上了。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createResource, updateResource, deleteResource } from '../data/FbResourceRepo';
import { FbApiResult } from '../data/FbHttp';
@ComponentV2
export struct FbResourceSheet {
/** 模块主题色(资源 = 靛蓝) */
@Param accent: string = '#5566A8';
/** 正在看/改的资源 id(0 = 新建) */
@Param editingId: number = 0;
@Param editingTitle: string = '';
@Param editingContent: string = '';
/** 归属人显示名(新建时为空) */
@Param ownerName: string = '';
/** 我能不能改(`perm` 是 owner / write)—— 只读共享进来的不给改不给删 */
@Param canWrite: boolean = true;
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@Event onDeleted: () => void = (): void => {};
@Local title: string = '';
@Local content: string = '';
@Local saving: boolean = false;
/** 删除的二次确认(网页也是先点一次再确认,避免误删证件) */
@Local confirmingDelete: boolean = false;
@Local note: string = '';
aboutToAppear(): void {
this.title = this.editingTitle;
this.content = this.editingContent;
}
build() {
/* 🔴🔴 根必须 `Stack`:全屏遮罩不能当 `Column` 的子项"排"
(排会把卡片挤出屏幕 —— 2026-10-20 真机事故,见 pitfalls.md) */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.editingId > 0 ? '资源' : '新增资源')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text(this.editingId > 0 ? ('归属 ' + this.ownerName) : '挂在你自己名下')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
// ---- 标题 ----
Row({ space: 8 }) {
Text('标题')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextInput({ text: this.title, placeholder: '如:身份证 / 学位证' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.enabled(this.editOk())
.onChange((v: string): void => {
this.title = v;
this.note = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 正文 ----
Row({ space: 8 }) {
Text('正文')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
TextArea({ text: this.content, placeholder: '编号、有效期、放在哪里…' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(120)
.layoutWeight(1)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.enabled(this.editOk())
.onChange((v: string): void => {
this.content = v;
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
// 只读共享进来的:说明白,别让人以为"输入框坏了"
if (!this.canWrite) {
Text('这条是共享给你的,只有只读权限 —— 要改请让归属人在他那边改。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width('100%')
.maxLines(3)
}
// 附件 / 共享:服务端没有接口,照实说明(不摆假入口)
if (this.editingId > 0) {
Text('附件与共享还在网页端「资源」里管 —— App 这边暂未开放。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 380 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
// ---- 底部:删除(左,红底白字)+ 保存(主操作)----
Row({ space: 10 }) {
if (this.editingId > 0 && this.canWrite) {
Text(this.confirmingDelete ? '真的删掉?' : '删除')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.height(44)
.padding({ left: 16, right: 16 })
.borderRadius(FbMetric.radiusS)
/* 🔴 v61:删除按钮统一红底白字。
⚠️ 红色这里写 hex 而不用 `FbColors.red`:那是 `Resource`
(`$r('app.color.red')`),而 `fbAlpha()` 要 hex 字符串。
值取自 `resources/base/element/color.json` 的 `red`,两处必须一致。 */
.backgroundColor(this.confirmingDelete ? '#C0533F' : fbAlpha('#C0533F', 0.86))
.onClick((): void => {
if (this.confirmingDelete) {
this.doDelete();
} else {
this.confirmingDelete = true;
}
})
}
if (this.canWrite) {
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '存下来'))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
} else {
// 只读时给一个"看清楚了"的出口(没有保存,也不能什么都不给)
Text('知道了')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, 0.9))
.onClick((): void => {
this.onClose();
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
/** 能否编辑(只读共享进来的锁输入框,但内容照样能看) */
private editOk(): boolean {
return this.canWrite;
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const t: string = this.title.trim();
if (t === '') {
this.note = '资源得有个标题(如「身份证」)。';
return;
}
this.saving = true;
this.note = '';
const token: string = FB_TOKEN_STORE.get();
const res: FbApiResult = this.editingId > 0
? await updateResource(token, this.editingId, t, this.content)
: await createResource(token, t, this.content);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.onSaved();
}
private async doDelete(): Promise<void> {
if (this.saving) {
return;
}
this.saving = true;
this.note = '';
const res: FbApiResult = await deleteResource(FB_TOKEN_STORE.get(), this.editingId);
this.saving = false;
this.confirmingDelete = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '删除失败,请稍后再试。';
return;
}
this.onDeleted();
}
}
@@ -0,0 +1,356 @@
/**
* 家书 FamilyBook · 「新建 / 编辑任务」浮层(鸿蒙端)
*
* 对应网页版 `templates/tasks/_edit_form.php`。
*
* 🔴 用户 2026-10-20:「新建任务、设置这些东西都这都没有。」
* 端上「新建任务」按钮原来**没有 `onTap`**,列表项也没有 `onClick`。
*
* ⚠️ 编辑态**不显示状态选择**:状态只走 `/tasks/<id>/status` 子动作
* (服务端 `PATCH` 会忽略请求里的 status,见 `FbTaskRepo` 文件头)。
* 在这里放个状态选择器 → 用户改完保存 → 状态没变 ⇒ 典型的"看起来成功了"。
* 改成完成请用列表上的那个圈(见 `FbTasksPage` 的 `toggleDone`)。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint } from '../common/Theme';
import { FbBoardOpt } from '../model/Types';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createTask, updateTask, FbTaskDraft } from '../data/FbTaskRepo';
import { FbApiResult } from '../data/FbHttp';
@ComponentV2
export struct FbTaskEditSheet {
/** 模块主题色(任务 = 赭红) */
@Param accent: string = '#A8574F';
/** 可选的看板(父页面从已加载任务里反推出来) */
@Param boards: FbBoardOpt[] = [];
/** 编辑的这条(0 = 新建) */
@Param editingId: number = 0;
@Param editingTitle: string = '';
@Param editingNote: string = '';
@Param editingDueAt: string = '';
@Param editingBoardId: number = 0;
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@Local title: string = '';
@Local note: string = '';
@Local boardId: number = 0;
/** 截止日期 `YYYY-MM-DD`,空 = 没有期限 */
@Local dueDate: string = '';
/** 截止时分 */
@Local dueTime: string = '18:00';
@Local pickerOpen: boolean = false;
@Local saving: boolean = false;
@Local note2: string = '';
aboutToAppear(): void {
this.title = this.editingTitle;
this.note = this.editingNote;
this.boardId = this.editingBoardId;
// `2026-10-25 18:00` → 日期 + 时分(服务端给的是空格分隔)
const raw: string = this.editingDueAt.trim();
if (raw.length >= 16) {
this.dueDate = raw.substring(0, 10);
this.dueTime = raw.substring(11, 16);
} else if (raw.length >= 10) {
this.dueDate = raw.substring(0, 10);
}
}
build() {
/* 🔴🔴 根必须 `Stack`(见 pitfalls.md:全屏遮罩不能当 Column 子项排) */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.editingId > 0 ? '编辑任务' : '新建任务')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text('写清楚要做什么;期限可以留空')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
// ---- 标题 ----
this.textField('做什么', '如「交电费」「给爸配眼镜」', this.title,
(v: string): void => {
this.title = v;
this.note2 = '';
})
// ---- 备注 ----
this.areaField('备注', '想补充的细节(可空)', this.note,
(v: string): void => {
this.note = v;
})
// ---- 截止时间 ----
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('期限')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(28)
if (this.dueDate !== '') {
Text('不设期限')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.dueDate = '';
})
} else {
Column().layoutWeight(1)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.dueDate === '') {
Row({ space: 6 }) {
Image($r('app.media.fb_ic_plus'))
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
Text('设一个期限')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.padding({ left: 10, right: 12, top: 7, bottom: 7 })
.backgroundColor(fbAlpha(this.accent, 0.08))
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.dueDate = fbIsoOf(new Date());
this.pickerOpen = true;
})
} else {
FbDateTimeRow({
label: '截止',
date: this.dueDate,
time: this.dueTime,
accent: this.accent,
onTap: (): void => {
this.pickerOpen = true;
}
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// ---- 看板 ----
Column({ space: 6 }) {
Text('看板')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Scroll() {
Row({ space: 8 }) {
this.boardChip(0, '不指定', '#A8574F')
ForEach(this.boards, (b: FbBoardOpt) => {
this.boardChip(b.id, b.name, b.color)
}, (b: FbBoardOpt): string => 'tb_' + b.id.toString())
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 360 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
if (this.note2 !== '') {
Text(this.note2)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
// ---- 保存 ----
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '建好这件事'))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
// ---- 日期时间选择 ----
if (this.pickerOpen) {
FbDateTimePicker({
title: '这个任务什么时候到期',
date: this.dueDate !== '' ? this.dueDate : fbIsoOf(new Date()),
time: this.dueTime,
accent: this.accent,
onConfirm: (d: string, t: string): void => {
this.dueDate = d;
this.dueTime = t;
this.pickerOpen = false;
},
onCancel: (): void => {
this.pickerOpen = false;
}
})
}
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const title: string = this.title.trim();
if (title === '') {
this.note2 = '任务得写清楚要做什么。';
return;
}
this.saving = true;
this.note2 = '';
const d: FbTaskDraft = {
title: title,
note: this.note.trim(),
// 没有期限就发空串(服务端 `Api::dt('due_at', '')` → null)
dueAt: this.dueDate !== '' ? fbJoinDateTime(this.dueDate, this.dueTime) : '',
boardId: this.boardId,
status: 'todo'
};
const token: string = FB_TOKEN_STORE.get();
const res: FbApiResult = this.editingId > 0
? await updateTask(token, this.editingId, d)
: await createTask(token, d);
this.saving = false;
if (!res.ok) {
this.note2 = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.onSaved();
}
/** 看板单选胶囊 */
@Builder
boardChip(id: number, name: string, color: string) {
Text(name)
.fontSize(FbMetric.fontTiny)
.fontColor(this.boardId === id ? '#FFFFFF' : FbColors.muted)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.boardId === id ? (id === 0 ? this.accent : color) : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.boardId = id;
})
}
@Builder
textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(44)
TextInput({ text: value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
@Builder
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(44)
TextArea({ text: value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(80)
.layoutWeight(1)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
}
@@ -0,0 +1,265 @@
/**
* 家书 FamilyBook · 「新建旅程」浮层(鸿蒙端)
*
* 对应网页版 `templates/trips/form.php`(新建那一路)。
*
* 🔴 为什么单独做这一批:端上原来的「新建旅程」按钮**根本没接 `onTap`**
* (`FbTripsPage` 里 `FbPrimaryButton({ label: '新建旅程', ... })` 没有回调),
* 点了什么都不发生 —— 用户报的"新建旅程没完成"就是这一条。
*
* ⚠️ 表单只放**网页版真有的三项**:
* 标题 / 简介 / 是否公开。**没有起止时间、没有城市** ——
* 服务端 `Trip::create()` 显式忽略它们,城市与时间一律从行程节点**派生**
* (见 `Trip::derived()`)。加个"时间"输入框只会让人以为填了有用。
*
* ⚠️ 题图这一批**不做**:网页版走 `cover` 文件上传 + `cover_crop` 裁剪,
* 而服务端目前只有观影的 `/movies/<id>/poster` 上传槽位,没有旅程的。
* ⇒ 新建时先不传图;列表卡仍会显示已有数据的题图。
* (补上传端点时要与网页版 `ImageSlot` 的 `cover` 槽位比例 16:9 一致,
* 否则宽高比会和网页端不一样 —— 那条在 app.css 里有专门的断言比对。)
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createTrip, updateTrip, FbTripDraft } from '../data/FbTripRepo';
import { FbApiResult } from '../data/FbHttp';
@ComponentV2
export struct FbTripNewSheet {
/** 模块主题色(旅程 = 绿) */
@Param accent: string = '#3F7D5A';
/**
* 编辑模式传进来的旅程(`null`/`undefined` = 新建)。
*
* ⚠️ 用 `id` 判断而不是靠"标题是否为空" —— 空标题是**合法**的中间状态
* (用户刚清空输入框,还没打完),那时不该被当成"新建"。
*/
@Param editingId: number = 0;
@Param editingTitle: string = '';
@Param editingSummary: string = '';
@Param editingPublic: boolean = true;
@Event onClose: () => void = (): void => {};
/** 保存成功:父页面据此刷新列表 */
@Event onSaved: () => void = (): void => {};
@Local title: string = '';
@Local summary: string = '';
@Local isPublic: boolean = true;
@Local saving: boolean = false;
/** 错误/提示(红字) */
@Local note: string = '';
aboutToAppear(): void {
this.title = this.editingTitle;
this.summary = this.editingSummary;
this.isPublic = this.editingPublic;
}
build() {
/* 🔴🔴 根必须是 `Stack`(见 `pitfalls.md` 的"全屏遮罩不能当 Column 子项排")。
`Column { 整高遮罩; 卡片 }` 会把卡片挤出屏幕 ⇒ 浮层"点了没反应"。 */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
// ---- 标题栏(统一版式:左标题 + 右 ✕)----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.editingId > 0 ? '编辑旅程' : '新建旅程')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text('先起个名字;城市与时间等加了行程自然就有了')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(2)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 旅程名(必填)----
this.textField('旅程名', '如「青岛·崂山 两日」', this.title,
(v: string): void => {
this.title = v;
this.note = '';
})
// ---- 简介 ----
this.areaField('简介', '这趟出行想记的几句话(可空)', this.summary,
(v: string): void => {
this.summary = v;
})
// ---- 是否公开 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('在列表里公开')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('不公开的旅程不会出现在列表里')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.isPublic })
.selectedColor(this.accent)
.onChange((on: boolean): void => {
this.isPublic = on;
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ top: 2, bottom: 2 })
// ---- 错误 / 提示 ----
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
// ---- 底部:保存(关闭统一在右上角 ✕)----
Row() {
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '建好这趟旅程'))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
.padding({ top: 4 })
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
/**
* 提交。
*
* ⚠️ 保存中要**挡住重复点击**:连点两下会建出两趟一模一样的旅程
* (服务端不去重)。`this.saving` 在这里是真正的功能,不是装饰。
*/
private async save(): Promise<void> {
if (this.saving) {
return;
}
const title: string = this.title.trim();
if (title === '') {
this.note = '旅程得有个名字。';
return;
}
this.saving = true;
this.note = '';
const token: string = FB_TOKEN_STORE.get();
const d: FbTripDraft = {
title: title,
summary: this.summary.trim(),
isPublic: this.isPublic
};
const res: FbApiResult = this.editingId > 0
? await updateTrip(token, this.editingId, d)
: await createTrip(token, d);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
this.onSaved();
}
/** 单行输入 */
@Builder
textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(48)
TextInput({ text: value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/** 多行输入(简介) */
@Builder
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(48)
TextArea({ text: value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(84)
.layoutWeight(1)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
}
@@ -0,0 +1,348 @@
/**
* 家书 FamilyBook · 「加一段行程」浮层(鸿蒙端)
*
* 对应网页版 `templates/trips/node_form.php` 的**核心几项**。
*
* 🔴 用户 2026-10-20:「现在这个旅程下面也没有这个行程啊,这些都需要增加。」
* 端上原先连"行程"都拉不到(`nodes` 恒空),更没有地方能加一段。
*
* ⚠️ 节点类型(`node_type`)与出行方式(`travel_mode`)的枚举是**服务端常量**
* (`Trip::NODE_TYPES` / `Trip::TRAVEL_MODES`),这里照抄一份。
* 为什么不从接口取:它们是"物理事实"(走路/火车/飞机不会变),
* 服务端注释里也写明这一点;为它多发一次请求不值得。
* 但**改枚举时要两边一起改** —— 这个文件里有一条注释提醒。
*
* ⚠️ 表格里**没有**「出发城市/到达城市」:服务端 `nodeData()` 虽然接受
* `from_city`/`to_city`,但网页版的新建表单里这两项是可选的补充,
* 端上第一批先只做主干(标题/地点/时间/类型/方式),
* 城市仍能通过「地点」体现(列表里会用 place 显示)。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { addTripNode, FbTripNodeDraft } from '../data/FbTripRepo';
import { FbApiResult } from '../data/FbHttp';
/** 节点类型(⚠️ 与 `app/Trip.php` 的 `NODE_TYPES` 保持一致) */
interface FbNodeTypeOpt {
key: string;
label: string;
icon: string;
}
const NODE_TYPES: FbNodeTypeOpt[] = [
{ key: 'play', label: '游玩', icon: '🎡' },
{ key: 'meal', label: '用餐', icon: '🍜' },
{ key: 'move', label: '赶路', icon: '🚗' },
{ key: 'stay', label: '住宿', icon: '🏨' },
{ key: 'other', label: '其他', icon: '📌' }
];
/** 出行方式(⚠️ 与 `app/Trip.php` 的 `TRAVEL_MODES` 保持一致) */
const TRAVEL_MODES: FbNodeTypeOpt[] = [
{ key: '', label: '不填', icon: '—' },
{ key: 'walk', label: '步行', icon: '🚶' },
{ key: 'bike', label: '骑行', icon: '🚲' },
{ key: 'drive', label: '自驾', icon: '🚗' },
{ key: 'taxi', label: '打车', icon: '🚕' },
{ key: 'bus', label: '公交', icon: '🚌' },
{ key: 'subway', label: '地铁', icon: '🚇' },
{ key: 'train', label: '火车', icon: '🚄' },
{ key: 'plane', label: '飞机', icon: '✈️' },
{ key: 'ship', label: '轮船', icon: '🚢' },
{ key: 'other', label: '其他', icon: '🧭' }
];
@ComponentV2
export struct FbTripNodeSheet {
/** 所属旅程 id */
@Param tripId: number = 0;
/** 模块主题色 */
@Param accent: string = '#3F7D5A';
@Event onClose: () => void = (): void => {};
/** 加成功后父页面重新拉节点 */
@Event onSaved: () => void = (): void => {};
@Local title: string = '';
@Local place: string = '';
@Local nodeType: string = 'play';
@Local travelMode: string = '';
/** 'plan' 计划 / 'actual' 实际 */
@Local category: string = 'plan';
/** 日期 `YYYY-MM-DD` */
@Local date: string = '';
/** 时分 `HH:mm` */
@Local time: string = '09:00';
@Local pickerOpen: boolean = false;
@Local saving: boolean = false;
@Local note: string = '';
aboutToAppear(): void {
// 默认今天(多数场景是"记刚刚发生的"或"排今天剩下的")
this.date = fbIsoOf(new Date());
}
build() {
/* 🔴🔴 根必须 `Stack`(见 `pitfalls.md`:全屏遮罩不能当 Column 子项排) */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('加一段行程')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text('这一趟里的一个节点,如「出发」「抵达民宿」')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(2)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
// ---- 标题 ----
this.textField('这一段', '如「出发」「抵达民宿」', this.title,
(v: string): void => {
this.title = v;
this.note = '';
})
// ---- 地点 ----
this.textField('地点', '如「青岛北站」「崂山区·沙子口」', this.place,
(v: string): void => {
this.place = v;
})
// ---- 时间(复用日程/观影那套日期时间控件)----
FbDateTimeRow({
label: '时间',
date: this.date,
time: this.time,
accent: this.accent,
onTap: (): void => {
this.pickerOpen = true;
}
})
// ---- 类型(单选 chips)----
this.chipRow('类型', NODE_TYPES, this.nodeType, (k: string): void => {
this.nodeType = k;
})
// ---- 出行方式(横向滚动 chips,10 项一屏放不下)----
Column({ space: 6 }) {
Text('出行方式')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Scroll() {
Row({ space: 8 }) {
ForEach(TRAVEL_MODES, (m: FbNodeTypeOpt) => {
this.chip(m, this.travelMode === m.key, (): void => {
this.travelMode = m.key;
})
}, (m: FbNodeTypeOpt): string => 'tm_' + m.key)
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// ---- 计划 / 实际 ----
this.chipRow('阶段', [
{ key: 'plan', label: '计划', icon: '🗓' },
{ key: 'actual', label: '实际', icon: '✅' }
], this.category, (k: string): void => {
this.category = k;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 380 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
// ---- 错误 ----
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.maxLines(3)
}
// ---- 保存 ----
Text(this.saving ? '保存中…' : '加到行程里')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98))
.onClick((): void => {
this.save();
})
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
/* ---- 日期时间选择(叠在最上层;`FbDateTimePicker` 自身也是全屏 Stack,
嵌在这里正好盖住整个浮层)---- */
if (this.pickerOpen) {
FbDateTimePicker({
title: '这一段的时间',
date: this.date,
time: this.time,
accent: this.accent,
onConfirm: (d: string, t: string): void => {
this.date = d;
this.time = t;
this.pickerOpen = false;
},
onCancel: (): void => {
this.pickerOpen = false;
}
})
}
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
private async save(): Promise<void> {
if (this.saving) {
return;
}
const title: string = this.title.trim();
if (title === '') {
this.note = '这一段得有个名字(比如「出发」)。';
return;
}
if (this.tripId <= 0) {
this.note = '没找到这趟旅程,请返回重进。';
return;
}
this.saving = true;
this.note = '';
const d: FbTripNodeDraft = {
title: title,
place: this.place.trim(),
// 服务端的 `Api::dt()` 吃 `YYYY-MM-DD HH:MM`(秒由服务端补)
startsAt: fbJoinDateTime(this.date, this.time),
endsAt: '',
nodeType: this.nodeType,
travelMode: this.travelMode,
category: this.category,
detail: ''
};
const res: FbApiResult = await addTripNode(FB_TOKEN_STORE.get(), this.tripId, d);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没加上,请稍后再试。';
return;
}
this.onSaved();
}
/** 一组单选 chips(带标签) */
@Builder
chipRow(label: string, opts: FbNodeTypeOpt[], cur: string, onPick: (k: string) => void) {
Column({ space: 6 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 8 }) {
ForEach(opts, (o: FbNodeTypeOpt) => {
this.chip(o, cur === o.key, (): void => {
onPick(o.key);
})
}, (o: FbNodeTypeOpt): string => 'cr_' + label + '_' + o.key)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 单个 chip */
@Builder
chip(o: FbNodeTypeOpt, on: boolean, onTap: () => void) {
Text(o.icon + ' ' + o.label)
.fontSize(FbMetric.fontTiny)
.fontColor(on ? '#FFFFFF' : FbColors.muted)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(on ? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
onTap();
})
}
/** 单行输入 */
@Builder
textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row({ space: 8 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(44)
TextInput({ text: value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
}
+245
View File
@@ -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<void> {
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<string> {
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<FbCardData> {
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;
}
+71
View File
@@ -0,0 +1,71 @@
/**
* 家书 FamilyBook · 纪念日的**写操作**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `days()`:
* POST /api/v1/days 新建
* PATCH /api/v1/days/<id> 改
* DELETE /api/v1/days/<id> 删
*
* 字段契约(服务端 `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<string, Object> {
const body: Record<string, Object> = {};
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<FbApiResult> {
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<FbApiResult> {
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<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/days/' + id.toString(), opts);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 家书 FamilyBook · 账本的**写操作**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `ledger()`:
* POST /api/v1/ledger 新建账本(name)
* POST /api/v1/ledger/<id>/entries 记一笔(kind / amount / title / note / person_id)
* DELETE /api/v1/ledger/<id> 删账本
*
* 🔴 用户 2026-10-20:「还有这个财务的显示也有问题。」
* ⇒ 端上「记一笔」按钮**没有 `onTap`**、账本切换**不重拉明细**、
* 资产/负债是**端上自己按正负号重新加**的(与净资产口径不一致)。见 `FbLedgerPage`。
*
* ⚠️ **金额传「元」的字符串**:服务端 `Ledger::toCents()` 负责转分
* (App 与网页表单同一口径)。这里**不要**先转分再传 —— 会双重换算,差 100 倍。
*
* ⚠️ **没有"改一笔"和"删一笔"的接口**(`Api::` 路由只解析到第 3 段,
* `/ledger/<id>/entries/<eid>` 的第 4 段会被丢掉)。所以端上只做"记一笔",
* 改了金额请去网页端操作 —— 这一条要显式写在界面上,别让用户以为存不上。
*/
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
/** 记一笔 */
export async function addLedgerEntry(token: string, ledgerId: number, kind: string,
amountYuan: string, title: string, note: string): Promise<FbApiResult> {
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
const body: Record<string, Object> = {};
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);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 家书 FamilyBook · 笔记的**写操作**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `notes()`:
* POST /api/v1/notes 新建(title / body 或 blocks / tags)
* PATCH /api/v1/notes/<id> 改
* DELETE /api/v1/notes/<id> 删(**只限作者**)
*
* 🔴 用户 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<FbApiResult> {
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
const payload: Record<string, Object> = {};
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<FbApiResult> {
const payload: Record<string, Object> = {};
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<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/notes/' + id.toString(), opts);
}
+71 -7
View File
@@ -123,6 +123,10 @@ interface RawDay {
month?: number; day?: number; is_leap?: number; month?: number; day?: number; is_leap?: number;
person_id?: number | null; person_name?: string | null; person_id?: number | null; person_name?: string | null;
source?: string; born?: string | null; source?: string; born?: string | null;
/** v65:关联人 id 数组(服务端列表/单条都会带) */
person_ids?: number[] | null;
/** v65:备注 */
note?: string | null;
} }
interface RawTask { interface RawTask {
@@ -201,6 +205,10 @@ interface RawNote {
id?: number; title?: string; is_shared?: number; updated_at?: string; id?: number; title?: string; is_shared?: number; updated_at?: string;
author_name?: string | null; avatar?: string | null; avatar_color?: string | null; author_name?: string | null; avatar?: string | null; avatar_color?: string | null;
is_mine?: number; blocks?: RawNoteBlock[]; is_mine?: number; blocks?: RawNoteBlock[];
/** v65:列表里服务端已经算好的摘要 */
excerpt?: string | null;
/** v65:标签数组(`Note::attachChildren` 挂上的) */
tags?: string[] | null;
} }
interface RawNoteBlock { interface RawNoteBlock {
@@ -262,6 +270,12 @@ export function parseTrips(json: string): FbTripItem[] {
dateRange: dateRangeText(s(r.starts_at), s(r.ends_at), dayCount), dateRange: dateRangeText(s(r.starts_at), s(r.ends_at), dayCount),
nodeCount: n(r.node_count), nodeCount: n(r.node_count),
doneCount: 0, 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); out.push(it);
} }
@@ -280,6 +294,7 @@ export function parseDays(json: string): FbAnniversaryItem[] {
if (n(r.person_id) > 0) { if (n(r.person_id) > 0) {
people.push(chip(s(r.person_name), '#A84A28', n(r.person_id))); 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 = { const it: FbAnniversaryItem = {
id: n(r.id), id: n(r.id),
title: s(r.title), title: s(r.title),
@@ -288,6 +303,15 @@ export function parseDays(json: string): FbAnniversaryItem[] {
kind: kindCn(s(r.kind)), kind: kindCn(s(r.kind)),
people: people, people: people,
yearly: s(r.repeat_type) !== 'once', 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); out.push(it);
} }
@@ -339,6 +363,10 @@ function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem
assignee: s(t.assignee_name), assignee: s(t.assignee_name),
childDone: n(t.child_done), childDone: n(t.child_done),
childTotal: n(t.child_total), childTotal: n(t.child_total),
/* v65:新建/编辑/搬看板要用到的三个**原始**字段(原来只派生了显示文案) */
boardId: n(t.board_id),
note: s(t.note),
dueAt: due,
}; };
return it; return it;
} }
@@ -455,15 +483,18 @@ export function parseResources(json: string): FbResourceItem[] {
const rows: RawResource[] = asArray<RawResource>(json); const rows: RawResource[] = asArray<RawResource>(json);
const out: FbResourceItem[] = []; const out: FbResourceItem[] = [];
for (const r of rows) { for (const r of rows) {
/* ⚠️ 服务端 `resources` 表**没有 category 列**(见 `app/Schema.php`)——
只有 title / content / owner_id。所以端上按「归属人」分组/筛选,
不按"证件/资料"那种并不存在的类别(v65 之前那两个胶囊永远筛出空列表)。 */
const perm: string = s(r.perm);
const it: FbResourceItem = { const it: FbResourceItem = {
id: n(r.id), id: n(r.id),
name: s(r.title), name: s(r.title),
// 资源没有"类别"字段(服务端的 content 是自由文本),
// ⇒ 用「谁的」来分类更贴近实际用法(Web 端列表也是按归属分组)
kind: 'other',
ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name), ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name),
fileCount: 0, ownerId: n(r.owner_id),
shared: s(r.perm) !== 'owner', perm: perm !== '' ? perm : 'read',
content: s(r.content),
shared: perm !== 'owner',
updatedAt: datePart(s(r.updated_at)), updatedAt: datePart(s(r.updated_at)),
}; };
out.push(it); out.push(it);
@@ -502,19 +533,52 @@ export function parseNotes(json: string): FbNoteItem[] {
const rows: RawNote[] = arr<RawNote>(root['items'] as RawNote[]); const rows: RawNote[] = arr<RawNote>(root['items'] as RawNote[]);
const out: FbNoteItem[] = []; const out: FbNoteItem[] = [];
for (const r of rows) { 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<string>(r.tags)) {
tags.push(s(t));
}
const exc: string = s(r.excerpt);
out.push({ out.push({
id: n(r.id), id: n(r.id),
title: s(r.title), title: s(r.title),
excerpt: firstTextOf(r.blocks), excerpt: exc !== '' ? exc : body,
tags: [], tags: tags,
updatedAt: datePart(s(r.updated_at)), updatedAt: datePart(s(r.updated_at)),
shared: n(r.is_shared) !== 0, shared: n(r.is_shared) !== 0,
attachCount: 0, attachCount: 0,
body: body,
mine: n(r.is_mine) !== 0,
}); });
} }
return out; return out;
} }
/**
* `note_blocks` → 一段纯文本。
*
* ⚠️ 图片块的内容是**附件 key**(不是正文),拼进来会变成一串乱码 key
* ⇒ 记成「[图片]」,用户一眼知道那里有张图。
*/
function noteBodyOf(blocks: RawNoteBlock[] | undefined): string {
const rows: RawNoteBlock[] = arr<RawNoteBlock>(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[] */ /** 通知 → FbNotificationItem[] */
export function parseNotifications(json: string): FbNotificationItem[] { export function parseNotifications(json: string): FbNotificationItem[] {
const root: Record<string, Object> | null = asObject(json); const root: Record<string, Object> | null = asObject(json);
@@ -0,0 +1,50 @@
/**
* 家书 FamilyBook · 资源的**写操作**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `resources()`:
* POST /api/v1/resources 新建(title / content)
* PATCH /api/v1/resources/<id> 改(title / content / category)
* DELETE /api/v1/resources/<id> 删
*
* 🔴 用户 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<FbApiResult> {
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
const body: Record<string, Object> = {};
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<FbApiResult> {
const body: Record<string, Object> = {};
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<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/resources/' + id.toString(), opts);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 家书 FamilyBook · 任务的**写操作**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `tasks()`:
* POST /api/v1/tasks 新建(title / note / due_at / board_id / status)
* PATCH /api/v1/tasks/<id> 改(标题/备注/截止/看板/负责人/分类)
* POST /api/v1/tasks/<id>/status 改状态(todo / doing / done)
* DELETE /api/v1/tasks/<id> 删
*
* 🔴 用户 2026-10-20:「任务也是一样啊,任务也点不进去,也没法那个完成,
* 新建任务、设置这些东西都这都没有。」
* ⇒ 端上原来这三件事**一件都没接**(按钮没 onTap、列表项没 onClick)。
*
* ⚠️ **状态只走 `/status` 子动作**:服务端 `PATCH /tasks/<id>` 里
* `'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<FbApiResult> {
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
`Record<string, Object> = {}` 逐键赋值(见 FbTripRepo 同一说明)。 */
const body: Record<string, Object> = {};
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<FbApiResult> {
const body: Record<string, Object> = {};
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<FbApiResult> {
const body: Record<string, Object> = {};
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<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
}
+228
View File
@@ -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/<id> 改
* DELETE /api/v1/trips/<id> 删
* GET /api/v1/trips/<id>/nodes 行程节点
* POST /api/v1/trips/<id>/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<FbApiResult> {
/* ⚠️ 请求体必须用 `Record<string, Object> = {}` **逐键赋值**,不能写字面量 ——
ArkTS 的 `arkts-no-untyped-obj-literals` 只认显式 class/interface,
`Record<…>` 不算(与 `FbMovieRepo.saveMovie` 同一写法)。 */
const body: Record<string, Object> = {};
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<FbApiResult> {
const body: Record<string, Object> = {};
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<FbApiResult> {
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<FbTripNode[]> {
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<string, Object> = r as Record<string, Object>;
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<FbApiResult> {
/* 同上:逐键赋值(ArkTS 不允许 `Record` 的字面量初始化) */
const body: Record<string, Object> = {};
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<FbApiResult> {
const query: Record<string, string> = {};
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<string, Object>, 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<string, Object>, 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, Object>): 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, Object>): 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;
}
@@ -12,6 +12,8 @@ import { FbDb } from '../data/FbDb';
import { FB_MODULE_TOKEN } from '../data/FbModuleRepo'; import { FB_MODULE_TOKEN } from '../data/FbModuleRepo';
import { FB_KEY_STORE } from '../data/FbKeyStore'; import { FB_KEY_STORE } from '../data/FbKeyStore';
import { FB_ATTACH } from '../data/FbAttachQueue'; import { FB_ATTACH } from '../data/FbAttachQueue';
import { FB_MODULES } from '../model/Modules';
import { fbAppState } from '../common/Store';
const DOMAIN: number = 0x0000; const DOMAIN: number = 0x0000;
@@ -28,6 +30,9 @@ export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onCreate'); hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onCreate');
// 🔴 v66:先摆正"从哪进来"的模块(桌面卡片可能直接指向日程/任务),
// 再去做一堆异步预加载 —— 模块名是同步的,不依赖下面任何东西。
this.applyWantModule(want);
// 🔴 预加载 API 令牌进内存。 // 🔴 预加载 API 令牌进内存。
// 页面里需要**同步**判断"有没有登录"(决定要不要发起同步), // 页面里需要**同步**判断"有没有登录"(决定要不要发起同步),
// 所以必须在页面出现之前把令牌读进内存(见 FbTokenStore 的说明)。 // 所以必须在页面出现之前把令牌读进内存(见 FbTokenStore 的说明)。
@@ -100,6 +105,58 @@ export default class EntryAbility extends UIAbility {
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onDestroy'); 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<string, Object> | 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 { onWindowStageCreate(windowStage: window.WindowStage): void {
// ① 先跟随系统深浅色。必须在取窗口之前调用,否则会读到尚未更新的色彩配置。 // ① 先跟随系统深浅色。必须在取窗口之前调用,否则会读到尚未更新的色彩配置。
try { try {
@@ -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);
}
}
+113 -23
View File
@@ -124,10 +124,26 @@ export const FB_DEMO_CINEMAS: string[] = ['家里', '万达影城·李沧店', '
// ============================================================ // ============================================================
export const FB_DEMO_TRIPS: FbTripItem[] = [ 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: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03',
{ id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04', nodeCount: 12, doneCount: 12 }, nodeCount: 8, doneCount: 8, coverKey: '', city: '青岛', summary: '海边住了两晚,早上赶海、下午爬山。',
{ id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24', nodeCount: 0, doneCount: 0 } 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 分「计划 / 实际」) */ /** 一段旅程的行程(trips/show 时间轴;stage 分「计划 / 实际」) */
@@ -172,13 +188,41 @@ export const FB_DEMO_EVENTS_SOON: FbCalEvent[] = [
// ============================================================ // ============================================================
export const FB_DEMO_TASKS: FbTaskItem[] = [ 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: 1, title: '交物业费', status: 'doing', board: '家务', boardColor: '#A8574F',
{ id: 3, title: '整理去年旅行照片', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0 }, dueText: '10-13 前', 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 }, boardId: 1, note: '', dueAt: '2026-10-13 18:00'
{ 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: 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) */ /** 任务看板(tasks/settings) */
@@ -224,12 +268,30 @@ export const FB_DEMO_LEDGER_ENTRIES: FbLedgerEntry[] = [
// ============================================================ // ============================================================
export const FB_DEMO_RESOURCES: FbResourceItem[] = [ 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: 1, name: '户口本', ownerName: '全家', ownerId: 1, perm: 'owner',
{ id: 3, name: '儿子学籍资料', kind: 'material', ownerName: '儿子', fileCount: 6, shared: true, updatedAt: '2026-09-01' }, content: '放在书房第二个抽屉。编号 3702…', shared: false, updatedAt: '2026-09-14'
{ 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: 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[] = [ 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: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…',
{ id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…', tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1 }, tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2,
{ id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…', tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6 }, body: '1. 换推拉门(断桥铝,考虑隔音)\n2. 加一组置物架\n3. 补两盆绿萝', mine: true
{ id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…', tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0 } },
{
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<FbTaskItem>[] {
} }
/** 按 kind 分组建资源(证件 / 资料 / 其他) */ /** 按 kind 分组建资源(证件 / 资料 / 其他) */
/**
* 按**归属人**分组(资源挂在某人名下)。
*
* 🔴 v65:原来按"证件 / 资料"分组 —— 但服务端 `resources` 表根本没有类别列
* (`app/Schema.php` 只有 title / content / owner_id),解析时只能全填 `other`
* ⇒ 列表永远只出现一个「其他」组,两个筛选胶囊点了永远是空列表。
* 改成按归属人分组:这是真实存在的字段,也正好是网页版列表的读法。
*/
export function fbGroupResource(list: FbResourceItem[]): FbGroup<FbResourceItem>[] { export function fbGroupResource(list: FbResourceItem[]): FbGroup<FbResourceItem>[] {
return fbGroupBy(list, (it: FbResourceItem): string => fbResourceKindName(it.kind)); return fbGroupBy(list, (it: FbResourceItem): string => it.ownerName);
} }
/** 按 status 分组建书架(在读 / 想读 / 读完) */ /** 按 status 分组建书架(在读 / 想读 / 读完) */
+22 -7
View File
@@ -41,9 +41,13 @@ export const FB_SET_BOOKS: FbSettingItem[] = [
{ icon: 'share', label: '微信读书导入', hint: '同步微信读书的书单', action: '' } { 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[] = [ export const FB_SET_DAYS: FbSettingItem[] = [
{ icon: 'filter', label: '纪念日分类', hint: '全家共用一套分类', action: '' } { icon: 'filter', label: '纪念日分类', hint: '按分类挑着看', action: 'daykinds' }
]; ];
/** 账本(templates/ledger/_settings_menu.php) */ /** 账本(templates/ledger/_settings_menu.php) */
@@ -52,10 +56,16 @@ export const FB_SET_LEDGER: FbSettingItem[] = [
{ icon: 'edit', label: '账本名字', hint: '改这本账本的名字', action: '' } { 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[] = [ export const FB_SET_TASKS: FbSettingItem[] = [
{ icon: 'tasks', label: '分类与看板', hint: '管分类、管看板', action: '' }, { icon: 'tasks', label: '分类与看板', hint: '按看板找任务', action: 'taskboards' },
{ icon: 'check', label: '已完成', hint: '看已经完成的任务', action: '' } { icon: 'check', label: '已完成', hint: '看已经完成的任务', action: 'taskdone' }
]; ];
/** 旅程(templates/trips/_settings_menu.php) */ /** 旅程(templates/trips/_settings_menu.php) */
@@ -65,7 +75,12 @@ export const FB_SET_TRIPS: FbSettingItem[] = [
{ icon: 'dot', label: '节点类型', hint: '行程节点的类型', action: '' } { icon: 'dot', label: '节点类型', hint: '行程节点的类型', action: '' }
]; ];
/** 资源(网页版没有独立菜单;这里只放已有的入口) */ /** 资源(网页版没有独立菜单;这里只放端上真做得到的两个筛选)
*
* 🔴 v65:原来两条是「格式筛选」(`action === ''` ⇒ 点了不响应),
* 而且端上的"格式"根本不存在 —— 服务端 `resources` 表没有类别列。
* 改成按**权限/归属**筛:`perm` 与 `owner_id` 都是服务端真给的字段。 */
export const FB_SET_RESOURCES: FbSettingItem[] = [ export const FB_SET_RESOURCES: FbSettingItem[] = [
{ icon: 'filter', label: '格式筛选', hint: '只看某类资源', action: '' } { icon: 'share', label: '共享给我的', hint: '别人名下、共享给我看的', action: 'resourceShared' },
{ icon: 'people', label: '我名下的', hint: '挂在自己名下的资源', action: 'resourceMine' }
]; ];
+86 -9
View File
@@ -435,6 +435,25 @@ export interface FbTripItem {
nodeCount: number; nodeCount: number;
/** 已完成段数 */ /** 已完成段数 */
doneCount: 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 的时间轴节点) */ /** 旅程里的一段行程(trips/show 的时间轴节点) */
@@ -545,6 +564,27 @@ export interface FbTaskItem {
/** 子任务完成数 / 总数 */ /** 子任务完成数 / 总数 */
childDone: number; childDone: number;
childTotal: 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[]; people: FbKinChip[];
/** 是否每年重复 */ /** 是否每年重复 */
yearly: boolean; 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; id: number;
/** 名称,如「户口本」 */ /** 名称,如「户口本」 */
name: string; name: string;
/** 类别:document(证件)/ material(资料)/ other */ /** 归属人显示名 */
kind: string;
/** 归属人(空表示全家) */
ownerName: string; ownerName: string;
/** 附件数 */ /** 归属人 id(`POST /resources` 只能挂自己名下;这里留着是为了以后能选人) */
fileCount: number; ownerId: number;
/** 是否共享给我 */ /**
* 我的权限:`owner` / `write` / `read`。
*
* 🔴 v65:服务端 `Resource::all` 的 SELECT 里就有这个 CASE WHEN,
* 但端上原来**扔掉了** ⇒ 只读共享来的资源也画成可编辑的样子。
*/
perm: string;
/** 正文(证件编号、有效期、放在哪…)—— 详情要显示、编辑要回填 */
content: string;
/** 是否共享来的(`perm !== 'owner'`) */
shared: boolean; shared: boolean;
/** 更新时间 */ /** 更新时间(只取日期部分) */
updatedAt: string; updatedAt: string;
} }
@@ -677,6 +744,17 @@ export interface FbNoteItem {
shared: boolean; shared: boolean;
/** 附件数 */ /** 附件数 */
attachCount: number; attachCount: number;
/**
* 正文**纯文本**(把 `note_blocks` 里 text 块拼起来,图片块记成「[图片]」)。
*
* 🔴 v65(用户 2026-10-20「笔记现在也点不进去,记笔记也记不了」):
* 服务端 `/notes` 列表其实**已经带 `blocks`**(`Note::attachChildren` 一次性挂上),
* 只是端上原来只用它算了个摘要扔掉 ⇒ 点进详情时正文没了。
* 这里存下来,详情直接显示,不必再打一次 `/notes/<id>`。
*/
body: string;
/** 是不是我写的(不是的话只能看,服务端也只让作者删) */
mine: boolean;
} }
// ============================================================ // ============================================================
@@ -701,8 +779,7 @@ export interface FbNotificationItem {
// ============================================================ // ============================================================
/** 一个分组(名册按分组、导航按分类、日历按日历本…都用它) */ /** 一个分组(名册按分组、导航按分类、日历按日历本…都用它) */
export interface FbGroup<T> { export interface FbGroup<T> { /** 分组键(唯一) */
/** 分组键(唯一) */
key: string; key: string;
/** 分组标题 */ /** 分组标题 */
title: string; title: string;
+157
View File
@@ -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' }
});
})
}
}
+162
View File
@@ -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' }
});
})
}
}
+235
View File
@@ -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' }
});
})
}
}
+313 -11
View File
@@ -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 { FbAppState, fbAppState } from '../../common/Store';
import { FbAnniversaryItem, FbKinChip } from '../../model/Types'; import { FbAnniversaryItem, FbKinChip } from '../../model/Types';
import { parseDays } from '../../data/FbParse'; import { parseDays } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad'; import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; 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 { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
@@ -45,8 +49,29 @@ export struct FbDaysPage {
@Local settingsOpen: boolean = false; @Local settingsOpen: boolean = false;
@Local all: FbAnniversaryItem[] = []; @Local all: FbAnniversaryItem[] = [];
/** 类别筛选('' = 全部) */ /** 列表筛选('' = 全部 / 'birthday' = 生日 / 'custom' = 自己加的) */
@Local kindFilter: string = ''; @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 的说明)。 * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
@@ -64,6 +89,14 @@ export struct FbDaysPage {
} }
private backOnce(): boolean { private backOnce(): boolean {
if (this.editOpen) {
this.editOpen = false;
return true;
}
if (this.kindSheetOpen) {
this.kindSheetOpen = false;
return true;
}
if (this.settingsOpen) { if (this.settingsOpen) {
this.settingsOpen = false; this.settingsOpen = false;
return true; return true;
@@ -74,6 +107,16 @@ export struct FbDaysPage {
aboutToAppear(): void { aboutToAppear(): void {
FB_BACK.register('days', (): boolean => this.backOnce()); FB_BACK.register('days', (): boolean => this.backOnce());
this.load(); this.load();
this.loadRoster();
}
/** 读名册(选关联人用)。失败不阻断页面:只是"关联人"点开没人可选 */
private async loadRoster(): Promise<void> {
try {
this.roster = await FbPersonRepo.inst().list();
} catch (err) {
this.roster = [];
}
} }
private async load(): Promise<void> { private async load(): Promise<void> {
@@ -101,6 +144,73 @@ export struct FbDaysPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0; 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() { build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -124,7 +234,11 @@ export struct FbDaysPage {
FbPrimaryButton({ FbPrimaryButton({
label: '添加', label: '添加',
bg: this.accent, 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。 // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -140,6 +254,16 @@ export struct FbDaysPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center) .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 }) { Column({ space: 0 }) {
// ---- 即将到来(30 天内)---- // ---- 即将到来(30 天内)----
@@ -175,8 +299,6 @@ export struct FbDaysPage {
.onDidScroll(fbOnHScroll) .onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off) .scrollBar(BarState.Off)
.width('100%') .width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
} }
} }
.padding(FbMetric.cardPad) .padding(FbMetric.cardPad)
@@ -186,11 +308,33 @@ export struct FbDaysPage {
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
// ---- 全部纪念日 ---- // ---- 全部纪念日 ----
/* ⚠️ 筛选维度用「来源」(自己加的 / 名册生日派生)而不是"节日/纪念日":
服务端的 `kind` 是**自由文本**(用户在设置里能自建分类名),
原来那三个胶囊拿 'anniversary' / 'holiday' 去比,
而自定义条目存的是「结婚纪念日」这种原文 ⇒ 点了就是空列表。 */
Row({ space: 8 }) { Row({ space: 8 }) {
this.kindChip('全部', '') this.kindChip('全部', '')
this.kindChip('生日', 'birthday') this.kindChip('生日', 'birthday')
this.kindChip('纪念日', 'anniversary') this.kindChip('自己加的', 'custom')
this.kindChip('节日', 'holiday') /* 从「设置 → 按分类看」筛过来时,这里给一个能取消的胶囊 */
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%') .width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 14, bottom: 8 }) .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 14, bottom: 8 })
@@ -216,6 +360,11 @@ export struct FbDaysPage {
.width('100%') .width('100%')
} }
.width('100%') .width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
/* 🔴 v65 修正:这行原来被贴在了**「即将到来」那条横向 Scroll** 上
(内层横滑组件吃了一个 100+vp 的底部内边距 ⇒ 那块卡片底下多一截空白,
而页面真正需要的留白反而没有)。挪回到页面根 `Column` 上。 */
.padding({ bottom: FB_DOCK_RESERVE })
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
@@ -248,12 +397,143 @@ export struct FbDaysPage {
this.settingsOpen = false; this.settingsOpen = false;
}, },
onPick: (action: string): void => { onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应) /* v65:这条原来 `action === ''`(画灰、点了不响应)。
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。 「分类」在服务端是**自由文本**,「分类列表」本身没有 API,
所以这里能做的、也确实是用户想要的,是"按已经出现过的分类挑一个看"。 */
if (action === 'daykinds') {
this.kindSheetOpen = true;
this.settingsOpen = false;
return;
}
} }
}) })
.transition(fbSheetTransition()) .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%') .width('100%')
.height('100%') .height('100%')
@@ -324,7 +604,12 @@ export struct FbDaysPage {
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1) .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%') .width('100%')
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
@@ -362,6 +647,11 @@ export struct FbDaysPage {
} }
} }
.width('100%') .width('100%')
/* 🔴 v65:整行可点 → 打开编辑(派生生日给一句提示,见 `openEdit`)。
原来这里**没有 `onClick`** ⇒ 用户说"这些都需要增加"。 */
.onClick((): void => {
this.openEdit(a);
})
} }
@Builder @Builder
@@ -429,6 +719,18 @@ export struct FbDaysPage {
if (this.kindFilter.length === 0) { if (this.kindFilter.length === 0) {
return this.all; 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;
} }
} }
+108 -39
View File
@@ -25,6 +25,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbLedgerEntrySheet } from '../../components/FbLedgerEntrySheet';
import { FB_BACK } from '../../common/FbBack'; import { FB_BACK } from '../../common/FbBack';
import { FB_SET_LEDGER } from '../../model/FbSettingsMenus'; import { FB_SET_LEDGER } from '../../model/FbSettingsMenus';
@@ -60,6 +61,22 @@ export struct FbLedgerPage {
@Local syncNote: string = ''; @Local syncNote: string = '';
/** 当前账本 id(0 = 用第一本) */ /** 当前账本 id(0 = 用第一本) */
@Local bookId: number = 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 { private backOnce(): boolean {
if (this.entryOpen) {
this.entryOpen = false;
return true;
}
if (this.settingsOpen) { if (this.settingsOpen) {
this.settingsOpen = false; this.settingsOpen = false;
return true; return true;
@@ -117,14 +138,11 @@ export struct FbLedgerPage {
parseLedgerEntries, parseLedgerEntries,
(d: FbLedgerDetail): void => { (d: FbLedgerDetail): void => {
this.entries = d.entries; this.entries = d.entries;
// ⚠️ 净资产写回**这本账**(页面的 `netCent()` 是从 `currentBook()` 上取的)。 /* ⚠️ 净资产/资产/负债**都用服务端那份**(见 `netNow` 的说明)。
// 概览接口不返回 net,所以这里补上 —— 不新增页面字段, 原来写回 `books[i].netCent` 还有一个隐患:改数组元素不一定会触发刷新。 */
// 避免和已有的 `netCent()` 方法重名(实测撞过一次)。 this.netNow = d.netCent;
for (const bk of this.books) { this.assetNow = d.assetCent;
if (bk.id === id) { this.debtNow = d.debtCent;
bk.netCent = d.netCent;
}
}
}, },
(note: string): void => { (note: string): void => {
this.syncNote = note; this.syncNote = note;
@@ -143,6 +161,12 @@ export struct FbLedgerPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0; fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
} }
/** 「记一笔」浮层开着 ⇒ 底部悬浮快捷栏让位(与设置浮层不会同时开) */
@Monitor('entryOpen')
onEntrySheetChanged(): void {
fbAppState().sheetCount = this.entryOpen ? 1 : 0;
}
build() { build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -170,7 +194,15 @@ export struct FbLedgerPage {
FbPrimaryButton({ FbPrimaryButton({
label: '记一笔', label: '记一笔',
bg: this.accent, 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。 // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -186,6 +218,16 @@ export struct FbLedgerPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center) .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` **必须保留**:它是**横向**滚动(账本多时排不下)。 // ⚠️ 这层 `Scroll` **必须保留**:它是**横向**滚动(账本多时排不下)。
// 它与"页面纵向滚动"是两码事,不能被拍平(拍平会整条挤在一起)。 // 它与"页面纵向滚动"是两码事,不能被拍平(拍平会整条挤在一起)。
@@ -210,7 +252,11 @@ export struct FbLedgerPage {
.borderRadius(FbMetric.radiusPill) .borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => { .onClick(() => {
/* 🔴 v65:切账本必须**重拉明细**。
原来只改了 `bookId`,`entries` 还是上一本的
⇒ 标题换了、账目和净资产仍旧是别的账本的(用户说的"显示有问题")。 */
this.bookId = b.id; this.bookId = b.id;
this.loadEntries(b.id);
}) })
}, (b: FbLedgerBook): string => 'bk_' + b.id.toString()) }, (b: FbLedgerBook): string => 'bk_' + b.id.toString())
} }
@@ -337,8 +383,6 @@ export struct FbLedgerPage {
}, (e: FbLedgerEntry): string => 'le_' + e.id.toString()) }, (e: FbLedgerEntry): string => 'le_' + e.id.toString())
} }
.width('100%') .width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
} }
.padding(FbMetric.cardPad) .padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface) .backgroundColor(FbColors.surface)
@@ -353,6 +397,11 @@ export struct FbLedgerPage {
.width('100%') .width('100%')
} }
.width('100%') .width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
/* 🔴 v65 修正:这行原来被贴在了**每个类别分组的内层 Column** 上
(内层吃了一个 100+vp 的底部内边距 ⇒ 每组账目下面多一截空白,
而页面真正需要的留白反而没有)。挪回页面根 `Column`。 */
.padding({ bottom: FB_DOCK_RESERVE })
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
@@ -385,8 +434,27 @@ export struct FbLedgerPage {
this.settingsOpen = false; this.settingsOpen = false;
}, },
onPick: (action: string): void => { 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()) .transition(fbSheetTransition())
@@ -421,18 +489,13 @@ export struct FbLedgerPage {
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.layoutWeight(1) .layoutWeight(1)
Text(fbMoney(e.amountCent)) /* ⚠️ 金额按**服务器的负债语义**显示(`Ledger::isDebt` 同一份判断):
负债类(贷款 / 信用卡 / 欠款)一律显示成负号 —— 用户可能填的是正数
(「我欠 5000」),直接显示 `5000` 会让人以为是资产。 */
Text(fbMoney(this.shownCent(e)))
.fontSize(FbMetric.fontBody) .fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor(e.amountCent < 0 ? FbColors.red : FbColors.ink) .fontColor(this.shownCent(e) < 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)
} }
.width('100%') .width('100%')
.padding({ top: 11, bottom: 11 }) .padding({ top: 11, bottom: 11 })
@@ -465,37 +528,43 @@ export struct FbLedgerPage {
} }
private netCent(): number { private netCent(): number {
return this.currentBook().netCent; return this.netNow;
} }
/** 总资产(分)—— **服务端口径**(`Ledger::summary`),不是端上按正负号重加 */
private assetCent(): number { private assetCent(): number {
let sum: number = 0; return this.assetNow;
for (const e of this.entries) {
if (e.amountCent > 0) {
sum = sum + e.amountCent;
}
}
return sum;
} }
/** 总负债(分,正数)—— 服务端按绝对值计入,见 `netNow` 的说明 */
private debtCent(): number { private debtCent(): number {
let sum: number = 0; return this.debtNow;
for (const e of this.entries) {
if (e.amountCent < 0) {
sum = sum + e.amountCent;
}
}
return sum;
} }
private sumOf(items: FbLedgerEntry[]): number { private sumOf(items: FbLedgerEntry[]): number {
let sum: number = 0; let sum: number = 0;
for (const e of items) { for (const e of items) {
sum = sum + e.amountCent; sum = sum + this.shownCent(e);
} }
return sum; 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<FbLedgerEntry>[] { private groups(): FbGroup<FbLedgerEntry>[] {
const keys: string[] = []; const keys: string[] = [];
const buckets: FbLedgerEntry[][] = []; const buckets: FbLedgerEntry[][] = [];
+121 -30
View File
@@ -19,6 +19,9 @@ import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; 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 all: FbNoteItem[] = [];
/** 顶部提示 */ /** 顶部提示 */
@Local syncNote: string = ''; @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` 已统一打开。 * ⚠️ 不自己开库:`EntryAbility.onCreate` 已统一打开。
*/ */
aboutToAppear(): void { aboutToAppear(): void {
FB_BACK.register('notes', (): boolean => this.backOnce());
this.load(); 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<void> { private async load(): Promise<void> {
await loadModule<FbNoteItem[]>('notes', parseNotes, await loadModule<FbNoteItem[]>('notes', parseNotes,
(rows: FbNoteItem[]): void => { (rows: FbNoteItem[]): void => {
@@ -60,29 +113,36 @@ export struct FbNotesPage {
} }
build() { build() {
Scroll() { /* 🔴 v65:外层补一层 `Stack` —— 笔记浮层要浮在页面之上
Column() { (本页原来是 `Scroll` 直接作根,没有可以放浮层的容器)。 */
this.topBar() Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
this.topBar()
// 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「笔记」) // 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「笔记」)
Row({ space: 8 }) { Row({ space: 8 }) {
Column({ space: 2 }) { Column({ space: 2 }) {
Text(this.all.length.toString() + ' 条 · 分块 + 标签') Text(this.all.length.toString() + ' 条 · 分块 + 标签')
.fontSize(FbMetric.fontSmall) .fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted) .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) .width('100%')
.layoutWeight(1) .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
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)
if (this.all.length === 0) { if (this.all.length === 0) {
FbEmptyBox({ FbEmptyBox({
@@ -114,18 +174,45 @@ export struct FbNotesPage {
.width('100%') .width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏 // 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE }) .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%') .width('100%')
.height('100%') .height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
} }
@Builder @Builder
@@ -180,6 +267,10 @@ export struct FbNotesPage {
.backgroundColor(FbColors.surface) .backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius) .borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
/* 🔴 v65:整卡可点 → 打开看/改(原来**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openNote(n);
})
} }
} }
@@ -19,6 +19,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbResourceSheet } from '../../components/FbResourceSheet';
import { FB_BACK } from '../../common/FbBack'; import { FB_BACK } from '../../common/FbBack';
import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus'; import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus';
@@ -44,8 +45,24 @@ export struct FbResourcesPage {
@Local settingsOpen: boolean = false; @Local settingsOpen: boolean = false;
@Local all: FbResourceItem[] = []; @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 的说明)。 * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
@@ -63,6 +80,10 @@ export struct FbResourcesPage {
} }
private backOnce(): boolean { private backOnce(): boolean {
if (this.sheetOpen) {
this.sheetOpen = false;
return true;
}
if (this.settingsOpen) { if (this.settingsOpen) {
this.settingsOpen = false; this.settingsOpen = false;
return true; return true;
@@ -100,6 +121,34 @@ export struct FbResourcesPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0; 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() { build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -126,7 +175,12 @@ export struct FbResourcesPage {
FbPrimaryButton({ FbPrimaryButton({
label: '添加', label: '添加',
bg: this.accent, 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。 // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -142,11 +196,11 @@ export struct FbResourcesPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
// ---- 类别筛选 ---- // ---- 筛选(全部 / 共享给我 / 我名下)----
Row({ space: 8 }) { Row({ space: 8 }) {
this.kindChip('全部', '') this.scopeChip('全部', '')
this.kindChip('证件', 'document') this.scopeChip('共享给我', 'shared')
this.kindChip('资料', 'material') this.scopeChip('我名下', 'mine')
Blank() Blank()
Text('共享给我 ' + this.sharedCount().toString() + ' 项') Text('共享给我 ' + this.sharedCount().toString() + ' 项')
.fontSize(FbMetric.fontTiny) .fontSize(FbMetric.fontTiny)
@@ -216,8 +270,41 @@ export struct FbResourcesPage {
this.settingsOpen = false; this.settingsOpen = false;
}, },
onPick: (action: string): void => { 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()) .transition(fbSheetTransition())
@@ -227,13 +314,13 @@ export struct FbResourcesPage {
.height('100%') .height('100%')
} }
/** 一项资源卡 */ /** 一项资源卡(整卡可点 → 打开详情/编辑) */
@Builder @Builder
resourceCard(r: FbResourceItem) { resourceCard(r: FbResourceItem) {
Row({ space: 12 }) { Row({ space: 12 }) {
// 类别图标底 // 图标底:共享来的用「共享」图标,自己名下的用「锁」
Stack({ alignContent: Alignment.Center }) { 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) .width(19)
.height(19) .height(19)
.renderMode(ImageRenderMode.Template) .renderMode(ImageRenderMode.Template)
@@ -254,26 +341,26 @@ export struct FbResourcesPage {
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1) .layoutWeight(1)
if (r.shared) { if (r.shared) {
FbTag({ text: '已共享', tone: 'green' }) FbTag({ text: r.perm === 'write' ? '可改' : '只读', tone: 'green' })
} }
} }
.width('100%') .width('100%')
.alignItems(VerticalAlign.Center) .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 }) { Row({ space: 10 }) {
Text('归属 ' + r.ownerName) Text('归属 ' + r.ownerName)
.fontSize(10) .fontSize(10)
.fontColor(FbColors.muted) .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) Text(r.updatedAt)
.fontSize(10) .fontSize(10)
.fontColor(FbColors.faint) .fontColor(FbColors.faint)
@@ -295,18 +382,23 @@ export struct FbResourcesPage {
.borderRadius(FbMetric.radius) .borderRadius(FbMetric.radius)
.margin({ bottom: 8 }) .margin({ bottom: 8 })
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
/* 🔴 v65:整卡可点 → 打开详情(原来列表项**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openDetail(r);
})
} }
/** 筛选胶囊(全部 / 共享给我 / 我名下) */
@Builder @Builder
kindChip(label: string, key: string) { scopeChip(label: string, key: string) {
Text(label) Text(label)
.fontSize(FbMetric.fontSmall) .fontSize(FbMetric.fontSmall)
.fontColor(this.kindFilter === key ? Color.White : FbColors.muted) .fontColor(this.scopeFilter === key ? Color.White : FbColors.muted)
.backgroundColor(this.kindFilter === key ? this.accent : FbColors.inputBg) .backgroundColor(this.scopeFilter === key ? this.accent : FbColors.inputBg)
.padding({ left: 12, right: 12, top: 6, bottom: 6 }) .padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusPill) .borderRadius(FbMetric.radiusPill)
.onClick(() => { .onClick(() => {
this.kindFilter = key; this.scopeFilter = key;
}) })
} }
@@ -327,21 +419,15 @@ export struct FbResourcesPage {
.backgroundColor(FbColors.paper) .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[] { private filtered(): FbResourceItem[] {
if (this.kindFilter.length === 0) { if (this.scopeFilter.length === 0) {
return this.all; 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<FbResourceItem>[] { private groups(): FbGroup<FbResourceItem>[] {
+372 -19
View File
@@ -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 { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbTaskItem } from '../../model/Types'; import { FbBoardOpt, FbGroup, FbTaskItem } from '../../model/Types';
import { fbGroupTask } from '../../model/DemoData'; import { fbGroupTask } from '../../model/DemoData';
import { parseTasks } from '../../data/FbParse'; import { parseTasks } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad'; import { loadModule } from '../../data/FbModuleLoad';
@@ -20,8 +20,14 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbSheetRing } from '../../components/FbSheetRing';
import { FbSheetClose } from '../../components/FbSheetClose';
import { FB_BACK } from '../../common/FbBack'; import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TASKS } from '../../model/FbSettingsMenus'; 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 @ComponentV2
export struct FbTasksPage { export struct FbTasksPage {
@@ -33,7 +39,6 @@ export struct FbTasksPage {
@BuilderParam topBar: () => void; @BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState(); @Param app: FbAppState = fbAppState();
private boards: string[] = [];
/** 模块主题色(任务 = 赭红) */ /** 模块主题色(任务 = 赭红) */
private accent: string = '#A8574F'; private accent: string = '#A8574F';
/** /**
@@ -48,6 +53,36 @@ export struct FbTasksPage {
@Local tasks: FbTaskItem[] = []; @Local tasks: FbTaskItem[] = [];
/** 当前筛选的看板('' = 全部) */ /** 当前筛选的看板('' = 全部) */
@Local boardFilter: string = ''; @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 的说明)。 * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
@@ -65,6 +100,16 @@ export struct FbTasksPage {
} }
private backOnce(): boolean { private backOnce(): boolean {
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
顺序按"后打开的在前",否则按一次返回会跳关两层。 */
if (this.editOpen) {
this.editOpen = false;
return true;
}
if (this.boardSheetOpen) {
this.boardSheetOpen = false;
return true;
}
if (this.settingsOpen) { if (this.settingsOpen) {
this.settingsOpen = false; this.settingsOpen = false;
return true; return true;
@@ -102,6 +147,103 @@ export struct FbTasksPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0; 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/<id>/status`**:服务端 `PATCH /tasks/<id>` 会把
* 请求里的 status **原样忽略**(见 `FbTaskRepo` 文件头的说明)
* ⇒ 走 PATCH 会"看起来保存成功,但状态没变"。
*
* ⚠️ 已完成 → 取消完成统一回 `todo`(不回 `doing`:用户只想撤销一次误勾,
* 把它标成"进行中"是替用户做了他没表达的决定)。
*/
private async toggleDone(t: FbTaskItem): Promise<void> {
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() { build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -129,7 +271,11 @@ export struct FbTasksPage {
FbPrimaryButton({ FbPrimaryButton({
label: '新建任务', label: '新建任务',
bg: this.accent, 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。 // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -145,13 +291,43 @@ export struct FbTasksPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center) .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() { Scroll() {
Row({ space: 8 }) { Row({ space: 8 }) {
this.boardChip('全部', '') this.boardChip('全部', '')
ForEach(this.boards, (b: string) => { /* v65:看板从已加载的任务里反推(原来这个数组**从来没被赋值** ⇒
this.boardChip(b, b) 这条筛选条永远是空的,只剩一个「全部」) */
}, (b: string): string => 'bd_' + b) 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 }) .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
} }
@@ -222,17 +398,155 @@ export struct FbTasksPage {
this.settingsOpen = false; this.settingsOpen = false;
}, },
onPick: (action: string): void => { 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()) .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%') .width('100%')
.height('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 @Builder
boardChip(label: string, key: string) { boardChip(label: string, key: string) {
@@ -265,17 +579,30 @@ export struct FbTasksPage {
.backgroundColor(FbColors.paper) .backgroundColor(FbColors.paper)
} }
/** 一张任务卡 */ /** 一张任务卡(整卡可点 → 打开编辑;左边的圈单独点 → 勾完成) */
@Builder @Builder
taskCard(t: FbTaskItem) { taskCard(t: FbTaskItem) {
Column({ space: 8 }) { Column({ space: 8 }) {
Row({ space: 10 }) { Row({ space: 10 }) {
// 状态圈:完成=实心+勾、进行中=实心+白点、待办=空心 /* 状态圈 + 一块透明热区。
FbCheckCircle({ 🔴 v65:原来这个圈只有 16vp 且**没有 `onClick`** ⇒
dia: 16, 「没法那个完成」就是这条 —— 点它什么都不发生。
done: t.status === 'done', ⇒ 外面包一层 32×32 的热区(手指点得中),并接上改状态。
doing: t.status === 'doing', ⚠️ 内层有点击时父级的整卡点击**不会**再触发(ArkUI 由最内层消费),
color: this.accent 所以勾完成不会顺带弹出编辑浮层。 */
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) Text(t.title)
@@ -336,16 +663,42 @@ export struct FbTasksPage {
.borderRadius(FbMetric.radius) .borderRadius(FbMetric.radius)
.margin({ bottom: 8 }) .margin({ bottom: 8 })
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
/* 🔴 v65:整卡可点 → 打开编辑(原来列表项**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openEdit(t);
})
} }
private filtered(): FbTaskItem[] { private filtered(): FbTaskItem[] {
if (this.boardFilter.length === 0) { let list: FbTaskItem[] = this.tasks;
return 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<FbTaskItem>[] { private groups(): FbGroup<FbTaskItem>[] {
if (this.statusFilter === 'done') {
const items: FbTaskItem[] = this.filtered();
if (items.length === 0) {
return [];
}
const one: FbGroup<FbTaskItem> = {
key: 'done',
title: '已完成',
items: items,
count: items.length.toString() + ' 项'
};
const only: FbGroup<FbTaskItem>[] = [];
only.push(one);
return only;
}
return fbGroupTask(this.filtered()); return fbGroupTask(this.filtered());
} }
+363 -42
View File
@@ -12,7 +12,7 @@
* 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。 * 时间轴节点分「计划 / 实际」两阶段 —— 与 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 { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem, FbTripNode } from '../../model/Types'; import { FbTripItem, FbTripNode } from '../../model/Types';
import { fbMoney } from '../../model/DemoData'; import { fbMoney } from '../../model/DemoData';
@@ -25,6 +25,12 @@ import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack'; import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TRIPS } from '../../model/FbSettingsMenus'; 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 @ComponentV2
export struct FbTripsPage { export struct FbTripsPage {
@@ -66,6 +72,27 @@ export struct FbTripsPage {
private accent: string = '#3F7D5A'; private accent: string = '#3F7D5A';
/** 当前展开的旅程 id(0 = 都没展开) */ /** 当前展开的旅程 id(0 = 都没展开) */
@Local expandedId: number = 0; @Local expandedId: number = 0;
/**
* 「新建旅程」浮层是否打开(v65)。
*
* 🔴 原先这颗按钮**没有 `onTap`** ⇒ 点了什么都不发生(用户:
* 「新建旅程啊…这些东西都还没完成」)。现在接上了。
*/
@Local newOpen: boolean = false;
/** 正在编辑的旅程(0 = 新建)。点卡片的「编辑」用 */
@Local editingId: number = 0;
@Local editingTitle: string = '';
@Local editingSummary: string = '';
/** 行程节点正在加载(展开时拉 `/trips/<id>/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 { private backOnce(): boolean {
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 */
if (this.nodeSheetTripId > 0) {
this.nodeSheetTripId = 0;
return true;
}
if (this.newOpen) {
this.newOpen = false;
return true;
}
if (this.settingsOpen) { if (this.settingsOpen) {
this.settingsOpen = false; this.settingsOpen = false;
return true; return true;
} }
// 行程展开着时,返回先收起它(否则用户只能靠再点一次卡片)
if (this.expandedId !== 0) {
this.expandedId = 0;
return true;
}
return false; return false;
} }
@@ -119,6 +161,23 @@ export struct FbTripsPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0; 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() { build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -144,7 +203,15 @@ export struct FbTripsPage {
FbPrimaryButton({ FbPrimaryButton({
label: '新建旅程', label: '新建旅程',
bg: this.accent, 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。 // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -221,92 +288,258 @@ export struct FbTripsPage {
}) })
.transition(fbSheetTransition()) .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%') .width('100%')
.height('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 @Builder
tripCard(t: FbTripItem) { tripCard(t: FbTripItem) {
Column({ space: 0 }) { Column({ space: 0 }) {
// 卡头 // ---- 卡头:左题图 + 右内容(横排,同网页版)----
Row({ space: 10 }) { Row({ space: 12 }) {
// 左侧色条(模块色的竖条,一眼区分旅程) // 题图:有图放真图,没图放占位(网页版是 🧳)
Column() Stack({ alignContent: Alignment.Center }) {
.width(4) if (t.coverKey !== '') {
.height(38) Image(fbImageSrc(t.coverKey, FB_TOKEN_STORE.get()))
.borderRadius(2) .width('100%')
.backgroundColor(this.accent) .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) Text(t.title)
.fontSize(FbMetric.fontTitle) .fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink) .fontColor(FbColors.ink)
.maxLines(1) .maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .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 }) { 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) .fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted) .fontColor(FbColors.muted)
Text(t.sub) .maxLines(2)
.fontSize(FbMetric.fontTiny) .textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor(FbColors.faint) .width('100%')
.lineHeight(17)
} }
} }
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.layoutWeight(1) .layoutWeight(1)
.flexShrink(1)
Column({ space: 3 }) { }
if (t.nodeCount > 0) { .width('100%')
Text(t.doneCount.toString() + '/' + t.nodeCount.toString()) .padding(FbMetric.cardPad)
.fontSize(FbMetric.fontSmall) .alignItems(VerticalAlign.Top)
.fontWeight(FontWeight.Medium) .onClick((): void => {
.fontColor(this.accent) // 点卡片主体 = 展开/收起行程(网页版是"进详情",手机上一屏放不下,
Text('行程') // 就地展开更顺手;编辑走下面那颗小按钮)
.fontSize(10) const was: boolean = this.isExpanded(t.id);
.fontColor(FbColors.faint) this.expandedId = was ? 0 : t.id;
} else { if (!was) {
FbTag({ text: '还没行程', tone: 'muted' }) 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')) Image($r('app.media.fb_ic_forward'))
.width(14) .width(12)
.height(14) .height(12)
.renderMode(ImageRenderMode.Template) .renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint)) .colorFilter(fbTint(FbTint.faint))
.rotate({ angle: this.isExpanded(t.id) ? 90 : 0 }) .rotate({ angle: this.isExpanded(t.id) ? 90 : 0 })
} }
.width('100%') .width('100%')
.padding(FbMetric.cardPad) .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 12 })
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => {
// 展开/收起:只改本地状态,不发请求
this.expandedId = this.isExpanded(t.id) ? 0 : t.id;
})
// 展开区:行程时间轴 // ---- 展开区:行程时间轴 ----
if (this.isExpanded(t.id)) { if (this.isExpanded(t.id)) {
Divider() Divider()
.color(FbColors.line2) .color(FbColors.line2)
.margin({ left: FbMetric.cardPad, right: FbMetric.cardPad }) .margin({ left: FbMetric.cardPad, right: FbMetric.cardPad })
if (this.nodes.length === 0) { if (this.nodesLoading) {
Column() { Row({ space: 8 }) {
Text('这个旅程还没有排行程') LoadingProgress()
.width(16)
.height(16)
.color(this.accent)
Text('正在取行程…')
.fontSize(FbMetric.fontSmall) .fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint) .fontColor(FbColors.faint)
} }
.width('100%') .width('100%')
.padding(FbMetric.cardPad) .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 { } else {
Column({ space: 0 }) { Column({ space: 0 }) {
// 分两段列:「实际」(已发生的)在前,「计划」在后 —— 与 Web 端顺序一致 // 分两段列:「实际」(已发生的)在前,「计划」在后 —— 与 Web 端顺序一致
this.stageBlock('实际', 'actual') this.stageBlock('实际', 'actual')
this.stageBlock('计划', 'plan') this.stageBlock('计划', 'plan')
// 已有节点时,加号排在最后(像网页版时间轴的末尾)
this.addNodeBtn(t.id)
} }
.width('100%') .width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: FbMetric.cardPad }) .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: FbMetric.cardPad })
@@ -320,6 +553,94 @@ export struct FbTripsPage {
.alignItems(HorizontalAlign.Start) .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<void> {
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 @Builder
stageBlock(title: string, stage: string) { stageBlock(title: string, stage: string) {
+18
View File
@@ -72,6 +72,24 @@
"resource": "$profile:backup_config" "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"
}
]
} }
] ]
} }
@@ -11,6 +11,26 @@
{ {
"name": "EntryAbility_label", "name": "EntryAbility_label",
"value": "家书" "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": "家书 · 今日全景"
} }
] ]
} }
@@ -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"]
}
]
}