Skip to content

Commit 0867140

Browse files
Ruicursoragent
andcommitted
feat(client-ui): 组合 Tab 对齐关注分组并支持分组盈亏汇总
组合侧复用关注分组筛选与列表顺序,全部视图保留全量汇总,分组视图按子集重算市值与盈亏指标。 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 963c9ec commit 0867140

3 files changed

Lines changed: 313 additions & 13 deletions

File tree

client-ui/src/market/PortfolioTab.tsx

Lines changed: 215 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import { useMemo } from 'react'
12
import { Spinner, Text, makeStyles, mergeClasses } from '@fluentui/react-components'
2-
import { BriefcaseRegular } from '@fluentui/react-icons'
3+
import { BriefcaseRegular, SettingsRegular } from '@fluentui/react-icons'
34
import SidebarListEmpty from './SidebarListEmpty'
45
import type { PortfolioSummaryData } from '../types/schemas'
6+
import type { WatchlistItem } from '../types/market'
57
import OpptrixButton from '../components/opptrix/OpptrixButton'
68
import { formatPct, formatPrice, formatPriceForMarket, pctTone, portfolioHoldingsKey } from './format'
79
import { instrumentKey, marketDisplayName, tryParseInstrumentInput } from './instrument'
810
import { 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'
918
import type { Market } from '../types/instrument'
1019
import { opptrixTokens, opptrixCssVars } from '../theme/tokens'
1120
import { 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

167253
function 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
}

client-ui/src/market/RightMarketPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,8 @@ function RightMarketPanel({
489489
loading={portfolioLoading}
490490
error={portfolioError}
491491
onRetry={() => { void refreshHoldings() }}
492+
watchlistItems={items}
493+
holdingsByCode={holdingsByCode}
492494
/>
493495
</div>
494496
{tab === 'detail' && detailStock && detailKind === 'cn-index' ? (

0 commit comments

Comments
 (0)