Files
yunhaifinance/frontend/src/pages/ExchangeRatePage.tsx
T
a273825743 706dcc24eb 备份:PWA配置前的完整版本
包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
2026-06-13 12:44:48 +08:00

378 lines
12 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
import apiClient from '../utils/request';
import dayjs from 'dayjs';
import { useLanguageStore } from '../store/languageStore';
const { Text, Title } = Typography;
const RATE_PAIR_KEYS = [
{ key: 'CNY_LAK', i18nKey: 'CNYLAK', from: 'CNY', to: 'LAK', fromI18n: 'CNY', toI18n: 'LAK' },
{ key: 'CNY_USD', i18nKey: 'CNYUSD', from: 'CNY', to: 'USD', fromI18n: 'CNY', toI18n: 'USD' },
{ key: 'CNY_THB', i18nKey: 'CNYTHB', from: 'CNY', to: 'THB', fromI18n: 'CNY', toI18n: 'THB' },
{ key: 'USD_LAK', i18nKey: 'USDLAK', from: 'USD', to: 'LAK', fromI18n: 'USD', toI18n: 'LAK' },
{ key: 'THB_LAK', i18nKey: 'THBLAK', from: 'THB', to: 'LAK', fromI18n: 'THB', toI18n: 'LAK' },
];
interface RateItem {
leftValue: number;
rightValue: number;
actualRate: number;
}
interface HistoryRate {
id: number;
pair_key: string;
rate: number;
effective_date: string;
created_at: string;
created_by_name?: string;
}
const ExchangeRatePage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const RATE_PAIRS = useMemo(() => RATE_PAIR_KEYS.map(p => ({
...p,
label: t(`exchangeRate.${p.i18nKey}`),
fromLabel: t(`exchangeRate.${p.fromI18n}`),
toLabel: t(`exchangeRate.${p.toI18n}`),
})), [t]);
const [rates, setRates] = useState<Record<string, RateItem>>({});
const [initialRates, setInitialRates] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [historyRates, setHistoryRates] = useState<HistoryRate[]>([]);
const [lastUpdateTime, setLastUpdateTime] = useState<string>('');
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchRates();
fetchHistory();
}, []);
const fetchRates = async () => {
setLoading(true);
try {
const res = await apiClient.get('/exchange-rates/latest');
if (res.data.success) {
const data = res.data.data;
const newRates: Record<string, RateItem> = {};
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const rate = parseFloat(data[pair.key]) || 1;
newRates[pair.key] = { leftValue: 1, rightValue: rate, actualRate: rate };
newInitialRates[pair.key] = rate;
});
setRates(newRates);
setInitialRates(newInitialRates);
if (res.data.updated_at) {
setLastUpdateTime(res.data.updated_at);
}
}
} catch (error) {
message.error(t('exchangeRate.getRateFailed'));
const defaultRates: Record<string, RateItem> = {};
const defaultInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const defaultRate = pair.key === 'CNY_LAK' ? 3000 : pair.key === 'CNY_USD' ? 0.14 : pair.key === 'CNY_THB' ? 4.5 : pair.key === 'USD_LAK' ? 21000 : 670;
defaultRates[pair.key] = { leftValue: 1, rightValue: defaultRate, actualRate: defaultRate };
defaultInitialRates[pair.key] = defaultRate;
});
setRates(defaultRates);
setInitialRates(defaultInitialRates);
} finally {
setLoading(false);
}
};
const fetchHistory = async () => {
try {
const res = await apiClient.get('/exchange-rates/history?limit=20');
if (res.data.success) {
setHistoryRates(res.data.data);
}
} catch (error) {
console.error('获取历史汇率失败:', error);
}
};
const handleLeftChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
const newRate = 1 / value;
setRates(prev => ({
...prev,
[key]: {
leftValue: value,
rightValue: 1,
actualRate: newRate
}
}));
};
const handleRightChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
const newRate = value;
setRates(prev => ({
...prev,
[key]: {
leftValue: 1,
rightValue: value,
actualRate: newRate
}
}));
};
const getActualRateDisplay = (key: string) => {
const item = rates[key];
if (!item) return '1 : 1.00';
const pair = RATE_PAIRS.find(p => p.key === key);
const actualRate = item.actualRate;
const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2;
return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`;
};
const handleConfirm = async () => {
setSaving(true);
try {
const savePromises = RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
const actualRate = item.rightValue / item.leftValue;
const initialRate = initialRates[pair.key];
if (Math.abs(actualRate - initialRate) < 0.0001) {
return null;
}
return apiClient.post('/exchange-rates', {
pair_key: pair.key,
rate: actualRate,
effective_date: dayjs().format('YYYY-MM-DD')
});
});
const validPromises = savePromises.filter(Boolean) as Promise<any>[];
if (validPromises.length === 0) {
message.info(t('exchangeRate.noChange'));
setSaving(false);
return;
}
await Promise.all(validPromises);
message.success(t('exchangeRate.saveSuccess'));
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
fetchHistory();
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const item = rates[pair.key];
if (item) {
newInitialRates[pair.key] = item.rightValue / item.leftValue;
}
});
setInitialRates(newInitialRates);
} catch (error) {
message.error(t('exchangeRate.saveFailed'));
} finally {
setSaving(false);
}
};
const historyColumns = [
{
title: t('exchangeRate.ratePair'),
dataIndex: 'from_currency',
key: 'from_currency',
render: (_: string, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return pair?.label || pairKey;
}
},
{
title: t('exchangeRate.rate'),
dataIndex: 'rate',
key: 'rate',
render: (rate: number, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return `1 ${record.from_currency} = ${parseFloat(rate).toFixed(pair?.key === 'CNY_USD' ? 4 : 2)} ${record.to_currency}`;
}
},
{
title: t('exchangeRate.effectiveDate'),
dataIndex: 'effective_date',
key: 'effective_date',
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
},
{
title: t('exchangeRate.setTime'),
dataIndex: 'created_at',
key: 'created_at',
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
},
{
title: t('exchangeRate.setBy'),
dataIndex: 'created_by_name',
key: 'created_by_name',
render: (name: string) => name || '-'
}
];
if (loading) {
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}><Spin size="large" /></div>;
}
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={2} style={{ marginBottom: 8 }}>{t('exchangeRate.title')}</Title>
<Space>
<Text type="secondary">{t('exchangeRate.description')}</Text>
{lastUpdateTime && (
<Tag color="blue">{t('exchangeRate.lastUpdated')}{lastUpdateTime}</Tag>
)}
</Space>
</div>
<Row gutter={[16, 16]}>
{RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
return (
<Col xs={24} sm={12} lg={8} key={pair.key}>
<Card title={pair.label} size="small" style={{ background: '#fafafa' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.fromLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.leftValue}
onChange={(v) => handleLeftChange(pair.key, v)}
precision={6}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={t('exchangeRate.inputFrom', { from: pair.fromLabel })}
/>
</div>
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff', fontWeight: 'bold' }}>=</div>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.toLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.rightValue}
onChange={(v) => handleRightChange(pair.key, v)}
precision={pair.key === 'CNY_USD' ? 4 : 2}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={t('exchangeRate.inputTo', { to: pair.toLabel })}
/>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{t('exchangeRate.actualRate')}{getActualRateDisplay(pair.key)}
</Text>
</div>
</Card>
</Col>
);
})}
</Row>
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleConfirm}
loading={saving}
style={{ minWidth: 200 }}
>
{t('exchangeRate.confirmSave')}
</Button>
</div>
<Card
title={
<Space>
<HistoryOutlined />
<span>{t('exchangeRate.historyRate')}</span>
</Space>
}
style={{ marginTop: 24 }}
>
<Table
dataSource={historyRates}
columns={historyColumns}
rowKey="id"
pagination={{ pageSize: 10 }}
size="small"
/>
</Card>
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
<Text type="warning">
{t('exchangeRate.tipText')}
</Text>
</Card>
</div>
);
};
export default ExchangeRatePage;