HarmonyOS7AA 分账结算页实战:费用汇总与成员收支平衡 文章目录前言费用记录要同时描述付款人和参与人总花费是派生值不需要单独存成员余额的正负号怎么来的新增消费时当前实现默认全员参与已结清状态目前只负责展示这个页面适合继续往哪扩完整代码最后总结前言AA 分账页最容易写成“记账列表”谁付了多少钱下面一排消费记录。这样当然能展示数据但真正有价值的是结算关系也就是每个人最后应该收回多少钱、还欠多少钱。这个案例没有做复杂的转账路径生成比如“小红转给小明 120 元”这种明细但它把分账页面的核心账本跑通了消费记录进入expenses总金额由记录累加出来每个成员的余额再从所有消费里推导。只要这条链路没写错后面加结算建议、多人选择、历史账单都会有基础。费用记录要同时描述付款人和参与人Expense里最关键的不是amount而是paidBy和participants这两个字段。AA 分账的麻烦就在这里付款的人不一定等于参与分摊的人。interfaceExpense{id:numbertitle:stringamount:numberpaidBy:stringparticipants:string[]settled:boolean}比如源码里的“打车费”是小张付的但只有小明和小张参与分摊。它不能简单按全员平均否则另外两个人会被错误计入成本。{id:4,title:打车费,amount:80,paidBy:小张,participants:[小明,小张],settled:true}Member目前只保存姓名和颜色这个颜色被用在付款人标签上。它不是业务必需字段但对列表可读性很有帮助尤其是消费记录多起来以后用户能更快识别是谁付的钱。总花费是派生值不需要单独存顶部蓝色卡片显示总花费它来自expenses的实时累加。这里用 getter 很合适因为总金额完全可以从已有记录推出来单独保存反而容易和列表不同步。gettotalExpense():number{returnthis.expenses.reduce((s,e)se.amount,0)}旧稿里容易把这段写成totalExpense(): number但源码里它是 getter所以页面使用时是this.totalExpense.toFixed(2)不是调用this.totalExpense()。这个差别在 ArkTS 里很实际模板里读 getter 更像读一个状态值表达上也更接近“总花费是由账单推导出来的结果”。成员余额的正负号怎么来的这页最值得看的函数是getMemberBalance()。它的算法不长但正负号含义一定要想清楚某人付款时余额先加上整笔金额如果他参与分摊再减掉自己应该承担的那一份。getMemberBalance(name:string):number{letbalance0this.expenses.forEach(e{constsharee.amount/e.participants.lengthif(e.paidByname)balancee.amountif(e.participants.includes(name))balance-share})returnbalance}拿“住宿费用 1200 元四个人均摊小明付款”来说小明先1200再因为自己也参与分摊减掉300这笔记录对他的净影响是900。其他三个人没有付款但参与分摊各自是-300。这个写法有一个隐含前提每笔费用都是等额分摊。如果要支持自定义比例、有人多付一点、孩子不参与分摊就不能只用participants.length算share需要把参与人的分摊金额也放进数据结构里。新增消费时当前实现默认全员参与点右上角会打开添加弹窗弹窗状态由showAdd、newTitle、newAmount、newPaidBy维护。用户可以输入消费说明和金额也可以选择付款人。StateshowAdd:booleanfalseStatenewTitle:stringStatenewAmount:stringStatenewPaidBy:string小明添加按钮里做了一个很基础的校验标题存在并且金额能被parseFloat()转成数字。通过后把新记录追加到expenses再清空输入状态并关闭弹窗。constamtparseFloat(this.newAmount)if(this.newTitle!isNaN(amt)){this.expensesthis.expenses.concat([{id:Date.now(),title:this.newTitle,amount:amt,paidBy:this.newPaidBy,participants:this.members.map(mm.name),settled:false}])this.newTitlethis.newAmountthis.showAddfalse}这里有个产品口径要说清楚新增记录默认participants是所有成员。源码目前没有提供“选择参与人”的 UI所以新加的消费一定是全员均摊。如果你在教程里把它说成支持灵活分摊就和实际代码不一致了。已结清状态目前只负责展示settled会在列表右侧显示“已结清”或“未结清”但当前页面没有切换结清状态的交互也没有在余额计算里排除已结清记录。也就是说在这个版本里settled只是消费记录的展示标签不会改变totalExpense和getMemberBalance()的结果。这不是 bug更像是一个留好的扩展点。如果后面要做真正结算需要先决定口径已结清记录是否还参与历史总花费统计是否从当前应收应付里移除这两个答案不同代码结构也会不一样。这个页面适合继续往哪扩我会优先补“参与人选择”因为它直接影响分账准确性。第二步再做“结算建议”把每个人的正负余额转换成具体转账动作。现在的代码已经能算出每个人的余额缺的是把余额池配平的那一步。还有一个小优化是减少重复调用。列表里每个成员会多次调用getMemberBalance(m.name)数据少时没感觉账单多了以后可以先把成员余额算成一个映射再用于渲染。教学示例保持现在这样更直观正式项目里可以再收一层。完整代码下面保留案例完整代码方便你直接对照学习、复制和重构。import{router}fromkit.ArkUIinterfaceExpense{id:numbertitle:stringamount:numberpaidBy:stringparticipants:string[]settled:boolean}interfaceMember{name:stringcolor:string}EntryComponentstruct SharedExpenseSplitter{Statemembers:Member[][{name:小明,color:#007AFF},{name:小红,color:#FF4757},{name:小李,color:#2ED573},{name:小张,color:#FFA502},]Stateexpenses:Expense[][{id:1,title:住宿费用,amount:1200,paidBy:小明,participants:[小明,小红,小李,小张],settled:false},{id:2,title:晚餐,amount:360,paidBy:小红,participants:[小明,小红,小李,小张],settled:false},{id:3,title:门票,amount:240,paidBy:小李,participants:[小明,小红,小李,小张],settled:false},{id:4,title:打车费,amount:80,paidBy:小张,participants:[小明,小张],settled:true},]StateshowAdd:booleanfalseStatenewTitle:stringStatenewAmount:stringStatenewPaidBy:string小明gettotalExpense():number{returnthis.expenses.reduce((s,e)se.amount,0)}getMemberBalance(name:string):number{letbalance0this.expenses.forEach(e{constsharee.amount/e.participants.lengthif(e.paidByname)balancee.amountif(e.participants.includes(name))balance-share})returnbalance}getMemberColor(name:string):string{returnthis.members.find(mm.namename)?.color??#888}build(){Stack(){Column(){Row(){Image($r(app.media.ic_back)).width(24).height(24).onClick(()router.back())Text(AA分账).fontSize(18).fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center)Text().fontSize(22).fontColor(#007AFF).onClick(()this.showAddtrue)}.width(100%).padding({left:16,right:16,top:12,bottom:12})// TotalColumn(){Text(总花费).fontSize(13).fontColor(#FFFFFFAA)Text(\¥\${this.totalExpense.toFixed(2)}\).fontSize(30).fontWeight(FontWeight.Bold).fontColor(#FFF).margin({top:4})Text(\\${this.members.length}人参与 · \${this.expenses.length}笔记录\).fontSize(12).fontColor(#FFFFFFAA).margin({top:4})}.width(calc(100% - 32vp)).backgroundColor(#007AFF).borderRadius(16).padding(20).margin({bottom:12}).alignItems(HorizontalAlign.Center)// BalancesText(各成员结算).fontSize(14).fontWeight(FontWeight.Bold).fontColor(#333).width(100%).padding({left:16,right:16,bottom:8})Row(){ForEach(this.members,(m:Member){Column(){Text(m.name).fontSize(13).fontColor(#555)Text(\\${this.getMemberBalance(m.name)0?:}¥\${this.getMemberBalance(m.name).toFixed(0)}\).fontSize(15).fontWeight(FontWeight.Bold).fontColor(this.getMemberBalance(m.name)0?#2ED573:#FF4757)Text(this.getMemberBalance(m.name)0?收回:欠款).fontSize(11).fontColor(#AAA).margin({top:2})}.layoutWeight(1).backgroundColor(#FFF).borderRadius(10).padding(10).margin({left:4,right:4})})}.width(100%).padding({left:12,right:12,bottom:12})// Expense listList({space:8}){ForEach(this.expenses,(e:Expense){ListItem(){Row(){Column(){Text(e.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(#333)Row(){Text(e.paidBy).fontSize(12).fontColor(#FFF).backgroundColor(this.getMemberColor(e.paidBy)).borderRadius(8).padding({left:6,right:6,top:2,bottom:2})Text( 付款).fontSize(12).fontColor(#888)Text(\ · \${e.participants.length}人均摊\).fontSize(12).fontColor(#888)}.margin({top:4})}.layoutWeight(1).alignItems(HorizontalAlign.Start)Column(){Text(\¥\${e.amount}\).fontSize(16).fontWeight(FontWeight.Bold).fontColor(#333)Text(\人均 ¥\${(e.amount/e.participants.length).toFixed(1)}\).fontSize(11).fontColor(#888)Text(e.settled?已结清:未结清).fontSize(11).fontColor(e.settled?#2ED573:#FF9500).margin({top:2})}.alignItems(HorizontalAlign.End)}.width(100%).backgroundColor(#FFF).borderRadius(12).padding(14).shadow({radius:4,color:#0000001A,offsetY:2})}})}.layoutWeight(1).padding({left:16,right:16})}.width(100%).height(100%).backgroundColor(#F5F5F5)// Add dialogif(this.showAdd){Column(){Column(){Text(添加消费).fontSize(17).fontWeight(FontWeight.Bold).margin({bottom:16})TextInput({placeholder:消费说明,text:this.newTitle}).onChange(vthis.newTitlev).margin({bottom:10})TextInput({placeholder:金额,text:this.newAmount}).type(InputType.Number).onChange(vthis.newAmountv).margin({bottom:10})Row(){Text(付款人).fontSize(14)ForEach(this.members,(m:Member){Text(m.name).fontSize(13).fontColor(this.newPaidBym.name?#FFF:#666).backgroundColor(this.newPaidBym.name?m.color:#EEE).borderRadius(12).padding({left:10,right:10,top:4,bottom:4}).margin({left:4}).onClick(()this.newPaidBym.name)})}.margin({bottom:16})Row(){Button(取消).layoutWeight(1).backgroundColor(#EEE).fontColor(#333).onClick((){this.showAddfalse})Button(添加).layoutWeight(1).backgroundColor(#007AFF).fontColor(#FFF).margin({left:12}).onClick((){constamtparseFloat(this.newAmount)if(this.newTitle!isNaN(amt)){this.expensesthis.expenses.concat([{id:Date.now(),title:this.newTitle,amount:amt,paidBy:this.newPaidBy,participants:this.members.map(mm.name),settled:false}])this.newTitlethis.newAmountthis.showAddfalse}})}}.backgroundColor(#FFF).borderRadius(16).padding(20).width(88%)}.width(100%).height(100%).backgroundColor(#00000060).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)}}}}最后总结AA 分账结算页 这个案例并不靠复杂技巧取胜它更像一个结构扎实、逻辑完整的业务页面样板。把这篇文章里的状态设计、函数组织和页面分层吃透之后你再去写同类型功能速度会明显快很多。