Files
yunhaifinance/backup_20260327/company-finance-system/frontend/src/pages/ExchangeRatePage.tsx
T

381 lines
13 KiB
TypeScript
Raw Normal View History

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;