Skip to content

Commit 1ccc69c

Browse files
dethan3TechQuery
andauthored
[add] Wealth Product selector page (#39)
Co-authored-by: TechQuery <[email protected]>
1 parent 89da576 commit 1ccc69c

File tree

23 files changed

+2802
-1458
lines changed

23 files changed

+2802
-1458
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package-lock.json
1010
/coverage
1111

1212
# next.js
13+
next-env.d.ts
1314
/.next/
1415
/out/
1516

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44

55
[![CI & CD](https://github.com/Open-Source-Bazaar/Open-Source-Bazaar.github.io/actions/workflows/main.yml/badge.svg)][7]
66

7+
## 项目简介
8+
9+
1. **指数基金精选页 `/finance`**:面向理财新手和进阶用户,一屏解释指数基金长期定投与指数投资的核心逻辑,一屏基于 AKShare 实时与历史数据筛选 30-50 只国内核心指数基金,支持按风险等级、收益、最大回撤等维度筛选和对比,可逐步扩展组合推荐和教育内容。
10+
711
## 上游项目
812

913
- [idea2app/Lark-Next-Bootstrap-ts][1]
1014

1115
## 技术栈
1216

1317
- Language: [TypeScript v5][2]
14-
- Component engine: [Nextjs v15][3]
18+
- Component engine: [Nextjs v16][3]
1519
- Component suite: [Bootstrap v5][4]
1620
- CI / CD: GitHub [Actions][10] + [Vercel][11]
1721

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
.fundCard {
2+
transition:
3+
transform 0.2s ease,
4+
box-shadow 0.2s ease;
5+
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
6+
border: 1px solid rgba(15, 23, 42, 0.08);
7+
border-radius: 20px;
8+
&:hover {
9+
transform: translateY(-4px);
10+
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
11+
}
12+
13+
.header small {
14+
line-height: 1.3;
15+
}
16+
}
17+
18+
.metric {
19+
gap: 1.5rem;
20+
.value {
21+
font-weight: 700;
22+
font-size: 2rem;
23+
}
24+
}
25+
.tagBadge {
26+
border: 1px solid rgba(15, 23, 42, 0.15);
27+
border-radius: 999px;
28+
font-weight: 500;
29+
}
30+
31+
.mutedText {
32+
color: rgba(15, 23, 42, 0.6);
33+
}
34+
35+
.positiveText {
36+
color: #198754;
37+
}
38+
39+
.negativeText {
40+
color: #dc3545;
41+
}

components/Finance/FundCard.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { FC, useContext, useMemo } from 'react';
2+
import { Badge, Card } from 'react-bootstrap';
3+
4+
import { INDEX_CATEGORY_LABEL_KEYS, INDEX_RISK_LABEL_KEYS } from '../../constants/finance';
5+
import { I18nContext } from '../../models/Translation';
6+
import { IndexFundSnapshot } from '../../types/finance';
7+
import styles from './Finance.module.less';
8+
import { SparkLine } from './SparkLine';
9+
10+
const formatNumber = (value?: number | null, locale = 'zh-CN') =>
11+
value != null ? value.toLocaleString(locale, { maximumFractionDigits: 2 }) : '--';
12+
13+
const formatPercent = (value?: number | null) =>
14+
value != null ? `${(value * 100).toFixed(2)}%` : '--';
15+
16+
const valueTone = (value?: number | null) =>
17+
value != null ? (value >= 0 ? styles.positiveText : styles.negativeText) : styles.mutedText;
18+
19+
export const FundCard: FC<IndexFundSnapshot> = ({
20+
symbol,
21+
displayName,
22+
category,
23+
riskLevel,
24+
latestValue,
25+
dailyChangePct,
26+
oneYearReturnPct,
27+
maxDrawdownPct,
28+
tags,
29+
sparkline,
30+
updatedAt,
31+
fallback,
32+
description,
33+
source,
34+
}) => {
35+
const { currentLanguage, t } = useContext(I18nContext);
36+
const sparklineId = useMemo(() => `fund-${symbol}`, [symbol]);
37+
const updatedAtISO = updatedAt ? new Date(updatedAt).toJSON() : undefined;
38+
39+
return (
40+
<Card className={`${styles.fundCard} h-100`}>
41+
<Card.Body className="d-flex flex-column gap-3">
42+
<div className={`${styles.header} d-flex justify-content-between align-items-start`}>
43+
<div>
44+
<h3 className="h5 mb-1">{displayName}</h3>
45+
<div className="d-flex flex-wrap gap-2 align-items-center">
46+
<Badge bg="light" text="dark">
47+
{t(INDEX_CATEGORY_LABEL_KEYS[category])}
48+
</Badge>
49+
<Badge
50+
bg={
51+
riskLevel === 'aggressive'
52+
? 'danger'
53+
: riskLevel === 'balanced'
54+
? 'warning'
55+
: 'success'
56+
}
57+
>
58+
{t(INDEX_RISK_LABEL_KEYS[riskLevel])}
59+
</Badge>
60+
{fallback && (
61+
<Badge bg="secondary" text="light">
62+
{t('offline_data')}
63+
</Badge>
64+
)}
65+
</div>
66+
</div>
67+
<small className="text-muted text-end">
68+
{t('data_source')} <br />
69+
{source.historyEndpoint}
70+
</small>
71+
</div>
72+
73+
<p className="text-muted mb-0">{description}</p>
74+
75+
<dl className={`${styles.metric} d-flex flex-wrap gap-4 align-items-center`}>
76+
<dt className="text-muted mb-1">{t('index_metric_latest_value')}</dt>
77+
<dd className={styles.value}>{formatNumber(latestValue, currentLanguage)}</dd>
78+
79+
<dt className="text-muted mb-1">{t('index_metric_daily_change')}</dt>
80+
<dd className={valueTone(dailyChangePct)}>{formatPercent(dailyChangePct)}</dd>
81+
82+
<dt className="text-muted mb-1">{t('index_metric_one_year_return')}</dt>
83+
<dd className={valueTone(oneYearReturnPct)}>{formatPercent(oneYearReturnPct)}</dd>
84+
85+
<dt className="text-muted mb-1">{t('index_metric_max_drawdown')}</dt>
86+
<dd className={valueTone(maxDrawdownPct)}>{formatPercent(maxDrawdownPct)}</dd>
87+
</dl>
88+
89+
<SparkLine points={sparkline} chartId={sparklineId} />
90+
91+
<ul className="list-unstyled m-0 d-flex flex-wrap gap-2">
92+
{tags?.map(tag => (
93+
<Badge key={tag} as="li" bg="light" text="dark" className={styles.tagBadge}>
94+
{tag}
95+
</Badge>
96+
))}
97+
</ul>
98+
99+
<time className="small text-muted" dateTime={updatedAtISO}>
100+
{t('updated_at')} {updatedAt || '--'}
101+
</time>
102+
103+
<a href={`/finance/${symbol}`} className="text-primary fw-semibold">
104+
{t('view_details')}
105+
</a>
106+
</Card.Body>
107+
</Card>
108+
);
109+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.sparkline {
2+
width: 100%;
3+
height: 80px;
4+
5+
svg {
6+
width: 100%;
7+
height: 100%;
8+
}
9+
10+
.placeholder {
11+
border: 1px dashed rgba(15, 23, 42, 0.2);
12+
border-radius: 12px;
13+
padding: 1rem;
14+
color: rgba(15, 23, 42, 0.6);
15+
text-align: center;
16+
}
17+
}

components/Finance/SparkLine.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { computed } from 'mobx';
2+
import { observer } from 'mobx-react';
3+
import { ObservedComponent } from 'mobx-react-helper';
4+
5+
import { i18n, I18nContext } from '../../models/Translation';
6+
import { IndexHistoryPoint } from '../../types/finance';
7+
import styles from './SparkLine.module.less';
8+
9+
export interface SparkLineProps {
10+
points: IndexHistoryPoint[];
11+
chartId: string;
12+
}
13+
14+
@observer
15+
export class SparkLine extends ObservedComponent<SparkLineProps, typeof i18n> {
16+
static contextType = I18nContext;
17+
18+
@computed
19+
get chartData() {
20+
const { points } = this.observedProps;
21+
22+
if (!points.length) return { polyline: '', gradientStops: [] as number[] };
23+
24+
const values = points.map(({ value }) => value);
25+
const min = Math.min(...values);
26+
const max = Math.max(...values);
27+
const delta = max - min || 1;
28+
29+
const polylinePoints = points
30+
.map(({ value }, index, { length }) => {
31+
const x = (index / Math.max(length - 1, 1)) * 100;
32+
const y = ((max - value) / delta) * 40;
33+
34+
return `${x.toFixed(2)},${y.toFixed(2)}`;
35+
})
36+
.join(' ');
37+
38+
const gradientOffsets = [0, 50, 100];
39+
40+
return { polyline: polylinePoints, gradientStops: gradientOffsets };
41+
}
42+
43+
renderContent() {
44+
const { t } = this.observedContext,
45+
{ chartId } = this.observedProps,
46+
{ polyline, gradientStops } = this.chartData;
47+
const gradientId = `${chartId}-gradient`;
48+
49+
return (
50+
<div className={styles.sparkline}>
51+
<svg viewBox="0 0 100 40" role="img" aria-label={t('index_sparkline_60d_label')}>
52+
<defs>
53+
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
54+
{gradientStops.map(offset => (
55+
<stop
56+
key={offset}
57+
offset={`${offset}%`}
58+
stopColor="var(--bs-primary)"
59+
stopOpacity="0.5"
60+
/>
61+
))}
62+
</linearGradient>
63+
</defs>
64+
<polyline
65+
fill="none"
66+
stroke={`url(#${gradientId})`}
67+
strokeWidth="2"
68+
strokeLinejoin="round"
69+
strokeLinecap="round"
70+
points={polyline}
71+
/>
72+
</svg>
73+
</div>
74+
);
75+
}
76+
77+
render() {
78+
const { t } = this.observedContext,
79+
{ points } = this.props;
80+
81+
return points.length ? (
82+
this.renderContent()
83+
) : (
84+
<div className={styles.sparkline}>
85+
<div className={styles.placeholder}>{t('data_preparing')}</div>
86+
</div>
87+
);
88+
}
89+
}

constants/finance.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { IndexFundCategory, IndexRiskLevel } from '../types/finance';
2+
import { I18nKey } from '../models/Translation';
3+
4+
export const INDEX_CATEGORY_LABEL_KEYS: Record<IndexFundCategory, I18nKey> = {
5+
broad: 'index_category_broad',
6+
sector: 'index_category_sector',
7+
theme: 'index_category_theme',
8+
};
9+
10+
export const INDEX_RISK_LABEL_KEYS: Record<IndexRiskLevel, I18nKey> = {
11+
conservative: 'index_risk_conservative',
12+
balanced: 'index_risk_balanced',
13+
aggressive: 'index_risk_aggressive',
14+
};

eslint.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import cspellPlugin from '@cspell/eslint-plugin';
22
import eslint from '@eslint/js';
33
import nextPlugin from '@next/eslint-plugin-next';
44
import stylistic from '@stylistic/eslint-plugin';
5+
import { defineConfig } from 'eslint/config';
56
import eslintConfigPrettier from 'eslint-config-prettier';
67
import react from 'eslint-plugin-react';
78
import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort';
@@ -16,7 +17,7 @@ import { fileURLToPath } from 'url';
1617

1718
const tsconfigRootDir = fileURLToPath(new URL('.', import.meta.url));
1819

19-
export default tsEslint.config(
20+
export default defineConfig(
2021
// register all of the plugins up-front
2122
{
2223
plugins: {
@@ -47,7 +48,6 @@ export default tsEslint.config(
4748
warnOnUnsupportedTypeScriptVersion: false,
4849
},
4950
},
50-
// @ts-expect-error https://github.com/vercel/next.js/issues/81695
5151
rules: {
5252
// spellchecker
5353
'@cspell/spellchecker': [

lib/akshare.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { buildURLData } from 'web-utility';
2+
3+
const DEFAULT_HOSTS = [
4+
process.env.AKSHARE_API_BASE,
5+
'https://akshare.xyz/api/public',
6+
'https://akshare.akfamily.xyz/api/public',
7+
].filter((value): value is string => !!value);
8+
9+
export async function requestAkShareJSON<T>(
10+
endpoint: string,
11+
params?: Record<string, string | number | undefined>,
12+
): Promise<T> {
13+
let lastError: Error | null = null;
14+
15+
for (const host of DEFAULT_HOSTS) {
16+
try {
17+
const url = new URL(endpoint.replace(/^\//, ''), host.endsWith('/') ? host : `${host}/`);
18+
19+
if (params) url.search = buildURLData(params) + '';
20+
21+
const response = await fetch(url.toString(), {
22+
headers: { accept: 'application/json' },
23+
cache: 'no-store',
24+
});
25+
26+
if (!response.ok)
27+
throw new Error(
28+
`AkShare responded with ${response.status} ${response.statusText} for ${url}`,
29+
);
30+
31+
const contentType = response.headers.get('content-type');
32+
33+
if (!contentType?.includes('application/json')) {
34+
const preview = (await response.text()).slice(0, 200);
35+
36+
throw new Error(`Unexpected content-type ${contentType} for ${url}: ${preview}`);
37+
}
38+
39+
return (await response.json()) as T;
40+
} catch (error) {
41+
lastError = error as Error;
42+
console.warn(`[AkShare] Falling back after error on host ${host}:`, lastError.message);
43+
}
44+
}
45+
throw lastError ?? new Error('AkShare request failed without specific error');
46+
}

0 commit comments

Comments
 (0)