381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
import React, { useState, useEffect } 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 axios from 'axios';
|
|
import dayjs from 'dayjs';
|
|
|
|
const { Text, Title } = Typography;
|
|
|
|
const RATE_PAIRS = [
|
|
{ key: 'CNY_LAK', label: '中老汇率', from: 'CNY', to: 'LAK', fromLabel: '人民币', toLabel: '老挝基普' },
|
|
{ key: 'CNY_USD', label: '中美汇率', from: 'CNY', to: 'USD', fromLabel: '人民币', toLabel: '美元' },
|
|
{ key: 'CNY_THB', label: '中泰汇率', from: 'CNY', to: 'THB', fromLabel: '人民币', toLabel: '泰铢' },
|
|
{ key: 'USD_LAK', label: '美老汇率', from: 'USD', to: 'LAK', fromLabel: '美元', toLabel: '老挝基普' },
|
|
{ key: 'THB_LAK', label: '泰老汇率', from: 'THB', to: 'LAK', fromLabel: '泰铢', toLabel: '老挝基普' },
|
|
];
|
|
|
|
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 [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 axios.get('/api/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('获取汇率失败');
|
|
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 axios.get('/api/exchange-rates/history?limit=20');
|
|
if (res.data.success) {
|
|
setHistoryRates(res.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('获取历史汇率失败:', error);
|
|
}
|
|
};
|
|
|
|
// 左侧输入 - 右侧自动变为1,重新计算汇率
|
|
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;
|
|
|
|
// 当左侧输入值时,右侧变为1,计算新的汇率
|
|
const newRate = 1 / value;
|
|
|
|
setRates(prev => ({
|
|
...prev,
|
|
[key]: {
|
|
leftValue: value,
|
|
rightValue: 1,
|
|
actualRate: newRate
|
|
}
|
|
}));
|
|
};
|
|
|
|
// 右侧输入 - 左侧自动变为1,重新计算汇率
|
|
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;
|
|
|
|
// 当右侧输入值时,左侧变为1,计算新的汇率
|
|
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 axios.post('/api/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('没有汇率发生变化');
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
|
|
await Promise.all(validPromises);
|
|
|
|
message.success('汇率保存成功');
|
|
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('保存汇率失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// 历史汇率表格列
|
|
const historyColumns = [
|
|
{
|
|
title: '汇率对',
|
|
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: '汇率',
|
|
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: '生效日期',
|
|
dataIndex: 'effective_date',
|
|
key: 'effective_date',
|
|
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
|
|
},
|
|
{
|
|
title: '设置时间',
|
|
dataIndex: 'created_at',
|
|
key: 'created_at',
|
|
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
|
|
},
|
|
{
|
|
title: '设置人',
|
|
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 }}>汇率管理</Title>
|
|
<Space>
|
|
<Text type="secondary">设置各币种汇率,输入任意一侧自动计算</Text>
|
|
{lastUpdateTime && (
|
|
<Tag color="blue">上次更新: {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={`输入${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={`输入${pair.toLabel}金额`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<div style={{ textAlign: 'center' }}>
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
实际汇率: {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 }}
|
|
>
|
|
确认保存汇率
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 历史汇率表 */}
|
|
<Card
|
|
title={
|
|
<Space>
|
|
<HistoryOutlined />
|
|
<span>历史汇率记录</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">
|
|
提示:输入任意一侧数值,另一侧会自动计算。实际汇率实时显示为 1左侧币种 = X右侧币种。点击"确认保存汇率"按钮保存当前设置到数据库。
|
|
</Text>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ExchangeRatePage;
|