11import { useEffect , useMemo , useState } from 'react'
2- import { Spinner , Text , Badge , makeStyles , mergeClasses } from '@fluentui/react-components'
2+ import { Spinner , Tab , TabList , Text , Badge , makeStyles , mergeClasses } from '@fluentui/react-components'
33import { EditRegular } from '@fluentui/react-icons'
44import { research } from '../api/client'
5- import type { WatchlistItem } from '../types/market'
6- import type { FundProfileData , FundSnapshotData } from '../types/market'
5+ import type { WatchlistItem , FundDetailData , FundProfileData , FundSnapshotData } from '../types/market'
76import FundNavChart from './FundNavChart'
87import TradingViewChart from './TradingViewChart'
98import { DETAIL_PANEL_CHART_MAX_HEIGHT_PX } from './chartViewConfig'
9+ import {
10+ FundArchivePanel ,
11+ FundHoldersPanel ,
12+ FundHoldingsPanel ,
13+ FundPerformancePanel ,
14+ } from './fundDetailPanels'
1015import {
1116 formatCompactNumber ,
1217 formatPct ,
@@ -21,8 +26,14 @@ import { displayCodeFromInstrument, resolveWatchlistInstrument } from './instrum
2126import { opptrixTokens , opptrixCssVars } from '../theme/tokens'
2227import { ghostInteractive } from '../theme/mixins'
2328
29+ type FundTab =
30+ | 'chart'
31+ | 'archive'
32+ | 'performance'
33+ | 'holdings'
34+ | 'holders'
35+
2436const CONTENT_PAD = '15px'
25- const DETAIL_FOOTNOTE = '净值约每 1–2 分钟刷新;持仓与档案详情请通过助手查询。'
2637
2738const useStyles = makeStyles ( {
2839 root : {
@@ -138,28 +149,26 @@ const useStyles = makeStyles({
138149 textOverflow : 'ellipsis' ,
139150 whiteSpace : 'nowrap' ,
140151 } ,
141- chartBody : {
152+ subTabs : {
153+ flexShrink : 0 ,
154+ padding : `0 ${ CONTENT_PAD } ` ,
155+ borderBottom : `1px solid ${ opptrixCssVars . separator } ` ,
156+ overflowX : 'auto' ,
157+ } ,
158+ body : {
159+ flex : 1 ,
160+ minHeight : 0 ,
161+ overflow : 'auto' ,
162+ padding : CONTENT_PAD ,
163+ } ,
164+ chartWrap : {
142165 flex : 1 ,
143166 minHeight : 0 ,
144167 display : 'flex' ,
145168 flexDirection : 'column' ,
146- overflowY : 'auto' ,
147- } ,
148- chartPanel : {
149- flexShrink : 0 ,
150169 maxHeight : `${ DETAIL_PANEL_CHART_MAX_HEIGHT_PX } px` ,
151- minHeight : '200px' ,
152170 padding : `4px ${ CONTENT_PAD } 8px` ,
153171 overflow : 'hidden' ,
154- display : 'flex' ,
155- flexDirection : 'column' ,
156- } ,
157- foot : {
158- flexShrink : 0 ,
159- padding : `0 ${ CONTENT_PAD } 10px` ,
160- fontSize : 'var(--opptrix-font-xs)' ,
161- color : opptrixCssVars . textTertiary ,
162- lineHeight : 1.45 ,
163172 } ,
164173 center : {
165174 flex : 1 ,
@@ -216,13 +225,25 @@ function numField(raw: unknown): number | null {
216225 return Number . isFinite ( n ) ? n : null
217226}
218227
228+ function snapshotFromDetail ( detail : FundDetailData | null ) : FundSnapshotData | null {
229+ if ( ! detail ?. snapshot ) return null
230+ return {
231+ code : detail . snapshot . code || detail . code ,
232+ profile : detail . snapshot . profile ?? null ,
233+ nav : detail . snapshot . nav ?? null ,
234+ quote : detail . snapshot . quote ?? null ,
235+ }
236+ }
237+
219238export default function FundDetailTab ( {
220239 stock,
221240 isHolding = false ,
222241 onManage,
223242} : Props ) {
224243 const s = useStyles ( )
244+ const [ tab , setTab ] = useState < FundTab > ( 'chart' )
225245 const [ snapshot , setSnapshot ] = useState < FundSnapshotData | null > ( null )
246+ const [ detail , setDetail ] = useState < FundDetailData | null > ( null )
226247 const [ loading , setLoading ] = useState ( false )
227248 const [ error , setError ] = useState ( '' )
228249
@@ -246,30 +267,67 @@ export default function FundDetailTab({
246267
247268 const profile = useMemo ( ( ) => mergeProfile ( snapshot ) , [ snapshot ] )
248269 const quoteRaw = snapshot ?. quote as Record < string , unknown > | null
270+ const failed = detail ?. failed ?? [ ]
271+ const holders = detail ?. holders ?? null
272+ const showHoldersTab = Boolean (
273+ holders
274+ && (
275+ holders . holderAmount != null
276+ || holders . instHolderRatio != null
277+ || holders . indivHolderRatio != null
278+ || holders . mgmtStaffHoldRatio != null
279+ || holders . avgHolderShare != null
280+ ) ,
281+ )
282+
283+ const performanceReturns = useMemo ( ( ) => {
284+ if ( detail ?. returns ) return detail . returns
285+ if ( ! profile ) return null
286+ if ( profile . performance || profile . ranks || profile . peerAvg ) {
287+ return {
288+ performance : profile . performance ,
289+ ranks : profile . ranks ,
290+ peerAvg : profile . peerAvg ,
291+ }
292+ }
293+ return null
294+ } , [ detail ?. returns , profile ] )
249295
250296 useEffect ( ( ) => {
251297 if ( ! instrumentRef ) {
252298 setSnapshot ( null )
299+ setDetail ( null )
253300 setError ( '' )
254301 return undefined
255302 }
256303 let cancelled = false
304+ setTab ( 'chart' )
257305 setLoading ( true )
258306 setError ( '' )
259- research . fundSnapshot ( instrumentRef )
260- . then ( resp => {
307+ setDetail ( null )
308+ research . fundDetail ( instrumentRef )
309+ . then ( async resp => {
310+ if ( cancelled ) return
311+ if ( resp . success && resp . data ?. snapshot ) {
312+ setDetail ( resp . data )
313+ setSnapshot ( snapshotFromDetail ( resp . data ) )
314+ return
315+ }
316+ const snap = await research . fundSnapshot ( instrumentRef )
261317 if ( cancelled ) return
262- if ( ! resp . success || ! resp . data ) {
263- setError ( resp . message || '暂时无法加载基金信息,请稍后再试' )
318+ if ( ! snap . success || ! snap . data ) {
319+ setError ( resp . message || snap . message || '暂时无法加载基金信息,请稍后再试' )
264320 setSnapshot ( null )
265321 return
266322 }
267- setSnapshot ( resp . data )
323+ setSnapshot ( snap . data )
324+ setDetail ( resp . data ?? null )
268325 } )
269326 . catch ( e => {
270327 if ( ! cancelled ) {
271- setError ( e instanceof Error ? e . message : '加载失败 ' )
328+ setError ( e instanceof Error ? e . message : '暂时无法加载基金信息,请稍后重试 ' )
272329 setSnapshot ( null )
330+ setDetail ( null )
273331 }
274332 } )
275333 . finally ( ( ) => {
@@ -278,6 +336,10 @@ export default function FundDetailTab({
278336 return ( ) => { cancelled = true }
279337 } , [ instrumentRef ] )
280338
339+ useEffect ( ( ) => {
340+ if ( tab === 'holders' && ! showHoldersTab ) setTab ( 'chart' )
341+ } , [ tab , showHoldersTab ] )
342+
281343 const displayName = resolveDisplayStockName ( stockCode ?? '' , profile ?. name , stock ?. name )
282344 const unitNav = profile ?. unitNav
283345 const changePct = profile ?. changePct
@@ -292,6 +354,14 @@ export default function FundDetailTab({
292354 : unitNav
293355 const premiumPct = numField ( quoteRaw ?. premiumPct ?? quoteRaw ?. premiumRate )
294356
357+ if ( loading && ! profile ) {
358+ return (
359+ < div className = { s . center } >
360+ < Spinner size = "small" label = "正在加载基金信息…" />
361+ </ div >
362+ )
363+ }
364+
295365 return (
296366 < div className = { s . root } >
297367 < div className = { s . hero } >
@@ -318,8 +388,6 @@ export default function FundDetailTab({
318388 { formatPct ( changePct ) }
319389 </ span >
320390 </ >
321- ) : loading ? (
322- < Spinner size = "tiny" label = "正在获取基金净值…" />
323391 ) : null }
324392 </ div >
325393 </ div >
@@ -328,52 +396,84 @@ export default function FundDetailTab({
328396 ) : null }
329397 { profile ? (
330398 < div className = { s . heroGrid } >
331- < HeroCell
332- label = { isListedFund ? '单位净值' : '净值' }
333- value = { formatPrice ( unitNav ) }
334- />
335- < HeroCell label = "累计净值" value = { formatPrice ( profile . accNav ) } />
336- < HeroCell label = "基金类型" value = { profile . fundType ?? '—' } />
337- < HeroCell
338- label = "规模"
339- value = { profile . scale != null ? `${ formatCompactNumber ( profile . scale ) } 亿` : '—' }
340- />
341- < HeroCell
342- label = "近一年"
343- value = { formatPct ( profile . return1y ?? null ) }
344- />
345- < HeroCell label = "基金经理" value = { profile . manager ?? '—' } />
346- < HeroCell label = "成立日期" value = { profile . establishDate ?? '—' } />
347- < HeroCell
348- label = "净值日期"
349- value = { profile . navDate ?? '—' }
350- />
351- { isListedFund && premiumPct != null ? (
352- < HeroCell label = "折溢价" value = { formatPct ( premiumPct ) } />
353- ) : null }
354- { profile . expenseRatio != null ? (
355- < HeroCell label = "管理费" value = { `${ profile . expenseRatio } %` } />
356- ) : null }
357- </ div >
399+ < HeroCell
400+ label = { isListedFund ? '单位净值' : '净值' }
401+ value = { formatPrice ( unitNav ) }
402+ />
403+ < HeroCell label = "复权净值" value = { formatPrice ( profile . accNav ) } />
404+ < HeroCell label = "基金类型" value = { profile . fundType ?? '—' } />
405+ < HeroCell
406+ label = "规模"
407+ value = { profile . scale != null ? `${ formatCompactNumber ( profile . scale ) } 亿` : '—' }
408+ />
409+ < HeroCell label = "近一年" value = { formatPct ( profile . return1y ?? null ) } />
410+ < HeroCell label = "基金经理" value = { profile . manager ?? '—' } />
411+ < HeroCell label = "成立日期" value = { profile . establishDate ?? '—' } />
412+ < HeroCell label = "净值日期" value = { profile . navDate ?? '—' } />
413+ { isListedFund && premiumPct != null ? (
414+ < HeroCell label = "折溢价" value = { formatPct ( premiumPct ) } />
415+ ) : null }
416+ { profile . expenseRatio != null ? (
417+ < HeroCell label = "管理费" value = { `${ profile . expenseRatio } %` } />
418+ ) : null }
419+ </ div >
358420 ) : null }
359421 </ div >
360422
361- < div className = { s . chartBody } >
362- < div className = { s . chartPanel } >
423+ < div className = { s . subTabs } >
424+ < TabList size = "small" selectedValue = { tab } onTabSelect = { ( _ , d ) => setTab ( d . value as FundTab ) } >
425+ < Tab value = "chart" > 走势</ Tab >
426+ < Tab value = "archive" > 档案</ Tab >
427+ < Tab value = "performance" > 业绩</ Tab >
428+ < Tab value = "holdings" > 持仓</ Tab >
429+ { showHoldersTab ? < Tab value = "holders" > 持有人</ Tab > : null }
430+ </ TabList >
431+ </ div >
432+
433+ { error && profile ? < Text className = { s . error } > { error } </ Text > : null }
434+
435+ { tab === 'chart' ? (
436+ < div className = { s . chartWrap } >
363437 { instrumentRef && isListedFund && ! isLof ? (
364438 < TradingViewChart
365439 code = { displayCode }
366440 instrument = { chartInstrument }
367441 expanded
368- active
442+ active = { tab === 'chart' }
369443 />
370444 ) : instrumentRef ? (
371- < FundNavChart instrument = { instrumentRef } active />
445+ < FundNavChart instrument = { instrumentRef } active = { tab === 'chart' } />
372446 ) : null }
373447 </ div >
374- { error && profile ? < Text className = { s . error } > 刷新失败:{ error } </ Text > : null }
375- < Text className = { s . foot } > { DETAIL_FOOTNOTE } </ Text >
376- </ div >
448+ ) : (
449+ < div className = { s . body } >
450+ { tab === 'archive' && (
451+ < FundArchivePanel profile = { profile } />
452+ ) }
453+ { tab === 'performance' && (
454+ < FundPerformancePanel
455+ returns = { performanceReturns }
456+ loading = { loading && ! detail }
457+ failed = { failed }
458+ />
459+ ) }
460+ { tab === 'holdings' && (
461+ < FundHoldingsPanel
462+ holdings = { detail ?. holdings ?? [ ] }
463+ allocation = { detail ?. allocation ?? null }
464+ loading = { loading && ! detail }
465+ failed = { failed }
466+ />
467+ ) }
468+ { tab === 'holders' && (
469+ < FundHoldersPanel
470+ holders = { holders }
471+ loading = { loading && ! detail }
472+ failed = { failed }
473+ />
474+ ) }
475+ </ div >
476+ ) }
377477 </ div >
378478 )
379479}
0 commit comments