文章目录
- 前言
- `StockItem_65tq` 同时保存行情和持仓
- 组合收益是一步一步推出来的
- 搜索只影响列表,不影响总资产
- 涨跌颜色来自单只股票,不来自组合
- `selectedTab` 目前只做高亮
- 完整代码
- 最后总结
前言
股票组合看板的重点不是把列表做漂亮,而是把几个金额关系算对。持仓市值、持仓成本、盈亏金额、盈亏率,这几个数字一旦口径不清,页面再精致也没用。这个 Demo 的数据量很小,但刚好能讲清楚组合看板里最基础的一套计算链路。
这页没有买入卖出、没有实时行情订阅,也没有真正的 tab 排序。它目前做的是:展示持仓,按名称或代码搜索,并根据持仓数量、当前价和成本价计算组合收益。
StockItem_65tq同时保存行情和持仓
每只股票对象里既有行情字段,也有持仓字段。price、change、changeRate、volume更像行情数据;held和cost则是用户自己的持仓信息。
interfaceStockItem_65tq{symbol:stringname:stringprice:numberchange:numberchangeRate:numbervolume:stringheld:numbercost:number}这个模型适合 Demo,因为所有信息在一个数组里,列表和汇总都能直接算。真实产品里通常会把行情和持仓拆开:行情来自市场接口,会高频变化;持仓来自账户系统,变化频率低得多。拆开之后,前端再按股票代码合并展示。
组合收益是一步一步推出来的
总市值是当前价乘以持仓数量,总成本是成本价乘以持仓数量,总盈亏是两者相减,盈亏率再除以总成本。代码把这几个值写成 getter,而不是保存成状态。
gettotalValue():number{returnthis.stocks.reduce((sum,s)=>sum+s.price*s.held,0)}gettotalCost():number{returnthis.stocks.reduce((sum,s)=>sum+s.cost*s.held,0)}gettotalProfit():number{returnthis.totalValue-this.totalCost}getprofitRate():number{returnthis.totalCost>0?(this.totalProfit/this.totalCost)*100:0}这个顺序很重要。totalProfit依赖totalValue和totalCost,profitRate又依赖totalProfit。只要stocks变化,所有汇总数字都会重新计算,不需要手动同步。
profitRate里判断this.totalCost > 0也值得保留。虽然示例数据都有成本,但真实数据里可能出现空持仓或成本为 0 的情况,直接除会得到无意义结果。
搜索只影响列表,不影响总资产
搜索框绑定searchText,列表渲染读取filteredStocks。如果没有搜索词,返回全部股票;有搜索词时,同时匹配股票代码和中文名称。
getfilteredStocks():StockItem_65tq[]{if(!this.searchText)returnthis.stocksreturnthis.stocks.filter(s=>s.symbol.toLowerCase().includes(this.searchText.toLowerCase())||s.name.includes(this.searchText))}这里有一个产品口径要注意:搜索只过滤列表,不影响顶部总市值和总盈亏。也就是说,用户搜索 TSLA 时,顶部仍然显示整个组合的资产,不是搜索结果的资产。这个选择没有绝对对错,但要保持一致。如果想让搜索后的汇总跟着变,就应该把汇总也改成基于filteredStocks计算。
涨跌颜色来自单只股票,不来自组合
列表里单只股票的涨跌幅颜色由changeRate决定,顶部组合盈亏颜色由totalProfit决定。这两个指标不能混在一起:一只股票当天上涨,不代表这只持仓一定盈利;因为用户成本价可能更高。
Text(`${stock.changeRate>=0?'+':''}${stock.changeRate.toFixed(2)}%`).fontColor('#FFF').backgroundColor(stock.changeRate>=0?'#2ED573':'#FF4757')当前列表还展示了“成本 $xxx”,但没有单独展示每只股票的持仓盈亏。如果要让看板更有投资分析价值,我会补一个单股盈亏:(stock.price - stock.cost) * stock.held,再显示盈亏率(stock.price - stock.cost) / stock.cost。
selectedTab目前只做高亮
页面上有“持仓、涨幅、跌幅”三个 tab,点击后会改变selectedTab,但filteredStocks并没有读取这个状态。因此当前 tab 只改变视觉选中态,不会真的排序或过滤列表。
.onClick(()=>this.selectedTab=idx)如果要补完整,最直接的做法是在filteredStocks之后再根据selectedTab排序:持仓页按市值排序,涨幅页按changeRate从高到低,跌幅页从低到高。这样 tab 才真的参与数据展示,而不是只停留在 UI 状态。
这也是这篇代码最适合继续练习的地方:先把计算口径固定,再把搜索、排序和汇总之间的关系讲清楚。资产类页面看似都是数字,真正麻烦的是每个数字到底代表什么范围。
完整代码
下面保留案例完整代码,方便你直接对照学习、复制和重构。
import{router}from'@kit.ArkUI'interfaceStockItem_65tq{symbol:stringname:stringprice:numberchange:numberchangeRate:numbervolume:stringheld:numbercost:number}@Entry@Componentstruct StockPortfolioDashboard{@Statestocks:StockItem_65tq[]=[{symbol:'AAPL',name:'苹果',price:189.5,change:2.3,changeRate:1.23,volume:'52.3M',held:10,cost:175.0},{symbol:'TSLA',name:'特斯拉',price:248.7,change:-5.1,changeRate:-2.01,volume:'38.1M',held:5,cost:260.0},{symbol:'MSFT',name:'微软',price:415.2,change:3.8,changeRate:0.92,volume:'21.7M',held:8,cost:390.0},{symbol:'GOOGL',name:'谷歌',price:172.4,change:-1.2,changeRate:-0.69,volume:'18.5M',held:12,cost:168.0},{symbol:'AMZN',name:'亚马逊',price:198.3,change:4.5,changeRate:2.32,volume:'29.8M',held:6,cost:185.0},]@StateselectedTab:number=0@StatesearchText:string=''gettotalValue():number{returnthis.stocks.reduce((sum,s)=>sum+s.price*s.held,0)}gettotalCost():number{returnthis.stocks.reduce((sum,s)=>sum+s.cost*s.held,0)}gettotalProfit():number{returnthis.totalValue-this.totalCost}getprofitRate():number{returnthis.totalCost>0?(this.totalProfit/this.totalCost)*100:0}getfilteredStocks():StockItem_65tq[]{if(!this.searchText)returnthis.stocksreturnthis.stocks.filter(s=>s.symbol.toLowerCase().includes(this.searchText.toLowerCase())||s.name.includes(this.searchText))}build(){Column(){// HeaderRow(){Image($r('app.media.startIcon')).width(24).height(24).onClick(()=>router.back())Text('股票组合').fontSize(18).fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center)Text('⚙').fontSize(20).fontColor('#555')}.width('100%').padding({left:16,right:16,top:12,bottom:12})// Portfolio summaryColumn(){Text('总市值').fontSize(13).fontColor('#FFFFFFAA').margin({bottom:4})Text(\`$\${this.totalValue.toFixed(2)}\`).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFF')Row(){Text(\`\${this.totalProfit>=0?'+':''}$\${this.totalProfit.toFixed(2)}\`).fontSize(14).fontColor(this.totalProfit>=0?'#2ED573':'#FF4757')Text(\`(\${this.profitRate>=0?'+':''}\${this.profitRate.toFixed(2)}%)\`).fontSize(14).fontColor(this.totalProfit>=0?'#2ED573':'#FF4757')}.margin({top:4})Text('累计持仓盈亏').fontSize(11).fontColor('#FFFFFFAA').margin({top:4})}.width('calc(100% - 32vp)').backgroundColor('#007AFF').borderRadius(16).padding(20).margin({bottom:12}).linearGradient({colors:[['#007AFF',0.0],['#0056CC',1.0]],angle:135})// SearchTextInput({placeholder:'搜索股票名称或代码',text:this.searchText}).onChange(v=>this.searchText=v).width('calc(100% - 32vp)').backgroundColor('#FFF').borderRadius(20).margin({bottom:12})// TabsRow(){ForEach(['持仓','涨幅','跌幅'],(tab:string,idx:number)=>{Text(tab).fontSize(14).fontColor(this.selectedTab===idx?'#007AFF':'#888').borderWidth({bottom:this.selectedTab===idx?2:0}).borderColor('#007AFF').padding({bottom:8,left:16,right:16}).onClick(()=>this.selectedTab=idx)})}.width('100%').padding({left:16}).margin({bottom:8})// Stock listList({space:8}){ForEach(this.filteredStocks,(stock:StockItem_65tq)=>{ListItem(){Row(){Column(){Text(stock.symbol).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333')Text(stock.name).fontSize(12).fontColor('#888')Text(\`持仓 \${stock.held}股\`).fontSize(11).fontColor('#AAA').margin({top:2})}.alignItems(HorizontalAlign.Start).layoutWeight(1)Column(){Text(\`$\${stock.price.toFixed(2)}\`).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#333')Text(\`\${stock.change>=0?'+':''}\${stock.change.toFixed(2)}\`).fontSize(12).fontColor(stock.change>=0?'#2ED573':'#FF4757')}.alignItems(HorizontalAlign.End).margin({right:8})Column(){Text(\`\${stock.changeRate>=0?'+':''}\${stock.changeRate.toFixed(2)}%\`).fontSize(13).fontColor('#FFF').backgroundColor(stock.changeRate>=0?'#2ED573':'#FF4757').borderRadius(6).padding({left:8,right:8,top:4,bottom:4})Text(\`成本 $\${stock.cost.toFixed(0)}\`).fontSize(11).fontColor('#AAA').margin({top:4})}.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')}}最后总结
股票组合看板 这个案例并不靠复杂技巧取胜,它更像一个结构扎实、逻辑完整的业务页面样板。把这篇文章里的状态设计、函数组织和页面分层吃透之后,你再去写同类型功能,速度会明显快很多。