Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbLedgerEntrySheet.ets
T

359 lines
14 KiB
Plaintext
Raw Normal View History

/**
* 家书 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/<id>/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<void> {
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/<id>/entries`,id 走 body + query) */
private async remove(): Promise<void> {
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();
}
}