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
+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 { FbAnniversaryItem, FbKinChip } from '../../model/Types';
import { parseDays } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FbSheetRing } from '../../components/FbSheetRing';
import { FbSheetClose } from '../../components/FbSheetClose';
import { FbDayEditSheet } from '../../components/FbDayEditSheet';
import { FbRosterPerson, FbPersonRepo } from '../../data/FbPersonRepo';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
@@ -45,8 +49,29 @@ export struct FbDaysPage {
@Local settingsOpen: boolean = false;
@Local all: FbAnniversaryItem[] = [];
/** 类别筛选('' = 全部) */
/** 列表筛选('' = 全部 / 'birthday' = 生日 / 'custom' = 自己加的) */
@Local kindFilter: string = '';
/**
* 编辑 / 新建浮层是否打开。
*
* 🔴 v65(用户 2026-10-20):「纪念日、新增纪念日、设置相关的功能。」
* ⇒ 原来「添加」这颗按钮**没有 `onTap`**、列表行**没有 `onClick`**。
*/
@Local editOpen: boolean = false;
@Local editingId: number = 0;
@Local editingTitle: string = '';
@Local editingKind: string = '';
@Local editingNote: string = '';
@Local editingRepeat: string = 'solar';
@Local editingMonth: number = 1;
@Local editingDay: number = 1;
@Local editingPersonIds: number[] = [];
/** 名册(编辑浮层里选关联人用) */
@Local roster: FbRosterPerson[] = [];
/** 「按分类看」浮层(设置菜单里那一条) */
@Local kindSheetOpen: boolean = false;
/** 一行提示(点了派生生日、或操作失败时) */
@Local hint: string = '';
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
@@ -64,6 +89,14 @@ export struct FbDaysPage {
}
private backOnce(): boolean {
if (this.editOpen) {
this.editOpen = false;
return true;
}
if (this.kindSheetOpen) {
this.kindSheetOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
@@ -74,6 +107,16 @@ export struct FbDaysPage {
aboutToAppear(): void {
FB_BACK.register('days', (): boolean => this.backOnce());
this.load();
this.loadRoster();
}
/** 读名册(选关联人用)。失败不阻断页面:只是"关联人"点开没人可选 */
private async loadRoster(): Promise<void> {
try {
this.roster = await FbPersonRepo.inst().list();
} catch (err) {
this.roster = [];
}
}
private async load(): Promise<void> {
@@ -101,6 +144,73 @@ export struct FbDaysPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
/** 编辑浮层开着 ⇒ 底部悬浮快捷栏让位(与设置浮层不会同时开) */
@Monitor('editOpen')
onEditChanged(): void {
fbAppState().sheetCount = this.editOpen ? 1 : 0;
}
/** 「按分类看」浮层同理 */
@Monitor('kindSheetOpen')
onKindSheetChanged(): void {
fbAppState().sheetCount = this.kindSheetOpen ? 1 : 0;
}
/** 打开「新建」 */
private openNew(): void {
const today: Date = new Date();
this.editingId = 0;
this.editingTitle = '';
this.editingKind = '';
this.editingNote = '';
this.editingRepeat = 'solar';
this.editingMonth = today.getMonth() + 1;
this.editingDay = today.getDate();
this.editingPersonIds = [];
this.hint = '';
this.editOpen = true;
}
/**
* 打开某条(点列表行)。
*
* ⚠️ **只有自己添加的能改**:名册生日派生出来的那些行 `id` 是 0,
* 拿它去打 `/days/0` 必然 404;而且改生日该去名册里改(那里是唯一真值)。
* ⇒ 派生行点开只给一句提示,不去打开一个注定失败的编辑框。
*/
private openEdit(a: FbAnniversaryItem): void {
if (a.source !== 'custom' || a.id <= 0) {
this.hint = '「' + a.title + '」是从名册里的生日自动来的 —— 要改请去名册改那个人的生日。';
return;
}
this.hint = '';
this.editingId = a.id;
this.editingTitle = a.title;
this.editingKind = a.rawKind;
this.editingNote = a.note;
this.editingRepeat = a.repeatType;
this.editingMonth = a.month;
this.editingDay = a.day;
this.editingPersonIds = a.personIds.slice();
this.editOpen = true;
}
/** 数据里出现过的分类名(去重),给「按分类看」用 */
private kindNames(): string[] {
const out: string[] = [];
for (const a of this.all) {
if (a.rawKind !== '' && out.indexOf(a.rawKind) < 0) {
out.push(a.rawKind);
}
}
return out;
}
/** 某个分类名下的条数 */
private countOfKind(name: string): number {
return this.all.filter((a: FbAnniversaryItem): boolean => a.rawKind === name).length;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -124,7 +234,11 @@ export struct FbDaysPage {
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */
onTap: (): void => {
this.openNew();
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -140,6 +254,16 @@ export struct FbDaysPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// 一行提示(点了派生生日 / 操作失败)—— 就地说明,不弹 toast
if (this.hint !== '') {
Text(this.hint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.maxLines(3)
}
Column({ space: 0 }) {
// ---- 即将到来(30 天内)----
@@ -175,8 +299,6 @@ export struct FbDaysPage {
.onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off)
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
}
.padding(FbMetric.cardPad)
@@ -186,11 +308,33 @@ export struct FbDaysPage {
.alignItems(HorizontalAlign.Start)
// ---- 全部纪念日 ----
/* ⚠️ 筛选维度用「来源」(自己加的 / 名册生日派生)而不是"节日/纪念日":
服务端的 `kind` 是**自由文本**(用户在设置里能自建分类名),
原来那三个胶囊拿 'anniversary' / 'holiday' 去比,
而自定义条目存的是「结婚纪念日」这种原文 ⇒ 点了就是空列表。 */
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('生日', 'birthday')
this.kindChip('纪念日', 'anniversary')
this.kindChip('节日', 'holiday')
this.kindChip('自己加的', 'custom')
/* 从「设置 → 按分类看」筛过来时,这里给一个能取消的胶囊 */
if (this.kindFilter.startsWith('kind:')) {
Row({ space: 4 }) {
Text('分类:' + this.kindFilter.substring(5))
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
Image($r('app.media.fb_ic_close'))
.width(10)
.height(10)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(fbAlpha(this.accent, 0.1))
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.kindFilter = '';
})
}
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 14, bottom: 8 })
@@ -216,6 +360,11 @@ export struct FbDaysPage {
.width('100%')
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
/* 🔴 v65 修正:这行原来被贴在了**「即将到来」那条横向 Scroll** 上
(内层横滑组件吃了一个 100+vp 的底部内边距 ⇒ 那块卡片底下多一截空白,
而页面真正需要的留白反而没有)。挪回到页面根 `Column` 上。 */
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
.height('100%')
@@ -248,12 +397,143 @@ export struct FbDaysPage {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
/* v65:这条原来 `action === ''`(画灰、点了不响应)。
「分类」在服务端是**自由文本**,「分类列表」本身没有 API,
所以这里能做的、也确实是用户想要的,是"按已经出现过的分类挑一个看"。 */
if (action === 'daykinds') {
this.kindSheetOpen = true;
this.settingsOpen = false;
return;
}
}
})
.transition(fbSheetTransition())
}
// ---- 新建 / 编辑纪念日浮层(v65)----
if (this.editOpen) {
FbDayEditSheet({
accent: this.accent,
editingId: this.editingId,
editingTitle: this.editingTitle,
editingKind: this.editingKind,
editingNote: this.editingNote,
editingRepeat: this.editingRepeat,
editingMonth: this.editingMonth,
editingDay: this.editingDay,
editingPersonIds: this.editingPersonIds,
roster: this.roster,
onClose: (): void => {
this.editOpen = false;
},
onSaved: (): void => {
this.editOpen = false;
this.load();
},
onDeleted: (): void => {
this.editOpen = false;
this.load();
}
})
.transition(fbSheetTransition())
}
// ---- 「按分类看」浮层(v65)----
if (this.kindSheetOpen) {
/* 遮罩与卡片必须**叠**(`Stack`),不能"排" —— 见 FbDayEditSheet 同一说明 */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.kindSheetOpen = false;
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('按分类看')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('挑一个分类,只看它下面的纪念日')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.kindSheetOpen = false;
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 2 }) {
if (this.kindNames().length === 0) {
Text('还没有带分类的纪念日')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.kindNames(), (k: string) => {
Row({ space: 8 }) {
Text(k)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.countOfKind(k).toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.onClick((): void => {
/* ⚠️ 真的去筛(不是只弹一句"有 N 条"):
值前缀 `kind:` 表示"按分类原文筛",见 `filtered()`。 */
this.kindFilter = 'kind:' + k;
this.hint = '';
this.kindSheetOpen = false;
})
}, (k: string): string => 'ks_' + k)
}
}
.width('100%')
}
.constraintSize({ maxHeight: 320 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
Text('分类名在网页端「纪念日 → 设置 → 纪念日分类」里配;App 这边只按已有分类筛。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
@@ -324,7 +604,12 @@ export struct FbDaysPage {
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
FbTag({ text: this.kindName(a.kind), tone: this.kindTone(a.kind) })
/* ⚠️ 显示**服务端原文**(用户自建的分类名,如「结婚纪念日」);
老数据/派生行没有 kind 时才回落到映射出来的类别名。 */
FbTag({
text: a.rawKind !== '' ? a.rawKind : this.kindName(a.kind),
tone: this.kindTone(a.kind)
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
@@ -362,6 +647,11 @@ export struct FbDaysPage {
}
}
.width('100%')
/* 🔴 v65:整行可点 → 打开编辑(派生生日给一句提示,见 `openEdit`)。
原来这里**没有 `onClick`** ⇒ 用户说"这些都需要增加"。 */
.onClick((): void => {
this.openEdit(a);
})
}
@Builder
@@ -429,6 +719,18 @@ export struct FbDaysPage {
if (this.kindFilter.length === 0) {
return this.all;
}
return this.all.filter((a: FbAnniversaryItem): boolean => a.kind === this.kindFilter);
if (this.kindFilter === 'custom') {
// 「自己加的」= 服务端 `source === 'custom'`(派生生日是 birthday_solar/lunar)
return this.all.filter((a: FbAnniversaryItem): boolean => a.source === 'custom');
}
if (this.kindFilter === 'birthday') {
return this.all.filter((a: FbAnniversaryItem): boolean => a.kind === 'birthday');
}
// `kind:<分类原文>` —— 按用户自建的分类名筛
if (this.kindFilter.startsWith('kind:')) {
const name: string = this.kindFilter.substring(5);
return this.all.filter((a: FbAnniversaryItem): boolean => a.rawKind === name);
}
return this.all;
}
}
+108 -39
View File
@@ -25,6 +25,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbLedgerEntrySheet } from '../../components/FbLedgerEntrySheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_LEDGER } from '../../model/FbSettingsMenus';
@@ -60,6 +61,22 @@ export struct FbLedgerPage {
@Local syncNote: string = '';
/** 当前账本 id(0 = 用第一本) */
@Local bookId: number = 0;
/**
* 当前这本账的**服务端汇总**(分)。
*
* 🔴 v65(用户 2026-10-20「还有这个财务的显示也有问题」):
* 原来「资产 / 负债」是端上**自己按金额正负号重新加**的,
* 而服务端的口径是「负债取绝对值」(`Ledger::summary`)——
* 于是一条填成 `-500` 的欠款会同时满足"是负债"与"是负数",
* 端上加出来是 `-500`、服务端算出来是 `500` ⇒
* **净资产 ≠ 资产 − 负债**,三个数字互相矛盾。
* ⇒ 三个数字一律用服务端给的(`summary` 里有 asset / debt / net)。
*/
@Local netNow: number = 0;
@Local assetNow: number = 0;
@Local debtNow: number = 0;
/** 「记一笔」浮层是否打开 */
@Local entryOpen: boolean = false;
/**
* 页面出现:**两级加载**。
@@ -78,6 +95,10 @@ export struct FbLedgerPage {
}
private backOnce(): boolean {
if (this.entryOpen) {
this.entryOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
@@ -117,14 +138,11 @@ export struct FbLedgerPage {
parseLedgerEntries,
(d: FbLedgerDetail): void => {
this.entries = d.entries;
// ⚠️ 净资产写回**这本账**(页面的 `netCent()` 是从 `currentBook()` 上取的)。
// 概览接口不返回 net,所以这里补上 —— 不新增页面字段,
// 避免和已有的 `netCent()` 方法重名(实测撞过一次)。
for (const bk of this.books) {
if (bk.id === id) {
bk.netCent = d.netCent;
}
}
/* ⚠️ 净资产/资产/负债**都用服务端那份**(见 `netNow` 的说明)。
原来写回 `books[i].netCent` 还有一个隐患:改数组元素不一定会触发刷新。 */
this.netNow = d.netCent;
this.assetNow = d.assetCent;
this.debtNow = d.debtCent;
},
(note: string): void => {
this.syncNote = note;
@@ -143,6 +161,12 @@ export struct FbLedgerPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
/** 「记一笔」浮层开着 ⇒ 底部悬浮快捷栏让位(与设置浮层不会同时开) */
@Monitor('entryOpen')
onEntrySheetChanged(): void {
fbAppState().sheetCount = this.entryOpen ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -170,7 +194,15 @@ export struct FbLedgerPage {
FbPrimaryButton({
label: '记一笔',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */
onTap: (): void => {
if (this.currentId() <= 0) {
this.syncNote = '还没有账本 —— 先在网页端建一本,或者在设置里新建。';
return;
}
this.entryOpen = true;
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -186,6 +218,16 @@ export struct FbLedgerPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// 一行提示(同步失败 / 没账本时点「记一笔」)
if (this.syncNote !== '') {
Text(this.syncNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.maxLines(3)
}
// ---- 账本切换(横向滚动条)----
// ⚠️ 这层 `Scroll` **必须保留**:它是**横向**滚动(账本多时排不下)。
// 它与"页面纵向滚动"是两码事,不能被拍平(拍平会整条挤在一起)。
@@ -210,7 +252,11 @@ export struct FbLedgerPage {
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.onClick(() => {
/* 🔴 v65:切账本必须**重拉明细**。
原来只改了 `bookId`,`entries` 还是上一本的
⇒ 标题换了、账目和净资产仍旧是别的账本的(用户说的"显示有问题")。 */
this.bookId = b.id;
this.loadEntries(b.id);
})
}, (b: FbLedgerBook): string => 'bk_' + b.id.toString())
}
@@ -337,8 +383,6 @@ export struct FbLedgerPage {
}, (e: FbLedgerEntry): string => 'le_' + e.id.toString())
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
@@ -353,6 +397,11 @@ export struct FbLedgerPage {
.width('100%')
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
/* 🔴 v65 修正:这行原来被贴在了**每个类别分组的内层 Column** 上
(内层吃了一个 100+vp 的底部内边距 ⇒ 每组账目下面多一截空白,
而页面真正需要的留白反而没有)。挪回页面根 `Column`。 */
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
.height('100%')
@@ -385,8 +434,27 @@ export struct FbLedgerPage {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
/* 「共享给谁」「账本名字」**都没有 API**(服务端只开了
建账本 / 记一笔 / 删账本),所以这里保持"暂未开放"的灰态,
不接一个点了报错的入口。 */
}
})
.transition(fbSheetTransition())
}
// ---- 记一笔浮层(v65)----
if (this.entryOpen) {
FbLedgerEntrySheet({
accent: this.accent,
ledgerId: this.currentId(),
ledgerName: this.currentBook().name,
onClose: (): void => {
this.entryOpen = false;
},
onSaved: (): void => {
this.entryOpen = false;
// 重拉明细:金额与汇总都在里面,端上拼不出来
this.loadEntries(this.currentId());
}
})
.transition(fbSheetTransition())
@@ -421,18 +489,13 @@ export struct FbLedgerPage {
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(fbMoney(e.amountCent))
/* ⚠️ 金额按**服务器的负债语义**显示(`Ledger::isDebt` 同一份判断):
负债类(贷款 / 信用卡 / 欠款)一律显示成负号 —— 用户可能填的是正数
(「我欠 5000」),直接显示 `5000` 会让人以为是资产。 */
Text(fbMoney(this.shownCent(e)))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(e.amountCent < 0 ? FbColors.red : FbColors.ink)
// 「盘点」:Web 端是每行一个入口,端上保留成一个轻按钮
Text('盘点')
.fontSize(10)
.fontColor(this.accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(fbAlpha(this.accent, 0.08))
.borderRadius(FbMetric.radiusPill)
.fontColor(this.shownCent(e) < 0 ? FbColors.red : FbColors.ink)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
@@ -465,37 +528,43 @@ export struct FbLedgerPage {
}
private netCent(): number {
return this.currentBook().netCent;
return this.netNow;
}
/** 总资产(分)—— **服务端口径**(`Ledger::summary`),不是端上按正负号重加 */
private assetCent(): number {
let sum: number = 0;
for (const e of this.entries) {
if (e.amountCent > 0) {
sum = sum + e.amountCent;
}
}
return sum;
return this.assetNow;
}
/** 总负债(分,正数)—— 服务端按绝对值计入,见 `netNow` 的说明 */
private debtCent(): number {
let sum: number = 0;
for (const e of this.entries) {
if (e.amountCent < 0) {
sum = sum + e.amountCent;
}
}
return sum;
return this.debtNow;
}
private sumOf(items: FbLedgerEntry[]): number {
let sum: number = 0;
for (const e of items) {
sum = sum + e.amountCent;
sum = sum + this.shownCent(e);
}
return sum;
}
/** 这个类别是不是负债(与 `Ledger::KINDS` 的第 2 列一致) */
private isDebtKind(kind: string): boolean {
return kind === 'loan' || kind === 'credit' || kind === 'owe';
}
/**
* 一条账目**该显示多少分**。
*
* ⚠️ 负债类一律取负:用户可能把欠款填成正数,直接显示会看成资产。
* 服务端 `summary` 也是这个口径(负债按绝对值计入总负债)。
*/
private shownCent(e: FbLedgerEntry): number {
const v: number = e.amountCent;
return this.isDebtKind(e.kind) ? -Math.abs(v) : v;
}
private groups(): FbGroup<FbLedgerEntry>[] {
const keys: string[] = [];
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 { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbNoteSheet } from '../../components/FbNoteSheet';
import { FB_BACK } from '../../common/FbBack';
import { fbSheetTransition } from '../../common/FbAnim';
// ============================================================
// 笔记
@@ -40,15 +43,65 @@ export struct FbNotesPage {
@Local all: FbNoteItem[] = [];
/** 顶部提示 */
@Local syncNote: string = '';
/**
* 笔记浮层是否打开(新建 / 看 / 改 / 删)。
*
* 🔴 v65(用户 2026-10-20):「笔记现在也点不进去,记笔记也记不了。」
* ⇒ 原来「记一条」按钮**没有 `onTap`**、卡片**没有 `onClick`**。
*/
@Local sheetOpen: boolean = false;
@Local sheetId: number = 0;
@Local sheetTitle: string = '';
@Local sheetBody: string = '';
@Local sheetTags: string = '';
@Local sheetMine: boolean = true;
/**
* 页面出现:读缓存 → 拉接口(本地优先)。
* ⚠️ 不自己开库:`EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
FB_BACK.register('notes', (): boolean => this.backOnce());
this.load();
}
aboutToDisappear(): void {
FB_BACK.unregister('notes');
}
/** 返回手势:先关浮层(true = 已消费) */
private backOnce(): boolean {
if (this.sheetOpen) {
this.sheetOpen = false;
return true;
}
return false;
}
/** 浮层开着 ⇒ 底部悬浮快捷栏让位 */
@Monitor('sheetOpen')
onSheetChanged(): void {
fbAppState().sheetCount = this.sheetOpen ? 1 : 0;
}
private openNew(): void {
this.sheetId = 0;
this.sheetTitle = '';
this.sheetBody = '';
this.sheetTags = '';
this.sheetMine = true;
this.sheetOpen = true;
}
private openNote(n: FbNoteItem): void {
this.sheetId = n.id;
this.sheetTitle = n.title;
this.sheetBody = n.body;
this.sheetTags = n.tags.join(' ');
this.sheetMine = n.mine;
this.sheetOpen = true;
}
private async load(): Promise<void> {
await loadModule<FbNoteItem[]>('notes', parseNotes,
(rows: FbNoteItem[]): void => {
@@ -60,29 +113,36 @@ export struct FbNotesPage {
}
build() {
Scroll() {
Column() {
this.topBar()
/* 🔴 v65:外层补一层 `Stack` —— 笔记浮层要浮在页面之上
(本页原来是 `Scroll` 直接作根,没有可以放浮层的容器)。 */
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
this.topBar()
// 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「笔记」)
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.all.length.toString() + ' 条 · 分块 + 标签')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
// 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「笔记」)
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.all.length.toString() + ' 条 · 分块 + 标签')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一条',
bg: this.accent,
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */
onTap: (): void => {
this.openNew();
}
})
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一条',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (this.all.length === 0) {
FbEmptyBox({
@@ -114,18 +174,45 @@ export struct FbNotesPage {
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
// ---- 笔记浮层:新建 / 看 / 改 / 删(v65)----
if (this.sheetOpen) {
FbNoteSheet({
accent: this.accent,
editingId: this.sheetId,
editingTitle: this.sheetTitle,
editingBody: this.sheetBody,
editingTags: this.sheetTags,
mine: this.sheetMine,
onClose: (): void => {
this.sheetOpen = false;
},
onSaved: (): void => {
this.sheetOpen = false;
this.load();
},
onDeleted: (): void => {
this.sheetOpen = false;
this.load();
}
})
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
}
@Builder
@@ -180,6 +267,10 @@ export struct FbNotesPage {
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
/* 🔴 v65:整卡可点 → 打开看/改(原来**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openNote(n);
})
}
}
@@ -19,6 +19,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbResourceSheet } from '../../components/FbResourceSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus';
@@ -44,8 +45,24 @@ export struct FbResourcesPage {
@Local settingsOpen: boolean = false;
@Local all: FbResourceItem[] = [];
/** 当前筛选类别('' = 全部) */
@Local kindFilter: string = '';
/**
* 当前筛选范围:'' = 全部 / 'shared' = 共享给我的 / 'mine' = 我名下的。
*
* 🔴 v65:原来这里是「证件 / 资料」两个类别 —— 服务端 `resources` 表
* **没有类别列**(`app/Schema.php` 只有 title / content / owner_id),
* 解析时 kind 只能全填 `other` ⇒ 这两个胶囊点了永远是空列表。
* 改成按**权限/归属**筛:这两个维度服务端真的给(`perm` / `owner_id`)。
*/
@Local scopeFilter: string = '';
/** 资源浮层(新建 / 查看 / 编辑 / 删除)是否打开 */
@Local sheetOpen: boolean = false;
/** 浮层里那条(0 = 新建) */
@Local sheetId: number = 0;
@Local sheetTitle: string = '';
@Local sheetContent: string = '';
@Local sheetOwner: string = '';
@Local sheetCanWrite: boolean = true;
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
@@ -63,6 +80,10 @@ export struct FbResourcesPage {
}
private backOnce(): boolean {
if (this.sheetOpen) {
this.sheetOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
@@ -100,6 +121,34 @@ export struct FbResourcesPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
/** 资源浮层开着 ⇒ 底部悬浮快捷栏同样让位(两个浮层不会同时开) */
@Monitor('sheetOpen')
onResourceSheetChanged(): void {
fbAppState().sheetCount = this.sheetOpen ? 1 : 0;
}
/** 打开「新增资源」 */
private openNew(): void {
this.sheetId = 0;
this.sheetTitle = '';
this.sheetContent = '';
this.sheetOwner = '';
this.sheetCanWrite = true;
this.sheetOpen = true;
}
/** 打开某条资源(只读共享进来的锁输入框,但内容照看) */
private openDetail(r: FbResourceItem): void {
this.sheetId = r.id;
this.sheetTitle = r.name;
this.sheetContent = r.content;
this.sheetOwner = r.ownerName;
/* `perm` 是服务端 `Resource::all` 里算好的:owner / write / read。
⚠️ 只有 owner 与 write 能改(服务端 `Resource::canWrite` 同一口径)。 */
this.sheetCanWrite = r.perm === 'owner' || r.perm === 'write';
this.sheetOpen = true;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -126,7 +175,12 @@ export struct FbResourcesPage {
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** ——
用户说的"新增加资源"没完成就是这条。 */
onTap: (): void => {
this.openNew();
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -142,11 +196,11 @@ export struct FbResourcesPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 类别筛选 ----
// ---- 筛选(全部 / 共享给我 / 我名下)----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('证件', 'document')
this.kindChip('资料', 'material')
this.scopeChip('全部', '')
this.scopeChip('共享给我', 'shared')
this.scopeChip('我名下', 'mine')
Blank()
Text('共享给我 ' + this.sharedCount().toString() + ' 项')
.fontSize(FbMetric.fontTiny)
@@ -216,8 +270,41 @@ export struct FbResourcesPage {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
/* v65:两条都接上了(原来都是空串 ⇒ 画灰、点了不响应) */
if (action === 'resourceShared') {
this.scopeFilter = 'shared';
this.settingsOpen = false;
return;
}
if (action === 'resourceMine') {
this.scopeFilter = 'mine';
this.settingsOpen = false;
return;
}
}
})
.transition(fbSheetTransition())
}
// ---- 资源浮层:新建 / 查看 / 编辑 / 删除(v65)----
if (this.sheetOpen) {
FbResourceSheet({
accent: this.accent,
editingId: this.sheetId,
editingTitle: this.sheetTitle,
editingContent: this.sheetContent,
ownerName: this.sheetOwner,
canWrite: this.sheetCanWrite,
onClose: (): void => {
this.sheetOpen = false;
},
onSaved: (): void => {
this.sheetOpen = false;
this.load();
},
onDeleted: (): void => {
this.sheetOpen = false;
this.load();
}
})
.transition(fbSheetTransition())
@@ -227,13 +314,13 @@ export struct FbResourcesPage {
.height('100%')
}
/** 一项资源卡 */
/** 一项资源卡(整卡可点 → 打开详情/编辑) */
@Builder
resourceCard(r: FbResourceItem) {
Row({ space: 12 }) {
// 类别图标底
// 图标底:共享来的用「共享」图标,自己名下的用「锁」
Stack({ alignContent: Alignment.Center }) {
Image(this.kindIcon(r.kind))
Image(r.shared ? $r('app.media.fb_ic_share') : $r('app.media.fb_ic_lock'))
.width(19)
.height(19)
.renderMode(ImageRenderMode.Template)
@@ -254,26 +341,26 @@ export struct FbResourcesPage {
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (r.shared) {
FbTag({ text: '已共享', tone: 'green' })
FbTag({ text: r.perm === 'write' ? '可改' : '只读', tone: 'green' })
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 正文预览:资源的正文就是"编号、有效期、放在哪" —— 比附件数有用得多
if (r.content !== '') {
Text(r.content)
.fontSize(10)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 10 }) {
Text('归属 ' + r.ownerName)
.fontSize(10)
.fontColor(FbColors.muted)
Row({ space: 3 }) {
Image($r('app.media.fb_ic_resources'))
.width(11)
.height(11)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
Text(r.fileCount.toString() + ' 个附件')
.fontSize(10)
.fontColor(FbColors.faint)
}
Text(r.updatedAt)
.fontSize(10)
.fontColor(FbColors.faint)
@@ -295,18 +382,23 @@ export struct FbResourcesPage {
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
/* 🔴 v65:整卡可点 → 打开详情(原来列表项**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openDetail(r);
})
}
/** 筛选胶囊(全部 / 共享给我 / 我名下) */
@Builder
kindChip(label: string, key: string) {
scopeChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.kindFilter === key ? Color.White : FbColors.muted)
.backgroundColor(this.kindFilter === key ? this.accent : FbColors.inputBg)
.fontColor(this.scopeFilter === key ? Color.White : FbColors.muted)
.backgroundColor(this.scopeFilter === key ? this.accent : FbColors.inputBg)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.kindFilter = key;
this.scopeFilter = key;
})
}
@@ -327,21 +419,15 @@ export struct FbResourcesPage {
.backgroundColor(FbColors.paper)
}
private kindIcon(kind: string): Resource {
if (kind === 'document') {
return $r('app.media.fb_ic_lock');
}
if (kind === 'material') {
return $r('app.media.fb_ic_book');
}
return $r('app.media.fb_ic_resources');
}
private filtered(): FbResourceItem[] {
if (this.kindFilter.length === 0) {
if (this.scopeFilter.length === 0) {
return this.all;
}
return this.all.filter((r: FbResourceItem): boolean => r.kind === this.kindFilter);
if (this.scopeFilter === 'shared') {
// 共享给我的 = 权限不是 owner 的那些
return this.all.filter((r: FbResourceItem): boolean => r.shared);
}
return this.all.filter((r: FbResourceItem): boolean => !r.shared);
}
private groups(): FbGroup<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 { FbGroup, FbTaskItem } from '../../model/Types';
import { FbBoardOpt, FbGroup, FbTaskItem } from '../../model/Types';
import { fbGroupTask } from '../../model/DemoData';
import { parseTasks } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
@@ -20,8 +20,14 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FbSheetRing } from '../../components/FbSheetRing';
import { FbSheetClose } from '../../components/FbSheetClose';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TASKS } from '../../model/FbSettingsMenus';
import { FbTaskEditSheet } from '../../components/FbTaskEditSheet';
import { setTaskStatus } from '../../data/FbTaskRepo';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { FbApiResult } from '../../data/FbHttp';
@ComponentV2
export struct FbTasksPage {
@@ -33,7 +39,6 @@ export struct FbTasksPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private boards: string[] = [];
/** 模块主题色(任务 = 赭红) */
private accent: string = '#A8574F';
/**
@@ -48,6 +53,36 @@ export struct FbTasksPage {
@Local tasks: FbTaskItem[] = [];
/** 当前筛选的看板('' = 全部) */
@Local boardFilter: string = '';
/**
* 状态筛选('' = 全部;'done' = 只看已完成)。
*
* 🔴 v65:设置菜单里的「已完成」原来 `action === ''` ⇒ 点了不响应。
* 网页版是 `?view=done`(`Task::doneList`),端上不换接口,
* 直接在已拉到的整份列表上筛(数据本来就是全量,没必要多打一次网络)。
*/
@Local statusFilter: string = '';
/**
* 新建 / 编辑任务浮层是否打开。
*
* 🔴 v65(用户 2026-10-20):「任务也是一样啊,任务也点不进去,也没法那个完成,
* 新建任务、设置这些东西都这都没有。」
* ⇒ 原来「新建任务」按钮**没有 `onTap`**、列表项**没有 `onClick`**,
* 勾完成的那个圈也**没有热区** —— 三件事一件都没接。现在都接上了。
*/
@Local editOpen: boolean = false;
/** 正在编辑的任务(0 = 新建) */
@Local editingId: number = 0;
@Local editingTitle: string = '';
@Local editingNote: string = '';
/** `2026-10-25 18:00`;空 = 没有期限 */
@Local editingDueAt: string = '';
@Local editingBoardId: number = 0;
/** 正在改状态的那条(防连点打重复请求) */
@Local busyId: number = 0;
/** 「分类与看板」浮层是否打开 */
@Local boardSheetOpen: boolean = false;
/** 操作失败时的一行红字(如勾完成失败) */
@Local opNote: string = '';
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
@@ -65,6 +100,16 @@ export struct FbTasksPage {
}
private backOnce(): boolean {
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
顺序按"后打开的在前",否则按一次返回会跳关两层。 */
if (this.editOpen) {
this.editOpen = false;
return true;
}
if (this.boardSheetOpen) {
this.boardSheetOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
@@ -102,6 +147,103 @@ export struct FbTasksPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
/** 新建/编辑浮层开着 ⇒ 底部悬浮快捷栏也要让位(与 `settingsOpen` 不会同时开) */
@Monitor('editOpen')
onEditChanged(): void {
fbAppState().sheetCount = this.editOpen ? 1 : 0;
}
/** 「分类与看板」浮层同理 */
@Monitor('boardSheetOpen')
onBoardSheetChanged(): void {
fbAppState().sheetCount = this.boardSheetOpen ? 1 : 0;
}
/** 打开「新建」浮层(清空回填字段) */
private openNew(): void {
this.editingId = 0;
this.editingTitle = '';
this.editingNote = '';
this.editingDueAt = '';
this.editingBoardId = 0;
this.opNote = '';
this.editOpen = true;
}
/** 打开「编辑」浮层(把这条任务的字段回填进去) */
private openEdit(t: FbTaskItem): void {
this.editingId = t.id;
this.editingTitle = t.title;
this.editingNote = t.note;
this.editingDueAt = t.dueAt;
this.editingBoardId = t.boardId;
this.opNote = '';
this.editOpen = true;
}
/**
* 勾选 / 取消完成。
*
* 🔴 **必须走 `/tasks/<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() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -129,7 +271,11 @@ export struct FbTasksPage {
FbPrimaryButton({
label: '新建任务',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设。 */
onTap: (): void => {
this.openNew();
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -145,13 +291,43 @@ export struct FbTasksPage {
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// 操作失败的一行红字(如勾完成失败)—— 不弹 toast,就地说明
if (this.opNote !== '') {
Text(this.opNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
}
// ---- 看板筛选条(横向滚动)----
Scroll() {
Row({ space: 8 }) {
this.boardChip('全部', '')
ForEach(this.boards, (b: string) => {
this.boardChip(b, b)
}, (b: string): string => 'bd_' + b)
/* v65:看板从已加载的任务里反推(原来这个数组**从来没被赋值** ⇒
这条筛选条永远是空的,只剩一个「全部」) */
ForEach(this.boardOpts(), (b: FbBoardOpt) => {
this.boardChip(b.name, b.name)
}, (b: FbBoardOpt): string => 'bd_' + b.id.toString())
// 「只看已完成」是设置菜单打开的筛选 ⇒ 这里给个能取消的胶囊
if (this.statusFilter === 'done') {
Row({ space: 4 }) {
Text('只看已完成')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
Image($r('app.media.fb_ic_close'))
.width(10)
.height(10)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(fbAlpha(this.accent, 0.1))
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.statusFilter = '';
})
}
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
@@ -222,17 +398,155 @@ export struct FbTasksPage {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
/* v65:这两条原来 `action === ''`(画灰、点了不响应)。
现在都接上了:
· taskdone → 只看已完成(对应网页版 `?view=done`)
· taskboards → 看板列表(挑一个看板只看它;增删改看板仍在网页端) */
if (action === 'taskdone') {
this.statusFilter = 'done';
this.settingsOpen = false;
return;
}
if (action === 'taskboards') {
this.boardSheetOpen = true;
this.settingsOpen = false;
return;
}
}
})
.transition(fbSheetTransition())
}
// ---- 新建 / 编辑任务浮层(v65)----
if (this.editOpen) {
FbTaskEditSheet({
accent: this.accent,
boards: this.boardOpts(),
editingId: this.editingId,
editingTitle: this.editingTitle,
editingNote: this.editingNote,
editingDueAt: this.editingDueAt,
editingBoardId: this.editingBoardId,
onClose: (): void => {
this.editOpen = false;
},
onSaved: (): void => {
this.editOpen = false;
// 服务端会派生逾期/看板色,保存后重拉一遍
this.load();
}
})
.transition(fbSheetTransition())
}
// ---- 「分类与看板」浮层(v65)----
if (this.boardSheetOpen) {
/* 🔴 遮罩与卡片必须**叠**(`Stack`),不能当 `Column` 的子项"排" ——
排会把卡片挤出屏幕(本项目 2026-10-20 真机事故,见 FbTaskEditSheet 同一说明)。 */
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.boardSheetOpen = false;
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('切换看板')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('挑一个看板,只看它的任务')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.boardSheetOpen = false;
},
color: this.accent
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 2 }) {
this.boardRow('全部', '', '', this.tasks.length)
ForEach(this.boardOpts(), (b: FbBoardOpt) => {
this.boardRow(b.name, b.name, b.color, this.countOf(b.name))
}, (b: FbBoardOpt): string => 'bs_' + b.id.toString())
}
.width('100%')
}
.constraintSize({ maxHeight: 340 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
Text('增删改看板、改分类,仍在网页端「任务 → 分类与看板」里做。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(2)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
}
/** 「分类与看板」浮层里的一行:色点 + 名字 + 条数(点它切筛选) */
@Builder
boardRow(label: string, key: string, color: string, count: number) {
Row({ space: 8 }) {
FbDot({ dia: 8, fillC: color !== '' ? color : this.accent })
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.boardFilter === key ? this.accent : FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(count.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
if (this.boardFilter === key) {
Image($r('app.media.fb_ic_check'))
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
}
}
.width('100%')
.padding({ left: 6, right: 6, top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.boardFilter = key;
this.boardSheetOpen = false;
})
}
/** 看板筛选胶囊 */
@Builder
boardChip(label: string, key: string) {
@@ -265,17 +579,30 @@ export struct FbTasksPage {
.backgroundColor(FbColors.paper)
}
/** 一张任务卡 */
/** 一张任务卡(整卡可点 → 打开编辑;左边的圈单独点 → 勾完成) */
@Builder
taskCard(t: FbTaskItem) {
Column({ space: 8 }) {
Row({ space: 10 }) {
// 状态圈:完成=实心+勾、进行中=实心+白点、待办=空心
FbCheckCircle({
dia: 16,
done: t.status === 'done',
doing: t.status === 'doing',
color: this.accent
/* 状态圈 + 一块透明热区。
🔴 v65:原来这个圈只有 16vp 且**没有 `onClick`** ⇒
「没法那个完成」就是这条 —— 点它什么都不发生。
⇒ 外面包一层 32×32 的热区(手指点得中),并接上改状态。
⚠️ 内层有点击时父级的整卡点击**不会**再触发(ArkUI 由最内层消费),
所以勾完成不会顺带弹出编辑浮层。 */
Stack({ alignContent: Alignment.Center }) {
FbCheckCircle({
dia: 16,
done: t.status === 'done',
doing: t.status === 'doing',
color: this.accent
})
}
.width(32)
.height(32)
.flexShrink(0)
.onClick((): void => {
this.toggleDone(t);
})
Text(t.title)
@@ -336,16 +663,42 @@ export struct FbTasksPage {
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(HorizontalAlign.Start)
/* 🔴 v65:整卡可点 → 打开编辑(原来列表项**没有 `onClick`** ⇒ "点不进去") */
.onClick((): void => {
this.openEdit(t);
})
}
private filtered(): FbTaskItem[] {
if (this.boardFilter.length === 0) {
return this.tasks;
let list: FbTaskItem[] = this.tasks;
if (this.boardFilter.length > 0) {
list = list.filter((t: FbTaskItem): boolean => t.board === this.boardFilter);
}
return this.tasks.filter((t: FbTaskItem): boolean => t.board === this.boardFilter);
/* v65:设置里的「已完成」(对应网页版 `?view=done`)。
⚠️ 只看已完成时**不再按状态分组**(一份列表里只有一个组,分组头就没意义了),
见 `groups()`。 */
if (this.statusFilter === 'done') {
list = list.filter((t: FbTaskItem): boolean => t.status === 'done');
}
return list;
}
private groups(): FbGroup<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());
}
+363 -42
View File
@@ -12,7 +12,7 @@
* 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。
*/
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem, FbTripNode } from '../../model/Types';
import { fbMoney } from '../../model/DemoData';
@@ -25,6 +25,12 @@ import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
/* v65:题图要走媒体代理(`Image()` 加不了自定义请求头,令牌得用 URL query 带过去) */
import { fbImageSrc } from '../../data/FbAttachQueue';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { fetchTripNodes } from '../../data/FbTripRepo';
import { FbTripNewSheet } from '../../components/FbTripNewSheet';
import { FbTripNodeSheet } from '../../components/FbTripNodeSheet';
@ComponentV2
export struct FbTripsPage {
@@ -66,6 +72,27 @@ export struct FbTripsPage {
private accent: string = '#3F7D5A';
/** 当前展开的旅程 id(0 = 都没展开) */
@Local expandedId: number = 0;
/**
* 「新建旅程」浮层是否打开(v65)。
*
* 🔴 原先这颗按钮**没有 `onTap`** ⇒ 点了什么都不发生(用户:
* 「新建旅程啊…这些东西都还没完成」)。现在接上了。
*/
@Local newOpen: boolean = false;
/** 正在编辑的旅程(0 = 新建)。点卡片的「编辑」用 */
@Local editingId: number = 0;
@Local editingTitle: string = '';
@Local editingSummary: string = '';
/** 行程节点正在加载(展开时拉 `/trips/<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 {
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 */
if (this.nodeSheetTripId > 0) {
this.nodeSheetTripId = 0;
return true;
}
if (this.newOpen) {
this.newOpen = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
// 行程展开着时,返回先收起它(否则用户只能靠再点一次卡片)
if (this.expandedId !== 0) {
this.expandedId = 0;
return true;
}
return false;
}
@@ -119,6 +161,23 @@ export struct FbTripsPage {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
/**
* 新建/编辑浮层开着 ⇒ 底部悬浮快捷栏也要让位。
*
* ⚠️ 与 `settingsOpen` 各管一个 @Monitor:两个浮层**不会同时开**
* (新建时设置菜单已关),所以这里直接按 `newOpen` 赋值即可。
*/
@Monitor('newOpen')
onNewChanged(): void {
fbAppState().sheetCount = this.newOpen ? 1 : 0;
}
/** 「加一段行程」浮层同样要让悬浮栏让位 */
@Monitor('nodeSheetTripId')
onNodeSheetChanged(): void {
fbAppState().sheetCount = this.nodeSheetTripId > 0 ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
@@ -144,7 +203,15 @@ export struct FbTripsPage {
FbPrimaryButton({
label: '新建旅程',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
icon: $r('app.media.fb_ic_plus'),
/* 🔴 v65:接上回调。原来这颗按钮**没有 `onTap`** —— 纯摆设,
点了什么都不发生(用户报的"新建旅程没完成"就是这条)。 */
onTap: (): void => {
this.editingId = 0;
this.editingTitle = '';
this.editingSummary = '';
this.newOpen = true;
}
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
@@ -221,92 +288,258 @@ export struct FbTripsPage {
})
.transition(fbSheetTransition())
}
// ---- 新建 / 编辑旅程浮层(v65)----
if (this.newOpen) {
FbTripNewSheet({
accent: this.accent,
editingId: this.editingId,
editingTitle: this.editingTitle,
editingSummary: this.editingSummary,
onClose: (): void => {
this.newOpen = false;
},
onSaved: (): void => {
this.newOpen = false;
// 保存后重新拉一遍列表(服务端的城市/时间是派生的,本地拼不出来)
this.expandedId = 0;
this.load();
}
})
.transition(fbSheetTransition())
}
// ---- 加一段行程浮层(v65)----
if (this.nodeSheetTripId > 0) {
FbTripNodeSheet({
tripId: this.nodeSheetTripId,
accent: this.accent,
onClose: (): void => {
this.nodeSheetTripId = 0;
},
onSaved: (): void => {
const id: number = this.nodeSheetTripId;
this.nodeSheetTripId = 0;
// 加完重拉节点(服务端会重算派生时间,列表也要跟着变)
this.load();
this.loadNodes(id);
}
})
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
}
/** 一张旅程卡:标题 + 时间 + 进度;展开后接行程时间轴 */
/**
* 一张旅程卡:**题图 + 标题 + 时间/城市/段数 + 简介 + 同行人**。
*
* 🔴 v65(用户 2026-10-20):「旅程的列表也不太对呀,应该有,题图啊、标题啊、
* 然后城市啊,应该跟网页版的题图是类似的。」
* ⇒ 照 `templates/trips/_list_body.php` 的 `trip-card` 重做:
* 网页版是**横排**(左题图 132×96 + 右 `trip-main`)。
* 手机上按同样比例缩到 96×72(4:3),右边留足文字宽度。
*
* ⚠️ 题图比例:网页版列表卡是 132×96 ≈ 4:3,而**详情页**的 hero 是 16:9。
* 这里按列表卡的 4:3 走(与用户要的"跟网页版类似"一致)。
*/
@Builder
tripCard(t: FbTripItem) {
Column({ space: 0 }) {
// 卡头
Row({ space: 10 }) {
// 左侧色条(模块色的竖条,一眼区分旅程)
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(this.accent)
// ---- 卡头:左题图 + 右内容(横排,同网页版)----
Row({ space: 12 }) {
// 题图:有图放真图,没图放占位(网页版是 🧳)
Stack({ alignContent: Alignment.Center }) {
if (t.coverKey !== '') {
Image(fbImageSrc(t.coverKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
// ⚠️ 不给 alt 图标:旅程封面是实景照,失败时给纯色底比给个
// "行李箱图标"更不刺眼(网页版失败时也是底色调)
.onError((): void => {
this.markCoverFailed(t.id);
})
}
// 有图但加载失败时,也别留一块透明 —— 统一给底色 + 图标
if (t.coverKey === '' || this.coverFailed(t.id)) {
Column({ space: 4 }) {
Image($r('app.media.fb_ic_trips'))
.width(22)
.height(22)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1E7D6')
}
}
.width(96)
.height(72)
.borderRadius(FbMetric.radiusS)
.clip(true)
.flexShrink(0)
Column({ space: 3 }) {
// 右侧:标题 / meta / 简介
Column({ space: 4 }) {
Text(t.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// meta 一行:时间 · 📍城市 · N 段行程
Row({ space: 10 }) {
if (t.dateRange !== '') {
Text(t.dateRange)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
if (t.city !== '') {
Text('📍 ' + t.city)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.layoutWeight(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
} else {
Column().layoutWeight(1)
}
}
.width('100%')
// 段数 + 同行人数(网页版是一排 meta,这里小屏分两行更清楚)
Row({ space: 8 }) {
Text(t.dateRange)
Text(t.nodeCount.toString() + ' 段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(t.nodeCount > 0 ? this.accent : FbColors.faint)
if (t.doneCount > 0) {
Text('已完成 ' + t.doneCount.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.attendeeCount > 0) {
Text('· ' + t.attendeeCount.toString() + ' 人同行')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
}
.width('100%')
// 简介:网页版截 80 字,这里两行截断(手机一行更窄)
if (t.summary !== '') {
Text(t.summary)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
Text(t.sub)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.lineHeight(17)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
if (t.nodeCount > 0) {
Text(t.doneCount.toString() + '/' + t.nodeCount.toString())
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(this.accent)
Text('行程')
.fontSize(10)
.fontColor(FbColors.faint)
} else {
FbTag({ text: '还没行程', tone: 'muted' })
}
.flexShrink(1)
}
.width('100%')
.padding(FbMetric.cardPad)
.alignItems(VerticalAlign.Top)
.onClick((): void => {
// 点卡片主体 = 展开/收起行程(网页版是"进详情",手机上一屏放不下,
// 就地展开更顺手;编辑走下面那颗小按钮)
const was: boolean = this.isExpanded(t.id);
this.expandedId = was ? 0 : t.id;
if (!was) {
this.loadNodes(t.id);
}
.alignItems(HorizontalAlign.End)
})
// ---- 卡尾操作条:编辑(网页版进详情页才有编辑,这里给个显式入口)----
Row({ space: 14 }) {
Text(this.isExpanded(t.id) ? '收起行程' : '查看行程')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
Column().layoutWeight(1)
Text('编辑')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.openEdit(t);
})
Image($r('app.media.fb_ic_forward'))
.width(14)
.height(14)
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
.rotate({ angle: this.isExpanded(t.id) ? 90 : 0 })
}
.width('100%')
.padding(FbMetric.cardPad)
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 12 })
.alignItems(VerticalAlign.Center)
.onClick(() => {
// 展开/收起:只改本地状态,不发请求
this.expandedId = this.isExpanded(t.id) ? 0 : t.id;
})
// 展开区:行程时间轴
// ---- 展开区:行程时间轴 ----
if (this.isExpanded(t.id)) {
Divider()
.color(FbColors.line2)
.margin({ left: FbMetric.cardPad, right: FbMetric.cardPad })
if (this.nodes.length === 0) {
Column() {
Text('这个旅程还没有排行程')
if (this.nodesLoading) {
Row({ space: 8 }) {
LoadingProgress()
.width(16)
.height(16)
.color(this.accent)
Text('正在取行程…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding(FbMetric.cardPad)
.alignItems(VerticalAlign.Center)
} else if (this.nodesNote !== '') {
/* 拉失败时**说实话**,不假装"还没排行程" ——
后者会让用户以为自己的行程丢了。 */
Column({ space: 6 }) {
Text(this.nodesNote)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
Text('点这里重试')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.onClick((): void => {
this.loadNodes(t.id);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(FbMetric.cardPad)
} else if (this.nodes.length === 0) {
Column({ space: 8 }) {
Text('这个旅程还没有排行程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
this.addNodeBtn(t.id)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(FbMetric.cardPad)
} else {
Column({ space: 0 }) {
// 分两段列:「实际」(已发生的)在前,「计划」在后 —— 与 Web 端顺序一致
this.stageBlock('实际', 'actual')
this.stageBlock('计划', 'plan')
// 已有节点时,加号排在最后(像网页版时间轴的末尾)
this.addNodeBtn(t.id)
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: FbMetric.cardPad })
@@ -320,6 +553,94 @@ export struct FbTripsPage {
.alignItems(HorizontalAlign.Start)
}
/** 打开编辑浮层(预填当前值) */
private openEdit(t: FbTripItem): void {
this.editingId = t.id;
this.editingTitle = t.title;
this.editingSummary = t.summary;
this.newOpen = true;
}
/** 展开区底部那颗「+ 加一段行程」 */
@Builder
addNodeBtn(tripId: number) {
Row({ space: 6 }) {
Image($r('app.media.fb_ic_plus'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
Text('加一段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.padding({ left: 10, right: 12, top: 7, bottom: 7 })
.backgroundColor(fbAlpha(this.accent, 0.08))
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
.onClick((): void => {
this.nodeSheetTripId = tripId;
})
}
/**
* 拉某趟旅程的行程节点。
*
* 🔴 v65:这是端上原先**最大**的缺口 —— `nodes` 一直是空数组,
* 所以每趟旅程展开后永远显示"还没有排行程"。
* 用户原话:「现在这个旅程下面也没有这个行程啊」。
*
* ⚠️ 三个状态要分清(否则用户会以为数据丢了):
* ① 正在拉 → 转圈;② 拉失败 → 明说"没取到" + 可重试;
* ③ 拉成功但确实没有 → 才说"还没排行程"。
*/
private async loadNodes(tripId: number): Promise<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
stageBlock(title: string, stage: string) {