364 lines
12 KiB
Plaintext
364 lines
12 KiB
Plaintext
/**
|
|||
|
|
* 家书 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<FbLedgerEntry>) => {
|
||
|
|
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<FbLedgerEntry>): 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<FbLedgerEntry>[] {
|
||
|
|
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<FbLedgerEntry>[] = [];
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|