Skip to content

Commit 3a1f964

Browse files
committed
feat(finance): internationalize finance page and fund cards
- Replace hardcoded Chinese copy in /pages/finance and FundCard with i18n t() calls - Refactor finance constants to export i18n label keys instead of raw strings - Add finance-related translation entries for zh-CN, en-US, and zh-TW - Move Finance styles from global SCSS to co-located Finance.module.less - Tweak AkShare/finance data utilities and API handler to align with new flow
1 parent f8ca608 commit 3a1f964

File tree

11 files changed

+460
-272
lines changed

11 files changed

+460
-272
lines changed

components/Finance/FundCard.tsx

Lines changed: 69 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,39 @@
1-
import { FC, useMemo } from 'react';
1+
import { FC, useContext, useMemo } from 'react';
22
import { 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';
66
import { IndexFundSnapshot, IndexHistoryPoint } from '../../types/finance';
7+
import styles from './Finance.module.less';
78

89
export 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

2122
const 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
);

constants/finance.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { IndexFundCategory, IndexRiskLevel } from '../types/finance';
2+
import zhCN from '../translation/zh-CN';
23

3-
export const INDEX_CATEGORY_LABELS: Record<IndexFundCategory, string> = {
4-
broad: '宽基',
5-
sector: '行业',
6-
theme: '主题',
4+
export type FinanceLabelKey = keyof typeof zhCN;
5+
6+
export const INDEX_CATEGORY_LABEL_KEYS: Record<IndexFundCategory, FinanceLabelKey> = {
7+
broad: 'index_category_broad',
8+
sector: 'index_category_sector',
9+
theme: 'index_category_theme',
710
};
811

9-
export const INDEX_RISK_LABELS: Record<IndexRiskLevel, string> = {
10-
conservative: '保守',
11-
balanced: '稳健',
12-
aggressive: '进取',
12+
export const INDEX_RISK_LABEL_KEYS: Record<IndexRiskLevel, FinanceLabelKey> = {
13+
conservative: 'index_risk_conservative',
14+
balanced: 'index_risk_balanced',
15+
aggressive: 'index_risk_aggressive',
1316
};

lib/akshare.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { buildURLData } from 'web-utility';
2+
13
const DEFAULT_HOSTS = [
24
process.env.AKSHARE_API_BASE,
35
'https://akshare.xyz/api/public',
@@ -8,22 +10,14 @@ export async function requestAkShareJSON<T>(
810
endpoint: string,
911
params?: Record<string, string | number | undefined>,
1012
): Promise<T> {
11-
if (!DEFAULT_HOSTS.length) throw new Error('No AkShare host configured');
12-
1313
let lastError: Error | null = null;
1414

1515
for (const host of DEFAULT_HOSTS) {
1616
try {
1717
const url = new URL(endpoint.replace(/^\//, ''), host.endsWith('/') ? host : `${host}/`);
1818

1919
if (params) {
20-
const searchParams = new URLSearchParams();
21-
22-
Object.entries(params).forEach(([key, value]) => {
23-
if (value !== undefined) searchParams.append(key, String(value));
24-
});
25-
26-
const serialized = searchParams.toString();
20+
const serialized = buildURLData(params).toString();
2721

2822
if (serialized) url.search = serialized;
2923
}

lib/finance.ts

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@ import { IndexHistoryPoint } from '../types/finance';
22

33
export const TRADING_DAYS_PER_YEAR = 252;
44

5-
export function computeChangePct(current?: number | null, base?: number | null) {
6-
if (current == null || base == null || base === 0) return null;
7-
8-
return (current - base) / base;
9-
}
5+
export const computeChangePct = (current?: number | null, base?: number | null): number | null =>
6+
current == null || base == null || base === 0 ? null : (current - base) / base;
107

118
export function computeOneYearReturn(series: IndexHistoryPoint[]) {
129
if (series.length < 2) return null;
@@ -16,12 +13,10 @@ export function computeOneYearReturn(series: IndexHistoryPoint[]) {
1613

1714
if (recent.length < 2) return null;
1815

19-
const first = recent[0].value;
20-
const last = recent[recent.length - 1].value;
21-
22-
if (!first) return null;
16+
const [{ value: first }] = recent;
17+
const { value: last } = recent.at(-1)!;
2318

24-
return (last - first) / first;
19+
return computeChangePct(last, first);
2520
}
2621

2722
export function computeMaxDrawdown(series: IndexHistoryPoint[]) {
@@ -32,35 +27,26 @@ export function computeMaxDrawdown(series: IndexHistoryPoint[]) {
3227
let peak = sorted[0].value;
3328
let maxDrawdown = 0;
3429

35-
sorted.forEach(point => {
36-
const { value } = point;
37-
30+
for (const { value } of sorted) {
3831
if (value > peak) peak = value;
3932

40-
if (!peak) return;
33+
if (!peak) break;
4134

42-
const drawdown = (value - peak) / peak;
35+
const drawdown = computeChangePct(value, peak);
4336

44-
if (drawdown < maxDrawdown) maxDrawdown = drawdown;
45-
});
37+
if (drawdown != null && drawdown < maxDrawdown) maxDrawdown = drawdown;
38+
}
4639

47-
return maxDrawdown || null;
40+
return maxDrawdown;
4841
}
4942

5043
export function computeDailyChange(series: IndexHistoryPoint[]) {
5144
if (series.length < 2) return null;
5245

53-
const sorted = [...series].sort((a, b) => a.date.localeCompare(b.date));
54-
const latest = sorted[sorted.length - 1].value;
55-
const previous = sorted[sorted.length - 2].value;
46+
const [{ value: previous }, { value: latest }] = series.slice(-2);
5647

5748
return computeChangePct(latest, previous);
5849
}
5950

60-
export function buildSparkline(series: IndexHistoryPoint[], limit = 30) {
61-
if (!series.length) return [];
62-
63-
const sorted = [...series].sort((a, b) => a.date.localeCompare(b.date));
64-
65-
return sorted.slice(-limit);
66-
}
51+
export const buildSparkline = (series: IndexHistoryPoint[], limit = 30) =>
52+
[...series].sort((a, b) => a.date.localeCompare(b.date)).slice(-limit);

models/Finance.ts

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { observable } from 'mobx';
12
import { ListModel } from 'mobx-restful';
3+
import { buildURLData } from 'web-utility';
24

35
import { ownClient } from './Base';
46
import { IndexFundSnapshot, IndexFundCategory, IndexRiskLevel } from '../types/finance';
@@ -17,31 +19,18 @@ export class IndexFundModel extends ListModel<IndexFundSnapshot, IndexFundFilter
1719
client = ownClient;
1820
baseURI = 'finance/index-funds';
1921

20-
constructor() {
21-
super();
22-
this.pageSize = 8;
23-
}
24-
25-
async loadPage(page = this.pageIndex, perPage = this.pageSize, filter: IndexFundFilter = {}) {
26-
const search = new URLSearchParams();
27-
28-
if (filter.category) search.set('category', filter.category);
29-
if (filter.riskLevel) search.set('riskLevel', filter.riskLevel);
30-
31-
search.set('limit', String(perPage));
22+
@observable accessor pageSize = 8;
3223

33-
const query = search.toString();
24+
async loadPage(page = this.pageIndex, limit = this.pageSize, filter: IndexFundFilter = {}) {
25+
const query = buildURLData({ ...filter, limit }).toString();
3426
const path = query ? `${this.baseURI}?${query}` : this.baseURI;
3527

3628
const { body } = await this.client.get<IndexFundAPIResponse>(path);
3729

38-
const data = body?.data || [];
39-
const totalCount = body?.meta?.total ?? data.length;
30+
const pageData = body?.data || [];
31+
const totalCount = body?.meta?.total ?? pageData.length;
4032

41-
return {
42-
pageData: data,
43-
totalCount,
44-
};
33+
return { pageData, totalCount };
4534
}
4635
}
4736

0 commit comments

Comments
 (0)