/** * 家书 FamilyBook · 财务模块(鸿蒙端) * * 对应 Web 端 `templates/ledger/index.php`。Web 端的结构(用户明确要求过的): * 「上面是概览,下面就是具体账目…新建账目和设置都在右侧; * 每个账目的金额变化与总金额变化,在账本列表上面画出折线图。」 * * 端上保留这个层次: * ① 账本横条(多本账可横向切换) * ② 概览卡:净资产大字 + 资产/负债分列(折线图位置留给第二轮,需要图表能力) * ③ 账目表:按类别分组,每行带「盘点」入口 * * ⚠️ 金额全程以**分**参与计算(`Ledger` 那套约定),显示时才转成元; * 任何地方都不要拿「元」去加总 —— 浮点误差会越滚越大。 */ import { FbColors, FbMetric } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbGroup, FbLedgerBook, FbLedgerEntry } from '../../model/Types'; import { FB_DEMO_LEDGER_BOOKS, FB_DEMO_LEDGER_ENTRIES, fbLedgerKindName, fbMoney } from '../../model/DemoData'; import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets'; @ComponentV2 export struct FbLedgerPage { @Param app: FbAppState = fbAppState(); /** 模块主题色(财务 = 橄榄绿) */ private accent: string = '#5E7A3C'; private books: FbLedgerBook[] = FB_DEMO_LEDGER_BOOKS; private entries: FbLedgerEntry[] = FB_DEMO_LEDGER_ENTRIES; /** 当前账本 id(0 = 用第一本) */ @Local bookId: number = 0; build() { Column() { // ---- 页头 ---- Row({ space: 8 }) { Column({ space: 2 }) { Text(this.currentBook().name) .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) Text(this.books.length.toString() + ' 本账 · ' + this.currentBook().entryCount.toString() + ' 条账目') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) FbPrimaryButton({ label: '记一笔', bg: this.accent, icon: $r('app.media.fb_ic_plus') }) FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted }) } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) // ---- 账本切换(横向)---- Scroll() { Row({ space: 8 }) { ForEach(this.books, (b: FbLedgerBook) => { Row({ space: 6 }) { Text(b.name) .fontSize(FbMetric.fontSmall) .fontColor(this.isCurrent(b.id) ? Color.White : FbColors.muted) .maxLines(1) if (b.myPerm !== 'owner') { FbTag({ text: b.myPerm === 'write' ? '可写' : '只读', tone: this.isCurrent(b.id) ? 'brand' : 'muted' }) } } .padding({ left: 12, right: 12, top: 7, bottom: 7 }) .backgroundColor(this.isCurrent(b.id) ? this.accent : FbColors.surface) .borderRadius(FbMetric.radiusPill) .alignItems(VerticalAlign.Center) .onClick(() => { this.bookId = b.id; }) }, (b: FbLedgerBook): string => 'bk_' + b.id.toString()) } .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad }) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .width('100%') .padding({ bottom: 10 }) Scroll() { Column({ space: 0 }) { // ---- 概览卡 ---- Column({ space: 12 }) { Column({ space: 4 }) { Text('净资产') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) Text(fbMoney(this.netCent())) .fontSize(30) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) } .alignItems(HorizontalAlign.Start) .width('100%') // 资产 / 负债分列 Row({ space: 0 }) { Column({ space: 3 }) { Text('资产') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(fbMoney(this.assetCent())) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(FbColors.green) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) Column({ space: 3 }) { Text('负债') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(fbMoney(this.debtCent())) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(FbColors.red) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .width('100%') // 折线图占位(第二轮接图表能力后填) Column({ space: 4 }) { Row({ space: 6 }) { Image($r('app.media.fb_ic_chart')) .width(13) .height(13) .renderMode(ImageRenderMode.Template) .fillColor(FbColors.faint) Text('净资产变化(盘点成线)') .fontSize(10) .fontColor(FbColors.faint) } .alignItems(VerticalAlign.Center) Column() .width('100%') .height(56) .borderRadius(FbMetric.radiusS) .backgroundColor(FbColors.inputBg) } .width('100%') .alignItems(HorizontalAlign.Start) } .width('100%') .padding(FbMetric.cardPad) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad }) .alignItems(HorizontalAlign.Start) // ---- 账目(按类别分组)---- Row({ space: 8 }) { Text('账目') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) Blank() Text(this.entries.length.toString() + ' 条 · 按类别') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .width('100%') .padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 14, bottom: 8 }) if (this.entries.length === 0) { FbEmptyBox({ title: '这本账还没有账目', hint: '存款、基金、保险、房子、车子都可以记进来', icon: $r('app.media.fb_ic_ledger') }) } else { ForEach(this.groups(), (g: FbGroup) => { Column({ space: 0 }) { // 类别小标题 + 该类别小计 Row({ space: 8 }) { Text(g.title) .fontSize(FbMetric.fontTiny) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) Text(fbMoney(this.sumOf(g.items))) .fontSize(10) .fontColor(FbColors.faint) Blank() } .width('100%') .padding({ top: 10, bottom: 6 }) Column({ space: 0 }) { ForEach(g.items, (e: FbLedgerEntry, idx: number) => { this.entryRow(e, idx === g.items.length - 1) }, (e: FbLedgerEntry): string => 'le_' + e.id.toString()) } .width('100%') } .width('100%') .padding(FbMetric.cardPad) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 12 }) .alignItems(HorizontalAlign.Start) }, (g: FbGroup): string => 'lg_' + g.key) Blank().height(16) } } .width('100%') } .layoutWeight(1) .width('100%') .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .align(Alignment.TopStart) } .width('100%') .height('100%') .backgroundColor(FbColors.paper) .alignItems(HorizontalAlign.Start) } /** 一条账目:名字 + 归属人 + 金额 + 盘点入口 */ @Builder entryRow(e: FbLedgerEntry, isLast: boolean) { Column({ space: 0 }) { Row({ space: 10 }) { Column({ space: 3 }) { Row({ space: 6 }) { Text(e.name) .fontSize(FbMetric.fontBody) .fontColor(FbColors.ink) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (e.ownerName.length > 0) { FbTag({ text: e.ownerName, tone: 'muted' }) } } .alignItems(VerticalAlign.Center) Text('更新于 ' + e.updatedAt) .fontSize(10) .fontColor(FbColors.faint) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Text(fbMoney(e.amountCent)) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(e.amountCent < 0 ? FbColors.red : FbColors.ink) // 「盘点」:Web 端是每行一个入口,端上保留成一个轻按钮 Text('盘点') .fontSize(10) .fontColor(this.accent) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(this.accent + '14') .borderRadius(FbMetric.radiusPill) } .width('100%') .padding({ top: 11, bottom: 11 }) .alignItems(VerticalAlign.Center) if (!isLast) { Divider().color(FbColors.line2) } } .width('100%') } private isCurrent(id: number): boolean { return this.currentId() === id; } private currentId(): number { if (this.bookId > 0) { return this.bookId; } return this.books.length > 0 ? this.books[0].id : 0; } private currentBook(): FbLedgerBook { const hit: FbLedgerBook | undefined = this.books.find((b: FbLedgerBook): boolean => b.id === this.currentId()); if (hit !== undefined) { return hit; } return { id: 0, name: '账本', netCent: 0, myPerm: 'owner', entryCount: 0 }; } private netCent(): number { return this.currentBook().netCent; } private assetCent(): number { let sum: number = 0; for (const e of this.entries) { if (e.amountCent > 0) { sum = sum + e.amountCent; } } return sum; } private debtCent(): number { let sum: number = 0; for (const e of this.entries) { if (e.amountCent < 0) { sum = sum + e.amountCent; } } return sum; } private sumOf(items: FbLedgerEntry[]): number { let sum: number = 0; for (const e of items) { sum = sum + e.amountCent; } return sum; } private groups(): FbGroup[] { const keys: string[] = []; const buckets: FbLedgerEntry[][] = []; for (const e of this.entries) { const k: string = fbLedgerKindName(e.kind); let idx: number = keys.indexOf(k); if (idx < 0) { keys.push(k); buckets.push([]); idx = keys.length - 1; } buckets[idx].push(e); } const out: FbGroup[] = []; for (let i = 0; i < keys.length; i++) { out.push({ key: keys[i], title: keys[i], items: buckets[i], count: buckets[i].length.toString() + ' 条' }); } return out; } }