/** * 家书 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, updateLedgerEntry, deleteLedgerEntry } 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 = ''; /** * 编辑模式:> 0 = 改这一条(`0` = 新记一笔)。 * * 🔴 v68(用户 2026-10-20「财务的显示也有问题」的后续): * 原来这里只有「记一笔」,底部还写着"改/删去网页端"——因为服务端没有路由。 * v68 服务端补了 `PATCH/DELETE /ledger//entries`(id 走 body), * 端上就地把**改一笔 / 删一笔**做完。 */ @Param editId: number = 0; @Param editKind: string = 'deposit'; @Param editAmount: string = ''; @Param editTitle: string = ''; @Param editNote: 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 = ''; /** 删除的二次确认(删账目不可逆,必须先问一句) */ @Local confirmingDelete: boolean = false; /** * 编辑态要**把 @Param 拷进 @Local**。 * * ⚠️ 不能在字段初始化里写 `@Local amount: string = this.editAmount`: * V2 的成员初始化顺序不保证,`@Param` 那时可能还没赋值(拿到空串)。 * 浮层每次打开都是新实例 ⇒ 在 `aboutToAppear` 拷一次就够。 */ aboutToAppear(): void { if (this.editId > 0) { if (this.editKind !== '') { this.kind = this.editKind; } this.amount = this.editAmount; this.title = this.editTitle; this.note = this.editNote; } } private isEdit(): boolean { return this.editId > 0; } 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(this.isEdit() ? '改这一笔' : '记一笔') .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) if (this.isEdit()) { if (!this.confirmingDelete) { Text('删掉这一笔') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .width('100%') .textAlign(TextAlign.Center) .onClick((): void => { this.confirmingDelete = true; }) } else { Text('再点一次就删掉了(不可恢复)') .fontSize(FbMetric.fontTiny) .fontColor('#FFFFFF') .textAlign(TextAlign.Center) .width('100%') .height(38) .borderRadius(FbMetric.radiusS) .backgroundColor('#C0533F') .onClick((): void => { this.remove(); }) } } } .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 ? (this.isEdit() ? '保存中…' : '记入中…') : (this.isEdit() ? '保存改动' : '记下来')) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(this.saving ? FbColors.faint : '#FFFFFF') .textAlign(TextAlign.Center) .width('100%') .height(44) .borderRadius(FbMetric.radiusS) .backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98)) .onClick((): void => { this.save(); }) } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } private async save(): Promise { if (this.saving) { return; } const amt: string = this.amount.trim(); if (amt === '') { this.err = '金额得填一个数。'; return; } this.saving = true; this.err = ''; const res: FbApiResult = this.isEdit() ? await updateLedgerEntry(FB_TOKEN_STORE.get(), this.ledgerId, this.editId, this.kind, amt, this.title.trim(), this.note.trim()) : 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(); } /** 删掉这一笔(服务端 `DELETE /ledger//entries`,id 走 body + query) */ private async remove(): Promise { if (this.saving) { return; } this.saving = true; const res: FbApiResult = await deleteLedgerEntry(FB_TOKEN_STORE.get(), this.ledgerId, this.editId); this.saving = false; if (!res.ok) { this.err = res.error !== '' ? res.error : '没删掉,请稍后再试。'; this.confirmingDelete = false; return; } this.onSaved(); } }