1+ import { useMemo } from 'react'
12import { Spinner , Text , makeStyles , mergeClasses } from '@fluentui/react-components'
2- import { BriefcaseRegular } from '@fluentui/react-icons'
3+ import { BriefcaseRegular , SettingsRegular } from '@fluentui/react-icons'
34import SidebarListEmpty from './SidebarListEmpty'
45import type { PortfolioSummaryData } from '../types/schemas'
6+ import type { WatchlistItem } from '../types/market'
57import OpptrixButton from '../components/opptrix/OpptrixButton'
68import { formatPct , formatPrice , formatPriceForMarket , pctTone , portfolioHoldingsKey } from './format'
79import { instrumentKey , marketDisplayName , tryParseInstrumentInput } from './instrument'
810import { displayPortfolioHoldingReturnPct } from './portfolioCalc'
11+ import {
12+ aggregatePortfolioScopeSummary ,
13+ resolvePortfolioHoldingsForGroup ,
14+ } from './portfolioGroupCalc'
15+ import { useWatchlistGroups } from './WatchlistGroupsContext'
16+ import WatchlistGroupsDialog from './WatchlistGroupsDialog'
17+ import type { HoldingSnapshot } from './useFollowPortfolio'
918import type { Market } from '../types/instrument'
1019import { opptrixTokens , opptrixCssVars } from '../theme/tokens'
1120import { ghostInteractive , sidebarItemSelected } from '../theme/mixins'
@@ -24,6 +33,70 @@ const useStyles = makeStyles({
2433 minHeight : 0 ,
2534 height : '100%' ,
2635 } ,
36+ chipRow : {
37+ display : 'flex' ,
38+ alignItems : 'center' ,
39+ gap : '6px' ,
40+ padding : `8px ${ CONTENT_PAD } 6px` ,
41+ borderBottom : `1px solid ${ opptrixCssVars . separator } ` ,
42+ minWidth : 0 ,
43+ minHeight : '34px' ,
44+ boxSizing : 'border-box' ,
45+ flexShrink : 0 ,
46+ } ,
47+ chipsWrap : {
48+ flex : 1 ,
49+ minWidth : 0 ,
50+ display : 'flex' ,
51+ alignItems : 'center' ,
52+ gap : '6px' ,
53+ overflowX : 'auto' ,
54+ overflowY : 'hidden' ,
55+ } ,
56+ chip : { ...ghostInteractive ,
57+ flexShrink : 0 ,
58+ height : '26px' ,
59+ padding : '0 10px' ,
60+ border : 'none' ,
61+ borderRadius : opptrixTokens . radiusFull ,
62+ backgroundColor : 'transparent' ,
63+ color : opptrixCssVars . textSecondary ,
64+ fontSize : 'var(--opptrix-font-sm)' ,
65+ fontWeight : 500 ,
66+ cursor : 'pointer' ,
67+ whiteSpace : 'nowrap' ,
68+ ':hover' : {
69+ backgroundColor : opptrixCssVars . surfaceHover ,
70+ color : opptrixCssVars . textPrimary ,
71+ } ,
72+ } ,
73+ chipActive : {
74+ backgroundColor : opptrixCssVars . accentSoft ,
75+ color : opptrixCssVars . accent ,
76+ ':hover' : {
77+ backgroundColor : opptrixCssVars . accentSoft ,
78+ color : opptrixCssVars . accent ,
79+ } ,
80+ } ,
81+ chipEditBtn : { ...ghostInteractive ,
82+ flexShrink : 0 ,
83+ display : 'inline-flex' ,
84+ alignItems : 'center' ,
85+ justifyContent : 'center' ,
86+ width : '26px' ,
87+ height : '26px' ,
88+ padding : 0 ,
89+ border : 'none' ,
90+ borderRadius : opptrixTokens . radiusFull ,
91+ backgroundColor : 'transparent' ,
92+ color : opptrixCssVars . textTertiary ,
93+ cursor : 'pointer' ,
94+ lineHeight : 0 ,
95+ ':hover' : {
96+ backgroundColor : opptrixCssVars . surfaceHover ,
97+ color : opptrixCssVars . textPrimary ,
98+ } ,
99+ } ,
27100 summary : {
28101 flexShrink : 0 ,
29102 padding : `8px ${ CONTENT_PAD } 6px` ,
@@ -143,6 +216,17 @@ const useStyles = makeStyles({
143216 whiteSpace : 'nowrap' ,
144217 lineHeight : 1.2 ,
145218 } ,
219+ footer : {
220+ flexShrink : 0 ,
221+ display : 'flex' ,
222+ alignItems : 'center' ,
223+ justifyContent : 'space-between' ,
224+ gap : '8px' ,
225+ padding : `6px ${ CONTENT_PAD } 8px` ,
226+ borderTop : `1px solid ${ opptrixCssVars . separator } ` ,
227+ fontSize : 'var(--opptrix-font-xs)' ,
228+ color : opptrixCssVars . textTertiary ,
229+ } ,
146230 center : {
147231 display : 'flex' ,
148232 alignItems : 'center' ,
@@ -162,6 +246,8 @@ interface PortfolioTabProps {
162246 loading : boolean
163247 error : string
164248 onRetry : ( ) => void
249+ watchlistItems : WatchlistItem [ ]
250+ holdingsByCode : Record < string , HoldingSnapshot >
165251}
166252
167253function pnlColor ( pct : number | null | undefined ) : string {
@@ -184,8 +270,59 @@ export default function PortfolioTab({
184270 loading,
185271 error,
186272 onRetry,
273+ watchlistItems,
274+ holdingsByCode,
187275} : PortfolioTabProps ) {
188276 const s = useStyles ( )
277+ const {
278+ groups,
279+ membership,
280+ selectedGroupId,
281+ setSelectedGroupId,
282+ dialogOpen,
283+ setDialogOpen,
284+ replaceDoc,
285+ } = useWatchlistGroups ( )
286+
287+ const groupsDoc = useMemo (
288+ ( ) => ( { groups, membership } ) ,
289+ [ groups , membership ] ,
290+ )
291+
292+ const selectedGroupTitle = useMemo (
293+ ( ) => groups . find ( g => g . id === selectedGroupId ) ?. title ?? null ,
294+ [ groups , selectedGroupId ] ,
295+ )
296+
297+ const allHoldings = summary ?. holdings ?? [ ]
298+
299+ const scopedHoldings = useMemo (
300+ ( ) => resolvePortfolioHoldingsForGroup (
301+ allHoldings ,
302+ watchlistItems ,
303+ membership ,
304+ selectedGroupId ,
305+ holdingsByCode ,
306+ ) ,
307+ [ allHoldings , watchlistItems , membership , selectedGroupId , holdingsByCode ] ,
308+ )
309+
310+ const scopeSummary = useMemo (
311+ ( ) => ( selectedGroupId
312+ ? aggregatePortfolioScopeSummary ( scopedHoldings )
313+ : summary
314+ ? {
315+ totalCost : summary . totalCost ,
316+ totalMarketValue : summary . totalMarketValue ,
317+ totalUnrealizedPnl : summary . totalUnrealizedPnl ,
318+ totalRealizedPnl : summary . totalRealizedPnl ,
319+ totalPnl : summary . totalPnl ,
320+ totalPnlPct : summary . totalPnlPct ,
321+ holdingsCount : summary . holdingsCount ,
322+ }
323+ : null ) ,
324+ [ selectedGroupId , scopedHoldings , summary ] ,
325+ )
189326
190327 if ( ! active ) {
191328 return < div className = { s . root } />
@@ -223,21 +360,60 @@ export default function PortfolioTab({
223360 )
224361 }
225362
226- const data = summary
227- const holdings = data ?. holdings ?? [ ]
363+ const holdings = selectedGroupId ? scopedHoldings : allHoldings
228364 const empty = holdings . length === 0
229- const totalPnlPct = isSanePortfolioReturnPct ( data ?. totalPnlPct ) ? data ?. totalPnlPct : null
365+ const displaySummary = scopeSummary
366+ const totalPnlPct = isSanePortfolioReturnPct ( displaySummary ?. totalPnlPct )
367+ ? displaySummary ?. totalPnlPct
368+ : null
369+ const showGroupChips = true
230370
231371 return (
232372 < div className = { s . root } >
233- { ! empty && data ? (
373+ { showGroupChips ? (
374+ < div className = { s . chipRow } >
375+ < div className = { mergeClasses ( s . chipsWrap , 'opptrix-scroll-x' ) } >
376+ < button
377+ type = "button"
378+ className = { mergeClasses ( s . chip , ! selectedGroupId && s . chipActive ) }
379+ onClick = { ( ) => setSelectedGroupId ( null ) }
380+ >
381+ 全部
382+ </ button >
383+ { groups . map ( group => (
384+ < button
385+ key = { group . id }
386+ type = "button"
387+ className = { mergeClasses ( s . chip , selectedGroupId === group . id && s . chipActive ) }
388+ onClick = { ( ) => setSelectedGroupId ( group . id ) }
389+ >
390+ { group . title }
391+ </ button >
392+ ) ) }
393+ </ div >
394+ < button
395+ type = "button"
396+ className = { s . chipEditBtn }
397+ aria-label = "管理分组"
398+ onClick = { ( ) => setDialogOpen ( true ) }
399+ >
400+ < SettingsRegular fontSize = { 14 } />
401+ </ button >
402+ </ div >
403+ ) : null }
404+
405+ { ! empty && displaySummary ? (
234406 < div className = { s . summary } >
235407 < div className = { s . metric } >
236- < Text className = { s . metricLabel } > 总市值</ Text >
237- < Text className = { s . metricValue } > { formatPrice ( data . totalMarketValue ) } </ Text >
408+ < Text className = { s . metricLabel } >
409+ { selectedGroupTitle ? `${ selectedGroupTitle } · 市值` : '总市值' }
410+ </ Text >
411+ < Text className = { s . metricValue } > { formatPrice ( displaySummary . totalMarketValue ) } </ Text >
238412 </ div >
239413 < div className = { s . metric } >
240- < Text className = { s . metricLabel } > 总收益</ Text >
414+ < Text className = { s . metricLabel } >
415+ { selectedGroupTitle ? '分组收益' : '总收益' }
416+ </ Text >
241417 < Text className = { s . metricValue } style = { { color : pnlColor ( totalPnlPct ) } } >
242418 { formatPct ( totalPnlPct ) }
243419 </ Text >
@@ -246,14 +422,14 @@ export default function PortfolioTab({
246422 < Text className = { s . metricLabel } > 浮动盈亏</ Text >
247423 < Text
248424 className = { s . metricValue }
249- style = { { color : pnlColor ( data . totalUnrealizedPnl ) } }
425+ style = { { color : pnlColor ( displaySummary . totalUnrealizedPnl ) } }
250426 >
251- { formatPrice ( data . totalUnrealizedPnl ) }
427+ { formatPrice ( displaySummary . totalUnrealizedPnl ) }
252428 </ Text >
253429 </ div >
254430 < div className = { s . metric } >
255431 < Text className = { s . metricLabel } > 持仓</ Text >
256- < Text className = { s . metricValue } > { data . holdingsCount } 只</ Text >
432+ < Text className = { s . metricValue } > { displaySummary . holdingsCount } 只</ Text >
257433 </ div >
258434 </ div >
259435 ) : null }
@@ -262,8 +438,16 @@ export default function PortfolioTab({
262438 { empty ? (
263439 < SidebarListEmpty
264440 icon = { < BriefcaseRegular /> }
265- title = "还没有持仓记录"
266- hint = "在个股或 ETF 详情里录入买卖后,会在这里汇总市值与盈亏"
441+ title = {
442+ selectedGroupTitle
443+ ? `「${ selectedGroupTitle } 」暂无持仓`
444+ : '还没有持仓记录'
445+ }
446+ hint = {
447+ selectedGroupTitle
448+ ? '该分组内的关注标的尚未录入买卖,或切换到「全部」查看其他持仓'
449+ : '在个股或 ETF 详情里录入买卖后,会在这里汇总市值与盈亏'
450+ }
267451 />
268452 ) : (
269453 holdings . map ( ( h , index ) => {
@@ -321,6 +505,24 @@ export default function PortfolioTab({
321505 } )
322506 ) }
323507 </ div >
508+
509+ { ! empty && displaySummary ? (
510+ < div className = { s . footer } >
511+ < span >
512+ { selectedGroupTitle
513+ ? `${ holdings . length } 只 · ${ selectedGroupTitle } `
514+ : `${ displaySummary . holdingsCount } 只 · 全部组合` }
515+ </ span >
516+ </ div >
517+ ) : null }
518+
519+ < WatchlistGroupsDialog
520+ open = { dialogOpen }
521+ items = { watchlistItems }
522+ doc = { groupsDoc }
523+ onClose = { ( ) => setDialogOpen ( false ) }
524+ onSave = { replaceDoc }
525+ />
324526 </ div >
325527 )
326528}
0 commit comments