Skip to content

Commit 8101605

Browse files
authored
Merge pull request #176 from Travisun/feat/watchlist-chart-fund-detail
feat: watchlist charts, fund detail, and quote circuit fix
2 parents ec446d5 + 3a76714 commit 8101605

72 files changed

Lines changed: 5514 additions & 671 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

client-ui/src/api/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,17 @@ export const research = {
516516
20000,
517517
),
518518

519+
fundDetail: (
520+
instrument: InstrumentRef,
521+
signal?: AbortSignal,
522+
) =>
523+
apiCall<import('../types/market').FundDetailData>(
524+
'fund_detail',
525+
{ instrument, code: instrument.symbol },
526+
{ signal },
527+
25000,
528+
),
529+
519530
etfScorecard: (code: string, signal?: AbortSignal) =>
520531
apiCall<import('../types/market').EtfScorecardData>(
521532
'etf_scorecard',

client-ui/src/market/FundDetailTab.tsx

Lines changed: 161 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { 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'
33
import { EditRegular } from '@fluentui/react-icons'
44
import { 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'
76
import FundNavChart from './FundNavChart'
87
import TradingViewChart from './TradingViewChart'
98
import { DETAIL_PANEL_CHART_MAX_HEIGHT_PX } from './chartViewConfig'
9+
import {
10+
FundArchivePanel,
11+
FundHoldersPanel,
12+
FundHoldingsPanel,
13+
FundPerformancePanel,
14+
} from './fundDetailPanels'
1015
import {
1116
formatCompactNumber,
1217
formatPct,
@@ -21,8 +26,14 @@ import { displayCodeFromInstrument, resolveWatchlistInstrument } from './instrum
2126
import { opptrixTokens, opptrixCssVars } from '../theme/tokens'
2227
import { ghostInteractive } from '../theme/mixins'
2328

29+
type FundTab =
30+
| 'chart'
31+
| 'archive'
32+
| 'performance'
33+
| 'holdings'
34+
| 'holders'
35+
2436
const CONTENT_PAD = '15px'
25-
const DETAIL_FOOTNOTE = '净值约每 1–2 分钟刷新;持仓与档案详情请通过助手查询。'
2637

2738
const 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+
219238
export 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
}

client-ui/src/market/StockDetailTab.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from './format'
1414
import TradingViewChart from './TradingViewChart'
1515
import { DETAIL_PANEL_CHART_MAX_HEIGHT_PX } from './chartViewConfig'
16+
import { WATCHLIST_QUOTES_POLL_MS } from './watchlistQuotes'
1617
import {
1718
normalizeWatchlistItem,
1819
resolveWatchlistInstrument,
@@ -25,7 +26,7 @@ import { opptrixTokens, opptrixCssVars } from '../theme/tokens'
2526
import { ghostInteractive } from '../theme/mixins'
2627

2728
const CONTENT_PAD = '15px'
28-
const DETAIL_FOOTNOTE = '行情约每 1–2 分钟刷新;公司资料、财务与新闻请通过助手查询。'
29+
const DETAIL_FOOTNOTE = '行情约每 1 分钟刷新;公司资料、财务与新闻请通过助手查询。'
2930

3031
const useStyles = makeStyles({
3132
root: {
@@ -264,7 +265,7 @@ function StockDetailTab({
264265

265266
useEffect(() => {
266267
if (!stockKey) return undefined
267-
const timer = window.setInterval(() => setDetailReload(n => n + 1), 90_000)
268+
const timer = window.setInterval(() => setDetailReload(n => n + 1), WATCHLIST_QUOTES_POLL_MS)
268269
return () => window.clearInterval(timer)
269270
}, [stockKey])
270271

0 commit comments

Comments
 (0)