1- import { FC , useMemo } from 'react' ;
1+ import { FC , useContext , useMemo } from 'react' ;
22import { Badge , Card } from 'react-bootstrap' ;
33
4- import { INDEX_CATEGORY_LABELS , INDEX_RISK_LABELS } from '../../constants/finance' ;
5- import styles from '../../styles/Finance.module.scss ' ;
4+ import { INDEX_CATEGORY_LABEL_KEYS , INDEX_RISK_LABEL_KEYS } from '../../constants/finance' ;
5+ import { I18nContext } from '../../models/Translation ' ;
66import { IndexFundSnapshot , IndexHistoryPoint } from '../../types/finance' ;
7+ import styles from './Finance.module.less' ;
78
89export interface FundCardProps {
910 data : IndexFundSnapshot ;
1011}
1112
12- const formatNumber = ( value : number | null | undefined ) =>
13- value == null ? '--' : value . toLocaleString ( 'zh-CN' , { maximumFractionDigits : 2 } ) ;
13+ const formatNumber = ( value ? : number | null ) =>
14+ value != null ? value . toLocaleString ( 'zh-CN' , { maximumFractionDigits : 2 } ) : '--' ;
1415
15- const formatPercent = ( value : number | null | undefined ) =>
16- value == null ? '--' : `${ ( value * 100 ) . toFixed ( 2 ) } %` ;
16+ const formatPercent = ( value ? : number | null ) =>
17+ value != null ? `${ ( value * 100 ) . toFixed ( 2 ) } %` : '--' ;
1718
18- const valueTone = ( value : number | null | undefined ) =>
19- value == null ? styles . mutedText : value >= 0 ? styles . positiveText : styles . negativeText ;
19+ const valueTone = ( value ? : number | null ) =>
20+ value != null ? ( value >= 0 ? styles . positiveText : styles . negativeText ) : styles . mutedText ;
2021
2122const Sparkline : FC < { points : IndexHistoryPoint [ ] ; chartId : string } > = ( { points, chartId } ) => {
23+ const { t } = useContext ( I18nContext ) ;
24+
2225 const { polyline, gradientStops } = useMemo ( ( ) => {
2326 if ( ! points . length ) return { polyline : '' , gradientStops : [ ] as number [ ] } ;
2427
25- const values = points . map ( point => point . value ) ;
28+ const values = points . map ( ( { value } ) => value ) ;
2629 const min = Math . min ( ...values ) ;
2730 const max = Math . max ( ...values ) ;
2831 const delta = max - min || 1 ;
2932
3033 const polylinePoints = points
31- . map ( ( point , index ) => {
32- const x = ( index / Math . max ( points . length - 1 , 1 ) ) * 100 ;
33- const y = ( ( max - point . value ) / delta ) * 40 ;
34+ . map ( ( { value } , index , { length } ) => {
35+ const x = ( index / Math . max ( length - 1 , 1 ) ) * 100 ;
36+ const y = ( ( max - value ) / delta ) * 40 ;
3437
3538 return `${ x . toFixed ( 2 ) } ,${ y . toFixed ( 2 ) } ` ;
3639 } )
@@ -40,14 +43,11 @@ const Sparkline: FC<{ points: IndexHistoryPoint[]; chartId: string }> = ({ point
4043
4144 return { polyline : polylinePoints , gradientStops : gradientOffsets } ;
4245 } , [ points ] ) ;
43-
44- if ( ! points . length ) return < div className = { styles . sparklinePlaceholder } > 数据准备中</ div > ;
45-
4646 const gradientId = `${ chartId } -gradient` ;
4747
4848 return (
4949 < div className = { styles . sparkline } >
50- < svg viewBox = "0 0 100 40" role = "img" aria-label = "近 60 日走势" >
50+ < svg viewBox = "0 0 100 40" role = "img" aria-label = { t ( 'index_sparkline_60d_label' ) } >
5151 < defs >
5252 < linearGradient id = { gradientId } x1 = "0%" y1 = "0%" x2 = "100%" y2 = "0%" >
5353 { gradientStops . map ( offset => (
@@ -73,22 +73,25 @@ const Sparkline: FC<{ points: IndexHistoryPoint[]; chartId: string }> = ({ point
7373 ) ;
7474} ;
7575
76- export const FundCard : FC < FundCardProps > = ( { data } ) => {
77- const {
78- displayName,
79- category,
80- riskLevel,
81- latestValue,
82- dailyChangePct,
83- oneYearReturnPct,
84- maxDrawdownPct,
85- tags,
86- sparkline,
87- updatedAt,
88- fallback,
89- description,
90- } = data ;
91- const sparklineId = useMemo ( ( ) => `fund-${ data . symbol } ` , [ data . symbol ] ) ;
76+ export const FundCard : FC < IndexFundSnapshot > = ( {
77+ symbol,
78+ displayName,
79+ category,
80+ riskLevel,
81+ latestValue,
82+ dailyChangePct,
83+ oneYearReturnPct,
84+ maxDrawdownPct,
85+ tags,
86+ sparkline,
87+ updatedAt,
88+ fallback,
89+ description,
90+ source,
91+ } ) => {
92+ const { t } = useContext ( I18nContext ) ;
93+ const sparklineId = useMemo ( ( ) => `fund-${ symbol } ` , [ symbol ] ) ;
94+ const updatedAtISO = updatedAt ? new Date ( updatedAt ) . toJSON ( ) : undefined ;
9295
9396 return (
9497 < Card className = { `${ styles . fundCard } h-100` } >
@@ -100,7 +103,7 @@ export const FundCard: FC<FundCardProps> = ({ data }) => {
100103 < h3 className = "h5 mb-1" > { displayName } </ h3 >
101104 < div className = "d-flex flex-wrap gap-2 align-items-center" >
102105 < Badge bg = "light" text = "dark" >
103- { INDEX_CATEGORY_LABELS [ category ] }
106+ { t ( INDEX_CATEGORY_LABEL_KEYS [ category ] ) }
104107 </ Badge >
105108 < Badge
106109 bg = {
@@ -111,60 +114,58 @@ export const FundCard: FC<FundCardProps> = ({ data }) => {
111114 : 'success'
112115 }
113116 >
114- { INDEX_RISK_LABELS [ riskLevel ] }
117+ { t ( INDEX_RISK_LABEL_KEYS [ riskLevel ] ) }
115118 </ Badge >
116119 { fallback && (
117120 < Badge bg = "secondary" text = "light" >
118- 离线数据
121+ { t ( 'offline_data' ) }
119122 </ Badge >
120123 ) }
121124 </ div >
122125 </ div >
123126 < small className = "text-muted text-end" >
124- 数据源 < br />
125- { data . source . historyEndpoint }
127+ { t ( 'data_source' ) } < br />
128+ { source . historyEndpoint }
126129 </ small >
127130 </ div >
128131
129132 < p className = "text-muted mb-0" > { description } </ p >
130133
131- < div className = { `${ styles . metricRow } d-flex flex-wrap gap-4 align-items-center` } >
132- < div >
133- < p className = "text-muted mb-1" > 最新点位</ p >
134- < span className = { styles . metricValue } > { formatNumber ( latestValue ) } </ span >
135- </ div >
136- < div >
137- < p className = "text-muted mb-1" > 日涨跌</ p >
138- < strong className = { valueTone ( dailyChangePct ) } > { formatPercent ( dailyChangePct ) } </ strong >
139- </ div >
140- < div >
141- < p className = "text-muted mb-1" > 近 1 年收益</ p >
142- < strong className = { valueTone ( oneYearReturnPct ) } >
143- { formatPercent ( oneYearReturnPct ) }
144- </ strong >
145- </ div >
146- < div >
147- < p className = "text-muted mb-1" > 最大回撤</ p >
148- < strong className = { valueTone ( maxDrawdownPct ) } > { formatPercent ( maxDrawdownPct ) } </ strong >
149- </ div >
150- </ div >
134+ < dl className = { `${ styles . metricRow } d-flex flex-wrap gap-4 align-items-center` } >
135+ < dt className = "text-muted mb-1" > { t ( 'index_metric_latest_value' ) } </ dt >
136+ < dd className = { styles . metricValue } > { formatNumber ( latestValue ) } </ dd >
137+
138+ < dt className = "text-muted mb-1" > { t ( 'index_metric_daily_change' ) } </ dt >
139+ < dd className = { valueTone ( dailyChangePct ) } > { formatPercent ( dailyChangePct ) } </ dd >
140+
141+ < dt className = "text-muted mb-1" > { t ( 'index_metric_one_year_return' ) } </ dt >
142+ < dd className = { valueTone ( oneYearReturnPct ) } > { formatPercent ( oneYearReturnPct ) } </ dd >
151143
152- < Sparkline points = { sparkline } chartId = { sparklineId } />
144+ < dt className = "text-muted mb-1" > { t ( 'index_metric_max_drawdown' ) } </ dt >
145+ < dd className = { valueTone ( maxDrawdownPct ) } > { formatPercent ( maxDrawdownPct ) } </ dd >
146+ </ dl >
153147
154- < div className = "d-flex flex-wrap gap-2" >
148+ { sparkline . length ? (
149+ < Sparkline points = { sparkline } chartId = { sparklineId } />
150+ ) : (
151+ < div className = { styles . sparklinePlaceholder } > { t ( 'data_preparing' ) } </ div >
152+ ) }
153+
154+ < ul className = "list-unstyled m-0 d-flex flex-wrap gap-2" >
155155 { tags ?. map ( tag => (
156- < Badge key = { tag } bg = "light" text = "dark" className = { styles . tagBadge } >
156+ < Badge key = { tag } as = "li" bg = "light" text = "dark" className = { styles . tagBadge } >
157157 { tag }
158158 </ Badge >
159159 ) ) }
160- </ div >
160+ </ ul >
161161
162- < div className = "d-flex justify-content-between align-items-center" >
163- < small className = "text-muted" > 更新于 { updatedAt || '--' } </ small >
164- < a href = { `/finance/${ data . symbol } ` } className = "text-primary fw-semibold" >
165- 查看详情 →
166- </ a >
167- </ div >
162+ < time className = "small text-muted" dateTime = { updatedAtISO } >
163+ { t ( 'updated_at' ) } { updatedAt || '--' }
164+ </ time >
165+
166+ < a href = { `/finance/${ symbol } ` } className = "text-primary fw-semibold" >
167+ { t ( 'view_details' ) }
168+ </ a >
168169 </ Card . Body >
169170 </ Card >
170171 ) ;
0 commit comments