Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
## 技术栈

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

Expand Down
41 changes: 41 additions & 0 deletions components/Finance/Finance.module.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.fundCard {
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
}

.header small {
line-height: 1.3;
}
}

.metric {
gap: 1.5rem;
.value {
font-weight: 700;
font-size: 2rem;
}
}
.tagBadge {
border: 1px solid rgba(15, 23, 42, 0.15);
border-radius: 999px;
font-weight: 500;
}

.mutedText {
color: rgba(15, 23, 42, 0.6);
}

.positiveText {
color: #198754;
}

.negativeText {
color: #dc3545;
}
109 changes: 109 additions & 0 deletions components/Finance/FundCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { FC, useContext, useMemo } from 'react';
import { Badge, Card } from 'react-bootstrap';

import { INDEX_CATEGORY_LABEL_KEYS, INDEX_RISK_LABEL_KEYS } from '../../constants/finance';
import { I18nContext } from '../../models/Translation';
import { IndexFundSnapshot } from '../../types/finance';
import styles from './Finance.module.less';
import { SparkLine } from './SparkLine';

const formatNumber = (value?: number | null, locale = 'zh-CN') =>
value != null ? value.toLocaleString(locale, { maximumFractionDigits: 2 }) : '--';

const formatPercent = (value?: number | null) =>
value != null ? `${(value * 100).toFixed(2)}%` : '--';

const valueTone = (value?: number | null) =>
value != null ? (value >= 0 ? styles.positiveText : styles.negativeText) : styles.mutedText;

export const FundCard: FC<IndexFundSnapshot> = ({
symbol,
displayName,
category,
riskLevel,
latestValue,
dailyChangePct,
oneYearReturnPct,
maxDrawdownPct,
tags,
sparkline,
updatedAt,
fallback,
description,
source,
}) => {
const { currentLanguage, t } = useContext(I18nContext);
const sparklineId = useMemo(() => `fund-${symbol}`, [symbol]);
const updatedAtISO = updatedAt ? new Date(updatedAt).toJSON() : undefined;

return (
<Card className={`${styles.fundCard} h-100`}>
<Card.Body className="d-flex flex-column gap-3">
<div className={`${styles.header} d-flex justify-content-between align-items-start`}>
<div>
<h3 className="h5 mb-1">{displayName}</h3>
<div className="d-flex flex-wrap gap-2 align-items-center">
<Badge bg="light" text="dark">
{t(INDEX_CATEGORY_LABEL_KEYS[category])}
</Badge>
<Badge
bg={
riskLevel === 'aggressive'
? 'danger'
: riskLevel === 'balanced'
? 'warning'
: 'success'
}
>
{t(INDEX_RISK_LABEL_KEYS[riskLevel])}
</Badge>
{fallback && (
<Badge bg="secondary" text="light">
{t('offline_data')}
</Badge>
)}
</div>
</div>
<small className="text-muted text-end">
{t('data_source')} <br />
{source.historyEndpoint}
</small>
</div>

<p className="text-muted mb-0">{description}</p>

<dl className={`${styles.metric} d-flex flex-wrap gap-4 align-items-center`}>
<dt className="text-muted mb-1">{t('index_metric_latest_value')}</dt>
<dd className={styles.value}>{formatNumber(latestValue, currentLanguage)}</dd>

<dt className="text-muted mb-1">{t('index_metric_daily_change')}</dt>
<dd className={valueTone(dailyChangePct)}>{formatPercent(dailyChangePct)}</dd>

<dt className="text-muted mb-1">{t('index_metric_one_year_return')}</dt>
<dd className={valueTone(oneYearReturnPct)}>{formatPercent(oneYearReturnPct)}</dd>

<dt className="text-muted mb-1">{t('index_metric_max_drawdown')}</dt>
<dd className={valueTone(maxDrawdownPct)}>{formatPercent(maxDrawdownPct)}</dd>
</dl>

<SparkLine points={sparkline} chartId={sparklineId} />

<ul className="list-unstyled m-0 d-flex flex-wrap gap-2">
{tags?.map(tag => (
<Badge key={tag} as="li" bg="light" text="dark" className={styles.tagBadge}>
{tag}
</Badge>
))}
</ul>
Comment on lines +91 to +97
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

标签(tags)未国际化处理。

当前 tags 数组中的字符串(如 '宽基'、'蓝筹')直接渲染,在非中文环境下会显示中文文本,违反国际化规范。

建议方案:

方案 1(推荐):在 constants/finance.ts 中定义标签映射,类似 INDEX_CATEGORY_LABEL_KEYS:

// constants/finance.ts
export const INDEX_TAG_LABEL_KEYS: Record<string, string> = {
  '宽基': 'fund_tag_broad_base',
  '蓝筹': 'fund_tag_blue_chip',
  '成长': 'fund_tag_growth',
  // ... 其他标签
};

然后在组件中映射:

         <ul className="list-unstyled m-0 d-flex flex-wrap gap-2">
           {tags?.map(tag => (
             <Badge key={tag} as="li" bg="light" text="dark" className={styles.tagBadge}>
-              {tag}
+              {t(INDEX_TAG_LABEL_KEYS[tag] || tag)}
             </Badge>
           ))}
         </ul>

方案 2:API 返回标签的 key 而非中文字符串,组件通过 key 查找翻译。

基于 learnings:所有用户可见文本必须使用 t() 函数。

🤖 Prompt for AI Agents
In components/Finance/FundCard.tsx around lines 154 to 160, the tags array is
rendered as raw Chinese strings which bypasses i18n; replace direct rendering
with translated labels by mapping tag values to translation keys (add an
INDEX_TAG_LABEL_KEYS or INDEX_TAG_KEY_MAP in constants/finance.ts mapping
Chinese tag strings to i18n keys), then in the component map each tag to
t(INDEX_TAG_LABEL_KEYS[tag] || tag) when rendering the Badge (use the original
tag as a safe fallback if no mapping exists); alternatively, if the API can be
changed, accept tag keys and call t(key) directly—ensure every user-visible tag
uses t() before render.


<time className="small text-muted" dateTime={updatedAtISO}>
{t('updated_at')} {updatedAt || '--'}
</time>

<a href={`/finance/${symbol}`} className="text-primary fw-semibold">
{t('view_details')}
</a>
</Card.Body>
</Card>
);
};
17 changes: 17 additions & 0 deletions components/Finance/SparkLine.module.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.sparkline {
width: 100%;
height: 80px;

svg {
width: 100%;
height: 100%;
}

.placeholder {
border: 1px dashed rgba(15, 23, 42, 0.2);
border-radius: 12px;
padding: 1rem;
color: rgba(15, 23, 42, 0.6);
text-align: center;
}
}
89 changes: 89 additions & 0 deletions components/Finance/SparkLine.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { computed } from 'mobx';
import { observer } from 'mobx-react';
import { ObservedComponent } from 'mobx-react-helper';

import { i18n, I18nContext } from '../../models/Translation';
import { IndexHistoryPoint } from '../../types/finance';
import styles from './SparkLine.module.less';

export interface SparkLineProps {
points: IndexHistoryPoint[];
chartId: string;
}

@observer
export class SparkLine extends ObservedComponent<SparkLineProps, typeof i18n> {
static contextType = I18nContext;

@computed
get chartData() {
const { points } = this.observedProps;

if (!points.length) return { polyline: '', gradientStops: [] as number[] };

const values = points.map(({ value }) => value);
const min = Math.min(...values);
const max = Math.max(...values);
const delta = max - min || 1;

const polylinePoints = points
.map(({ value }, index, { length }) => {
const x = (index / Math.max(length - 1, 1)) * 100;
const y = ((max - value) / delta) * 40;

return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');

const gradientOffsets = [0, 50, 100];

return { polyline: polylinePoints, gradientStops: gradientOffsets };
}

renderContent() {
const { t } = this.observedContext,
{ chartId } = this.observedProps,
{ polyline, gradientStops } = this.chartData;
const gradientId = `${chartId}-gradient`;

return (
<div className={styles.sparkline}>
<svg viewBox="0 0 100 40" role="img" aria-label={t('index_sparkline_60d_label')}>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
{gradientStops.map(offset => (
<stop
key={offset}
offset={`${offset}%`}
stopColor="var(--bs-primary)"
stopOpacity="0.5"
/>
))}
</linearGradient>
</defs>
<polyline
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
points={polyline}
/>
</svg>
</div>
);
}

render() {
const { t } = this.observedContext,
{ points } = this.props;

return points.length ? (
this.renderContent()
) : (
<div className={styles.sparkline}>
<div className={styles.placeholder}>{t('data_preparing')}</div>
</div>
);
}
}
14 changes: 14 additions & 0 deletions constants/finance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IndexFundCategory, IndexRiskLevel } from '../types/finance';
import { I18nKey } from '../models/Translation';

export const INDEX_CATEGORY_LABEL_KEYS: Record<IndexFundCategory, I18nKey> = {
broad: 'index_category_broad',
sector: 'index_category_sector',
theme: 'index_category_theme',
};

export const INDEX_RISK_LABEL_KEYS: Record<IndexRiskLevel, I18nKey> = {
conservative: 'index_risk_conservative',
balanced: 'index_risk_balanced',
aggressive: 'index_risk_aggressive',
};
4 changes: 2 additions & 2 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import cspellPlugin from '@cspell/eslint-plugin';
import eslint from '@eslint/js';
import nextPlugin from '@next/eslint-plugin-next';
import stylistic from '@stylistic/eslint-plugin';
import { defineConfig } from 'eslint/config';
import eslintConfigPrettier from 'eslint-config-prettier';
import react from 'eslint-plugin-react';
import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort';
Expand All @@ -16,7 +17,7 @@ import { fileURLToPath } from 'url';

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

export default tsEslint.config(
export default defineConfig(
// register all of the plugins up-front
{
plugins: {
Expand Down Expand Up @@ -47,7 +48,6 @@ export default tsEslint.config(
warnOnUnsupportedTypeScriptVersion: false,
},
},
// @ts-expect-error https://github.com/vercel/next.js/issues/81695
rules: {
// spellchecker
'@cspell/spellchecker': [
Expand Down
Loading