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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 分组建书架(在读 / 想读 / 读完) */
|
||||||
|
|||||||
@@ -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' }
|
||||||
];
|
];
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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[][] = [];
|
||||||
|
|||||||
@@ -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>[] {
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user