fix: 改进错误提示、代理测试和落地IP查询

- 前端所有页面显示后端真实错误信息,不再显示通用"失败"
- 新增代理测试功能和落地IP查询
- 修复凭证未配置时返回500改为400+中文提示
- 修复Settings页面字段名与后端一致(proxy_ip)
- 修复favicon 404、bcrypt版本兼容、tsconfig配置
This commit is contained in:
zqq61
2026-03-15 23:39:02 +08:00
parent 4f53889a8e
commit 1b8b2c0bd6
14 changed files with 3299 additions and 70 deletions

3111
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Modal, Form, Input, Select, InputNumber, Divider, message } from 'antd'
import { cardsApi, cardholdersApi } from '@/services/api'
import { cardsApi, cardholdersApi, getErrorMsg } from '@/services/api'
interface Props {
open: boolean
@@ -29,8 +29,8 @@ export default function CreateCardModal({ open, onClose, onSuccess }: Props) {
.then((res) => {
setCardholders(res.data.items || res.data || [])
})
.catch(() => {
message.error('获取持卡人列表失败')
.catch((err) => {
message.error(getErrorMsg(err, '获取持卡人列表失败'))
})
.finally(() => setChLoading(false))
}
@@ -43,9 +43,8 @@ export default function CreateCardModal({ open, onClose, onSuccess }: Props) {
message.success('卡片创建成功')
form.resetFields()
onSuccess()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
message.error(error.response?.data?.detail || '创建卡片失败')
} catch (err) {
message.error(getErrorMsg(err, '创建卡片失败'))
} finally {
setLoading(false)
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Modal, Form, Input, DatePicker, Select, Row, Col, message } from 'antd'
import { cardholdersApi } from '@/services/api'
import { cardholdersApi, getErrorMsg } from '@/services/api'
interface Props {
open: boolean
@@ -36,9 +36,8 @@ export default function CreateCardholderModal({ open, onClose, onSuccess }: Prop
message.success('持卡人创建成功')
form.resetFields()
onSuccess()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
message.error(error.response?.data?.detail || '创建持卡人失败')
} catch (err) {
message.error(getErrorMsg(err, '创建持卡人失败'))
} finally {
setLoading(false)
}

View File

@@ -16,7 +16,7 @@ import {
import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { tokensApi } from '@/services/api'
import { tokensApi, getErrorMsg } from '@/services/api'
const { Paragraph } = Typography
@@ -49,8 +49,8 @@ export default function ApiTokens() {
try {
const res = await tokensApi.getTokens()
setData(res.data.items || res.data || [])
} catch {
message.error('获取令牌列表失败')
} catch (err) {
message.error(getErrorMsg(err, '获取令牌列表失败'))
} finally {
setLoading(false)
}
@@ -71,8 +71,8 @@ export default function ApiTokens() {
setModalOpen(false)
form.resetFields()
fetchData()
} catch {
message.error('创建令牌失败')
} catch (err) {
message.error(getErrorMsg(err, '创建令牌失败'))
}
}
@@ -81,8 +81,8 @@ export default function ApiTokens() {
await tokensApi.deleteToken(id)
message.success('令牌已删除')
fetchData()
} catch {
message.error('删除令牌失败')
} catch (err) {
message.error(getErrorMsg(err, '删除令牌失败'))
}
}

View File

@@ -3,7 +3,7 @@ import { Table, DatePicker, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import { auditLogsApi } from '@/services/api'
import { auditLogsApi, getErrorMsg } from '@/services/api'
const { RangePicker } = DatePicker
@@ -37,8 +37,8 @@ export default function AuditLog() {
const res = await auditLogsApi.getAuditLogs(params)
setData(res.data.items || res.data || [])
setTotal(res.data.total || 0)
} catch {
message.error('获取操作日志失败')
} catch (err) {
message.error(getErrorMsg(err, '获取操作日志失败'))
} finally {
setLoading(false)
}

View File

@@ -22,7 +22,7 @@ import {
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { cardsApi, transactionsApi } from '@/services/api'
import { cardsApi, transactionsApi, getErrorMsg } from '@/services/api'
interface CardInfo {
card_id: string
@@ -75,8 +75,8 @@ export default function CardDetail() {
try {
const res = await cardsApi.getCard(id)
setCard(res.data)
} catch {
message.error('获取卡片信息失败')
} catch (err) {
message.error(getErrorMsg(err, '获取卡片信息失败'))
} finally {
setLoading(false)
}
@@ -111,8 +111,8 @@ export default function CardDetail() {
const res = await cardsApi.getCardDetails(id)
setCard((prev) => (prev ? { ...prev, ...res.data } : prev))
setShowSensitive(true)
} catch {
message.error('获取敏感信息失败')
} catch (err) {
message.error(getErrorMsg(err, '获取敏感信息失败'))
} finally {
setSensitiveLoading(false)
}
@@ -126,8 +126,8 @@ export default function CardDetail() {
})
message.success(action === 'activate' ? '卡片已激活' : '卡片已冻结')
fetchCard()
} catch {
message.error('操作失败')
} catch (err) {
message.error(getErrorMsg(err, '操作失败'))
}
}
@@ -138,8 +138,8 @@ export default function CardDetail() {
message.success('限额更新成功')
setLimitModalOpen(false)
fetchCard()
} catch {
message.error('更新限额失败')
} catch (err) {
message.error(getErrorMsg(err, '更新限额失败'))
}
}

View File

@@ -3,7 +3,7 @@ import { Table, Button, Tag, message } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { cardholdersApi } from '@/services/api'
import { cardholdersApi, getErrorMsg } from '@/services/api'
import CreateCardholderModal from '@/components/CreateCardholderModal'
interface Cardholder {
@@ -29,8 +29,8 @@ export default function Cardholders() {
const res = await cardholdersApi.getCardholders({ page, page_size: pageSize })
setData(res.data.items || res.data)
setTotal(res.data.total || 0)
} catch {
message.error('获取持卡人列表失败')
} catch (err) {
message.error(getErrorMsg(err, '获取持卡人列表失败'))
} finally {
setLoading(false)
}

View File

@@ -4,7 +4,7 @@ import { Table, Button, Select, Input, Tag, Space, message } from 'antd'
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { cardsApi } from '@/services/api'
import { cardsApi, getErrorMsg } from '@/services/api'
import CreateCardModal from '@/components/CreateCardModal'
interface CardRecord {
@@ -43,8 +43,8 @@ export default function Cards() {
const res = await cardsApi.getCards(params)
setData(res.data.items || res.data)
setTotal(res.data.total || 0)
} catch {
message.error('获取卡片列表失败')
} catch (err) {
message.error(getErrorMsg(err, '获取卡片列表失败'))
} finally {
setLoading(false)
}

View File

@@ -7,7 +7,7 @@ import {
PlusCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { dashboardApi } from '@/services/api'
import { dashboardApi, getErrorMsg } from '@/services/api'
interface DashboardData {
account_balance: number
@@ -26,8 +26,8 @@ export default function Dashboard() {
try {
const res = await dashboardApi.getDashboard()
setData(res.data)
} catch {
message.error('获取仪表板数据失败')
} catch (err) {
message.error(getErrorMsg(err, '获取仪表板数据失败'))
} finally {
setLoading(false)
}

View File

@@ -1,18 +1,16 @@
import { useState, useEffect } from 'react'
import { Card, Form, Input, InputNumber, Button, Divider, message, Space } from 'antd'
import { SaveOutlined, ApiOutlined } from '@ant-design/icons'
import { Card, Form, Input, InputNumber, Button, Divider, message, Space, Descriptions, Tag } from 'antd'
import { SaveOutlined, ApiOutlined, GlobalOutlined } from '@ant-design/icons'
import { settingsApi } from '@/services/api'
import api from '@/services/api'
interface SettingsData {
airwallex_client_id: string
airwallex_api_key: string
airwallex_base_url: string
proxy_host: string
proxy_port: number | null
proxy_username: string
proxy_password: string
daily_card_limit: number
interface ProxyTestResult {
success: boolean
proxy_configured: boolean
proxy_url: string | null
proxy_ip: string | null
proxy_status: string | null
direct_ip: string | null
direct_status: string | null
}
export default function Settings() {
@@ -20,15 +18,18 @@ export default function Settings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testingProxy, setTestingProxy] = useState(false)
const [proxyResult, setProxyResult] = useState<ProxyTestResult | null>(null)
const fetchSettings = async () => {
setLoading(true)
try {
const res = await settingsApi.getSettings()
// Backend returns a list of {key, value} — convert to object for form
const data: Record<string, string> = {}
if (Array.isArray(res.data)) {
for (const item of res.data) {
for (const item of res.data as { key: string; value: string }[]) {
// Don't fill masked values into password fields
if (item.value === '********') continue
data[item.key] = item.value
}
}
@@ -45,12 +46,12 @@ export default function Settings() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSave = async (values: SettingsData) => {
const handleSave = async () => {
setSaving(true)
try {
// Convert form object to list of {key, value} for backend
const values = await form.validateFields()
const updates = Object.entries(values)
.filter(([, v]) => v !== undefined && v !== null)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([key, value]) => ({ key, value: String(value) }))
await settingsApi.updateSettings(updates)
message.success('设置已保存')
@@ -64,9 +65,9 @@ export default function Settings() {
const handleTestConnection = async () => {
setTesting(true)
try {
const res = await api.post('/settings/test-connection')
const res = await settingsApi.testConnection()
if (res.data.success) {
message.success('连接测试成功')
message.success(res.data.message || '连接测试成功')
} else {
message.error(res.data.message || '连接测试失败')
}
@@ -77,12 +78,30 @@ export default function Settings() {
}
}
const handleTestProxy = async () => {
setTestingProxy(true)
setProxyResult(null)
try {
const res = await settingsApi.testProxy()
setProxyResult(res.data)
if (res.data.success) {
message.success('代理测试成功')
} else {
message.warning('代理测试失败,请检查配置')
}
} catch {
message.error('代理测试请求失败')
} finally {
setTestingProxy(false)
}
}
return (
<div>
<div className="page-header">
<h2></h2>
</div>
<Form form={form} layout="vertical" onFinish={handleSave} disabled={loading}>
<Form form={form} layout="vertical" disabled={loading}>
<Card title="Airwallex 凭证" style={{ marginBottom: 16 }}>
<Form.Item
label="Client ID"
@@ -96,10 +115,10 @@ export default function Settings() {
name="airwallex_api_key"
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input.Password placeholder="请输入 API Key" />
<Input.Password placeholder="请输入 API Key(留空则保持不变)" />
</Form.Item>
<Form.Item label="Base URL" name="airwallex_base_url">
<Input placeholder="https://api-demo.airwallex.com" />
<Input placeholder="https://api-demo.airwallex.com/" />
</Form.Item>
<Button
icon={<ApiOutlined />}
@@ -111,7 +130,7 @@ export default function Settings() {
</Card>
<Card title="代理设置" style={{ marginBottom: 16 }}>
<Form.Item label="代理 IP" name="proxy_host">
<Form.Item label="代理 IP" name="proxy_ip">
<Input placeholder="例如: 127.0.0.1" />
</Form.Item>
<Form.Item label="代理端口" name="proxy_port">
@@ -121,8 +140,38 @@ export default function Settings() {
<Input placeholder="可选" />
</Form.Item>
<Form.Item label="代理密码" name="proxy_password">
<Input.Password placeholder="可选" />
<Input.Password placeholder="可选(留空则保持不变)" />
</Form.Item>
<Space>
<Button
icon={<GlobalOutlined />}
onClick={handleTestProxy}
loading={testingProxy}
>
/ IP
</Button>
</Space>
{proxyResult && (
<Descriptions bordered size="small" column={1} style={{ marginTop: 16 }}>
<Descriptions.Item label="代理配置">
{proxyResult.proxy_configured
? <Tag color="blue">{proxyResult.proxy_url}</Tag>
: <Tag color="default"></Tag>}
</Descriptions.Item>
{proxyResult.proxy_ip !== undefined && (
<Descriptions.Item label="代理落地 IP">
{proxyResult.proxy_status === 'ok'
? <Tag color="green">{proxyResult.proxy_ip}</Tag>
: <Tag color="red">{proxyResult.proxy_status}</Tag>}
</Descriptions.Item>
)}
<Descriptions.Item label="服务器直连 IP">
{proxyResult.direct_status === 'ok'
? <Tag color="green">{proxyResult.direct_ip}</Tag>
: <Tag color="red">{proxyResult.direct_status}</Tag>}
</Descriptions.Item>
</Descriptions>
)}
</Card>
<Card title="卡片限制" style={{ marginBottom: 16 }}>
@@ -133,7 +182,7 @@ export default function Settings() {
<Divider />
<Space>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
<Button type="primary" onClick={handleSave} icon={<SaveOutlined />} loading={saving}>
</Button>
</Space>

View File

@@ -3,7 +3,7 @@ import { Table, Tag, DatePicker, Input, Select, Tabs, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import { transactionsApi, authorizationsApi } from '@/services/api'
import { transactionsApi, authorizationsApi, getErrorMsg } from '@/services/api'
const { RangePicker } = DatePicker
@@ -68,8 +68,8 @@ export default function Transactions() {
setAuthData(res.data.items || res.data)
setTotal(res.data.total || 0)
}
} catch {
message.error('获取数据失败')
} catch (err) {
message.error(getErrorMsg(err, '获取数据失败'))
} finally {
setLoading(false)
}

View File

@@ -25,6 +25,18 @@ api.interceptors.response.use(
}
)
/** Extract readable error message from axios error */
export function getErrorMsg(err: unknown, fallback = '操作失败'): string {
if (axios.isAxiosError(err)) {
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (typeof detail === 'object' && detail?.msg) return detail.msg
if (err.response?.data?.message) return err.response.data.message
if (err.response?.status) return `请求失败 (HTTP ${err.response.status})`
}
return fallback
}
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
@@ -71,6 +83,7 @@ export const settingsApi = {
getSettings: () => api.get('/settings'),
updateSettings: (data: { key: string; value: string }[]) => api.put('/settings', data),
testConnection: () => api.post('/settings/test-connection'),
testProxy: () => api.post('/settings/test-proxy'),
}
export const cardLogsApi = {