feat: Airwallex 发卡管理后台完整实现
- 后端: FastAPI + SQLAlchemy + SQLite, JWT认证, 代理支持的AirwallexClient - 前端: React 18 + Vite + Ant Design 5, 中文界面 - 功能: 卡片管理, 持卡人管理, 交易记录, API令牌, 系统设置, 审计日志 - 第三方API: X-API-Key认证, 权限控制 - Docker部署: docker-compose编排前后端
This commit is contained in:
23
frontend/Dockerfile
Normal file
23
frontend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Nginx config for SPA routing + API proxy
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Airwallex 发卡管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/nginx.conf
Normal file
26
frontend/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "airwallex-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"antd": "^5.20.0",
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"axios": "^1.7.4",
|
||||
"dayjs": "^1.11.12",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
49
frontend/src/App.tsx
Normal file
49
frontend/src/App.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Layout from '@/components/Layout'
|
||||
import Login from '@/pages/Login'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
import Cards from '@/pages/Cards'
|
||||
import CardDetail from '@/pages/CardDetail'
|
||||
import Cardholders from '@/pages/Cardholders'
|
||||
import Transactions from '@/pages/Transactions'
|
||||
import ApiTokens from '@/pages/ApiTokens'
|
||||
import Settings from '@/pages/Settings'
|
||||
import AuditLog from '@/pages/AuditLog'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="cards" element={<Cards />} />
|
||||
<Route path="cards/:id" element={<CardDetail />} />
|
||||
<Route path="cardholders" element={<Cardholders />} />
|
||||
<Route path="transactions" element={<Transactions />} />
|
||||
<Route path="api-tokens" element={<ApiTokens />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
118
frontend/src/components/CreateCardModal.tsx
Normal file
118
frontend/src/components/CreateCardModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Modal, Form, Input, Select, InputNumber, Divider, message } from 'antd'
|
||||
import { cardsApi, cardholdersApi } from '@/services/api'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
interface CardholderOption {
|
||||
cardholder_id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function CreateCardModal({ open, onClose, onSuccess }: Props) {
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cardholders, setCardholders] = useState<CardholderOption[]>([])
|
||||
const [chLoading, setChLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setChLoading(true)
|
||||
cardholdersApi
|
||||
.getCardholders({ page_size: 100 })
|
||||
.then((res) => {
|
||||
setCardholders(res.data.items || res.data || [])
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('获取持卡人列表失败')
|
||||
})
|
||||
.finally(() => setChLoading(false))
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await cardsApi.createCard(values)
|
||||
message.success('卡片创建成功')
|
||||
form.resetFields()
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
message.error(error.response?.data?.detail || '创建卡片失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="创建卡片"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={loading}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="持卡人"
|
||||
name="cardholder_id"
|
||||
rules={[{ required: true, message: '请选择持卡人' }]}
|
||||
>
|
||||
<Select
|
||||
loading={chLoading}
|
||||
placeholder="选择持卡人"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={cardholders.map((ch) => ({
|
||||
label: `${ch.first_name} ${ch.last_name} (${ch.email})`,
|
||||
value: ch.cardholder_id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="卡片昵称" name="nickname">
|
||||
<Input placeholder="可选,便于识别的昵称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="卡片类型" name="form_factor" initialValue="VIRTUAL">
|
||||
<Select
|
||||
options={[
|
||||
{ label: '虚拟卡', value: 'VIRTUAL' },
|
||||
{ label: '实体卡', value: 'PHYSICAL' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="用途说明" name="purpose">
|
||||
<Input.TextArea rows={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Divider>授权控制 (可选)</Divider>
|
||||
<Form.Item label="交易币种限制" name={['authorization_controls', 'allowed_currencies']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="例如: USD, CNY"
|
||||
options={[
|
||||
{ label: 'USD', value: 'USD' },
|
||||
{ label: 'CNY', value: 'CNY' },
|
||||
{ label: 'EUR', value: 'EUR' },
|
||||
{ label: 'GBP', value: 'GBP' },
|
||||
{ label: 'HKD', value: 'HKD' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="单笔交易限额" name={['authorization_controls', 'transaction_limit']}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
142
frontend/src/components/CreateCardholderModal.tsx
Normal file
142
frontend/src/components/CreateCardholderModal.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal, Form, Input, DatePicker, Select, Row, Col, message } from 'antd'
|
||||
import { cardholdersApi } from '@/services/api'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const countries = [
|
||||
{ label: '中国', value: 'CN' },
|
||||
{ label: '美国', value: 'US' },
|
||||
{ label: '英国', value: 'GB' },
|
||||
{ label: '香港', value: 'HK' },
|
||||
{ label: '新加坡', value: 'SG' },
|
||||
{ label: '澳大利亚', value: 'AU' },
|
||||
{ label: '加拿大', value: 'CA' },
|
||||
{ label: '日本', value: 'JP' },
|
||||
{ label: '韩国', value: 'KR' },
|
||||
{ label: '德国', value: 'DE' },
|
||||
]
|
||||
|
||||
export default function CreateCardholderModal({ open, onClose, onSuccess }: Props) {
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = {
|
||||
...values,
|
||||
date_of_birth: (values.date_of_birth as { format: (f: string) => string })?.format('YYYY-MM-DD'),
|
||||
}
|
||||
await cardholdersApi.createCardholder(data)
|
||||
message.success('持卡人创建成功')
|
||||
form.resetFields()
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
message.error(error.response?.data?.detail || '创建持卡人失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="创建持卡人"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={loading}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="example@email.com" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="名"
|
||||
name="first_name"
|
||||
rules={[{ required: true, message: '请输入名' }]}
|
||||
>
|
||||
<Input placeholder="First Name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="姓"
|
||||
name="last_name"
|
||||
rules={[{ required: true, message: '请输入姓' }]}
|
||||
>
|
||||
<Input placeholder="Last Name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="出生日期"
|
||||
name="date_of_birth"
|
||||
rules={[{ required: true, message: '请选择出生日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择日期" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="电话号码" name="phone_number">
|
||||
<Input placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item label="街道地址" name={['address', 'street_address']}>
|
||||
<Input placeholder="街道地址" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="城市" name={['address', 'city']}>
|
||||
<Input placeholder="城市" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="州/省" name={['address', 'state']}>
|
||||
<Input placeholder="州/省" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="邮编" name={['address', 'postcode']}>
|
||||
<Input placeholder="邮编" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="国家" name={['address', 'country']}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="选择国家"
|
||||
options={countries}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
99
frontend/src/components/Layout.tsx
Normal file
99
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout as AntLayout, Menu, Button, Dropdown, theme } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
CreditCardOutlined,
|
||||
UserOutlined,
|
||||
TransactionOutlined,
|
||||
KeyOutlined,
|
||||
SettingOutlined,
|
||||
FileTextOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { Sider, Header, Content } = AntLayout
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '仪表板' },
|
||||
{ key: '/cards', icon: <CreditCardOutlined />, label: '卡片管理' },
|
||||
{ key: '/cardholders', icon: <UserOutlined />, label: '持卡人' },
|
||||
{ key: '/transactions', icon: <TransactionOutlined />, label: '交易记录' },
|
||||
{ key: '/api-tokens', icon: <KeyOutlined />, label: 'API 令牌' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/audit-log', icon: <FileTextOutlined />, label: '操作日志' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const username = useAuthStore((s) => s.username)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const {
|
||||
token: { colorBgContainer },
|
||||
} = theme.useToken()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const selectedKey = '/' + (location.pathname.split('/')[1] || '')
|
||||
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme="dark"
|
||||
style={{ background: '#001529' }}
|
||||
>
|
||||
<div className="logo-container">
|
||||
<CreditCardOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
||||
{!collapsed && <h1>Airwallex 发卡管理</h1>}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey === '/' ? '/' : selectedKey]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<AntLayout>
|
||||
<Header className="layout-header" style={{ background: colorBgContainer }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ fontSize: 16, marginRight: 'auto' }}
|
||||
/>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" icon={<UserOutlined />}>
|
||||
{username || '管理员'}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content style={{ margin: 24, padding: 24, background: colorBgContainer, borderRadius: 8, minHeight: 280 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</AntLayout>
|
||||
</AntLayout>
|
||||
)
|
||||
}
|
||||
105
frontend/src/index.css
Normal file
105
frontend/src/index.css
Normal file
@@ -0,0 +1,105 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-container h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dashboard-card .ant-statistic-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sensitive-info {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.card-detail-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
232
frontend/src/pages/ApiTokens.tsx
Normal file
232
frontend/src/pages/ApiTokens.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Tag,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Checkbox,
|
||||
InputNumber,
|
||||
Space,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { tokensApi } from '@/services/api'
|
||||
|
||||
const { Paragraph } = Typography
|
||||
|
||||
interface ApiToken {
|
||||
id: string
|
||||
name: string
|
||||
token_masked: string
|
||||
permissions: string[]
|
||||
status: string
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
read_cards: '读取卡片',
|
||||
create_cards: '创建卡片',
|
||||
read_transactions: '读取交易',
|
||||
read_balance: '读取余额',
|
||||
}
|
||||
|
||||
export default function ApiTokens() {
|
||||
const [data, setData] = useState<ApiToken[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [newToken, setNewToken] = useState<string | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await tokensApi.getTokens()
|
||||
setData(res.data.items || res.data || [])
|
||||
} catch {
|
||||
message.error('获取令牌列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (values: {
|
||||
name: string
|
||||
permissions: string[]
|
||||
expires_days: number | null
|
||||
}) => {
|
||||
try {
|
||||
const res = await tokensApi.createToken(values)
|
||||
setNewToken(res.data.token || res.data.access_token)
|
||||
setModalOpen(false)
|
||||
form.resetFields()
|
||||
fetchData()
|
||||
} catch {
|
||||
message.error('创建令牌失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await tokensApi.deleteToken(id)
|
||||
message.success('令牌已删除')
|
||||
fetchData()
|
||||
} catch {
|
||||
message.error('删除令牌失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<ApiToken> = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: '令牌',
|
||||
dataIndex: 'token_masked',
|
||||
key: 'token_masked',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace' }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
key: 'permissions',
|
||||
render: (perms: string[]) =>
|
||||
perms?.map((p) => (
|
||||
<Tag key={p} color="blue">
|
||||
{permissionLabels[p] || p}
|
||||
</Tag>
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => (
|
||||
<Tag color={s === 'active' ? 'green' : 'red'}>{s === 'active' ? '有效' : '已失效'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
dataIndex: 'expires_at',
|
||||
key: 'expires_at',
|
||||
render: (t: string | null) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '永不过期'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: ApiToken) => (
|
||||
<Popconfirm
|
||||
title="确定要删除此令牌吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>API 令牌管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
创建令牌
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ showTotal: (t) => `共 ${t} 条` }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="创建 API 令牌"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item
|
||||
label="令牌名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入令牌名称' }]}
|
||||
>
|
||||
<Input placeholder="例如: 生产环境令牌" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="权限"
|
||||
name="permissions"
|
||||
rules={[{ required: true, message: '请选择至少一个权限' }]}
|
||||
>
|
||||
<Checkbox.Group
|
||||
options={[
|
||||
{ label: '读取卡片', value: 'read_cards' },
|
||||
{ label: '创建卡片', value: 'create_cards' },
|
||||
{ label: '读取交易', value: 'read_transactions' },
|
||||
{ label: '读取余额', value: 'read_balance' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="有效天数 (留空为永不过期)" name="expires_days">
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} placeholder="例如: 90" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="令牌已创建"
|
||||
open={!!newToken}
|
||||
onOk={() => setNewToken(null)}
|
||||
onCancel={() => setNewToken(null)}
|
||||
footer={[
|
||||
<Button key="close" type="primary" onClick={() => setNewToken(null)}>
|
||||
我已保存
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<p style={{ color: '#ff4d4f', fontWeight: 500, marginBottom: 16 }}>
|
||||
请立即复制并保存此令牌,关闭后将无法再次查看。
|
||||
</p>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Paragraph
|
||||
copyable={{
|
||||
icon: <CopyOutlined />,
|
||||
tooltips: ['复制', '已复制'],
|
||||
}}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
background: '#f5f5f5',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 6,
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{newToken}
|
||||
</Paragraph>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
frontend/src/pages/AuditLog.tsx
Normal file
109
frontend/src/pages/AuditLog.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
interface AuditLogRecord {
|
||||
id: string
|
||||
action: string
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
operator: string
|
||||
ip_address: string
|
||||
details: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function AuditLog() {
|
||||
const [data, setData] = useState<AuditLogRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const params: Record<string, unknown> = { page, page_size: pageSize }
|
||||
if (dateRange) {
|
||||
params.start_date = dateRange[0].format('YYYY-MM-DD')
|
||||
params.end_date = dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
try {
|
||||
const res = await auditLogsApi.getAuditLogs(params)
|
||||
setData(res.data.items || res.data || [])
|
||||
setTotal(res.data.total || 0)
|
||||
} catch {
|
||||
message.error('获取操作日志失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, dateRange])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const columns: ColumnsType<AuditLogRecord> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 120 },
|
||||
{ title: '资源类型', dataIndex: 'resource_type', key: 'resource_type', width: 120 },
|
||||
{
|
||||
title: '资源 ID',
|
||||
dataIndex: 'resource_id',
|
||||
key: 'resource_id',
|
||||
width: 160,
|
||||
render: (v: string) => v ? <span style={{ fontFamily: 'monospace' }}>{v.slice(0, 12)}...</span> : '-',
|
||||
},
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 },
|
||||
{ title: 'IP 地址', dataIndex: 'ip_address', key: 'ip_address', width: 140 },
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'details',
|
||||
key: 'details',
|
||||
ellipsis: true,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>操作日志</h2>
|
||||
</div>
|
||||
<div className="filter-bar">
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => {
|
||||
setDateRange(dates as [Dayjs, Dayjs] | null)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
269
frontend/src/pages/CardDetail.tsx
Normal file
269
frontend/src/pages/CardDetail.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Descriptions,
|
||||
Button,
|
||||
Tag,
|
||||
Table,
|
||||
Space,
|
||||
message,
|
||||
Spin,
|
||||
Modal,
|
||||
InputNumber,
|
||||
Form,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { cardsApi, transactionsApi } from '@/services/api'
|
||||
|
||||
interface CardInfo {
|
||||
card_id: string
|
||||
nickname: string
|
||||
cardholder_id: string
|
||||
cardholder_name: string
|
||||
status: string
|
||||
form_factor: string
|
||||
brand: string
|
||||
currency: string
|
||||
created_at: string
|
||||
is_personalized: boolean
|
||||
card_number?: string
|
||||
cvv?: string
|
||||
expiry_month?: string
|
||||
expiry_year?: string
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
transaction_id: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
merchant: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
INACTIVE: 'default',
|
||||
FROZEN: 'blue',
|
||||
CLOSED: 'red',
|
||||
}
|
||||
|
||||
export default function CardDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [card, setCard] = useState<CardInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showSensitive, setShowSensitive] = useState(false)
|
||||
const [sensitiveLoading, setSensitiveLoading] = useState(false)
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [txLoading, setTxLoading] = useState(false)
|
||||
const [limitModalOpen, setLimitModalOpen] = useState(false)
|
||||
const [limitForm] = Form.useForm()
|
||||
|
||||
const fetchCard = useCallback(async () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await cardsApi.getCard(id)
|
||||
setCard(res.data)
|
||||
} catch {
|
||||
message.error('获取卡片信息失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
if (!id) return
|
||||
setTxLoading(true)
|
||||
try {
|
||||
const res = await transactionsApi.getTransactions({ card_id: id })
|
||||
setTransactions(res.data.items || res.data || [])
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setTxLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCard()
|
||||
fetchTransactions()
|
||||
}, [fetchCard, fetchTransactions])
|
||||
|
||||
const handleShowSensitive = async () => {
|
||||
if (showSensitive) {
|
||||
setShowSensitive(false)
|
||||
return
|
||||
}
|
||||
if (!id) return
|
||||
setSensitiveLoading(true)
|
||||
try {
|
||||
const res = await cardsApi.getCardDetails(id)
|
||||
setCard((prev) => (prev ? { ...prev, ...res.data } : prev))
|
||||
setShowSensitive(true)
|
||||
} catch {
|
||||
message.error('获取敏感信息失败')
|
||||
} finally {
|
||||
setSensitiveLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (action: 'activate' | 'freeze') => {
|
||||
if (!id) return
|
||||
try {
|
||||
await cardsApi.updateCard(id, {
|
||||
status: action === 'activate' ? 'ACTIVE' : 'FROZEN',
|
||||
})
|
||||
message.success(action === 'activate' ? '卡片已激活' : '卡片已冻结')
|
||||
fetchCard()
|
||||
} catch {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateLimits = async (values: { daily_limit: number; monthly_limit: number }) => {
|
||||
if (!id) return
|
||||
try {
|
||||
await cardsApi.updateCard(id, { authorization_controls: values })
|
||||
message.success('限额更新成功')
|
||||
setLimitModalOpen(false)
|
||||
fetchCard()
|
||||
} catch {
|
||||
message.error('更新限额失败')
|
||||
}
|
||||
}
|
||||
|
||||
const txColumns: ColumnsType<Transaction> = [
|
||||
{ title: '交易 ID', dataIndex: 'transaction_id', key: 'transaction_id', width: 160, render: (v: string) => v?.slice(0, 12) + '...' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: Transaction) => `${r.currency} ${v?.toFixed(2)}` },
|
||||
{ title: '商户', dataIndex: 'merchant', key: 'merchant' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Tag>{s}</Tag> },
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm') },
|
||||
]
|
||||
|
||||
if (loading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />
|
||||
|
||||
if (!card) return <div>卡片不存在</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/cards')}>
|
||||
返回
|
||||
</Button>
|
||||
<h2>卡片详情</h2>
|
||||
</Space>
|
||||
<Space>
|
||||
{card.status === 'INACTIVE' && (
|
||||
<Button type="primary" icon={<UnlockOutlined />} onClick={() => handleStatusChange('activate')}>
|
||||
激活
|
||||
</Button>
|
||||
)}
|
||||
{card.status === 'ACTIVE' && (
|
||||
<Button danger icon={<LockOutlined />} onClick={() => handleStatusChange('freeze')}>
|
||||
冻结
|
||||
</Button>
|
||||
)}
|
||||
{card.status === 'FROZEN' && (
|
||||
<Button type="primary" icon={<UnlockOutlined />} onClick={() => handleStatusChange('activate')}>
|
||||
解冻
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setLimitModalOpen(true)}>修改限额</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="card-detail-section">
|
||||
<Descriptions title="基本信息" bordered column={2}>
|
||||
<Descriptions.Item label="卡片 ID">{card.card_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{card.nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="持卡人 ID">{card.cardholder_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="持卡人">{card.cardholder_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={statusColors[card.status] || 'default'}>{card.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{card.form_factor}</Descriptions.Item>
|
||||
<Descriptions.Item label="品牌">{card.brand || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{card.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{dayjs(card.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="card-detail-section"
|
||||
title="敏感信息"
|
||||
extra={
|
||||
<Button
|
||||
icon={showSensitive ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
loading={sensitiveLoading}
|
||||
onClick={handleShowSensitive}
|
||||
>
|
||||
{showSensitive ? '隐藏' : '查看敏感信息'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{showSensitive ? (
|
||||
<Descriptions bordered column={3}>
|
||||
<Descriptions.Item label="卡号">
|
||||
<span className="sensitive-info">{card.card_number || '-'}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="CVV">
|
||||
<span className="sensitive-info">{card.cvv || '-'}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="有效期">
|
||||
<span className="sensitive-info">
|
||||
{card.expiry_month && card.expiry_year
|
||||
? `${card.expiry_month}/${card.expiry_year}`
|
||||
: '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: '#999', padding: 24 }}>
|
||||
点击上方按钮查看敏感信息
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="相关交易">
|
||||
<Table
|
||||
columns={txColumns}
|
||||
dataSource={transactions}
|
||||
rowKey="transaction_id"
|
||||
loading={txLoading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 条` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="修改卡片限额"
|
||||
open={limitModalOpen}
|
||||
onCancel={() => setLimitModalOpen(false)}
|
||||
onOk={() => limitForm.submit()}
|
||||
>
|
||||
<Form form={limitForm} layout="vertical" onFinish={handleUpdateLimits}>
|
||||
<Form.Item label="单日限额" name="daily_limit">
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item label="月度限额" name="monthly_limit">
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
frontend/src/pages/Cardholders.tsx
Normal file
102
frontend/src/pages/Cardholders.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 CreateCardholderModal from '@/components/CreateCardholderModal'
|
||||
|
||||
interface Cardholder {
|
||||
cardholder_id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function Cardholders() {
|
||||
const [data, setData] = useState<Cardholder[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await cardholdersApi.getCardholders({ page, page_size: pageSize })
|
||||
setData(res.data.items || res.data)
|
||||
setTotal(res.data.total || 0)
|
||||
} catch {
|
||||
message.error('获取持卡人列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const columns: ColumnsType<Cardholder> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'cardholder_id',
|
||||
key: 'cardholder_id',
|
||||
width: 160,
|
||||
render: (id: string) => <span style={{ fontFamily: 'monospace' }}>{id?.slice(0, 12)}...</span>,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
key: 'name',
|
||||
render: (_: unknown, r: Cardholder) => `${r.first_name} ${r.last_name}`,
|
||||
},
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>持卡人管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
创建持卡人
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="cardholder_id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
/>
|
||||
<CreateCardholderModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={() => { setModalOpen(false); fetchData() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
frontend/src/pages/Cards.tsx
Normal file
142
frontend/src/pages/Cards.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 CreateCardModal from '@/components/CreateCardModal'
|
||||
|
||||
interface CardRecord {
|
||||
card_id: string
|
||||
nickname: string
|
||||
cardholder_name: string
|
||||
status: string
|
||||
form_factor: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
INACTIVE: 'default',
|
||||
FROZEN: 'blue',
|
||||
CLOSED: 'red',
|
||||
}
|
||||
|
||||
export default function Cards() {
|
||||
const [data, setData] = useState<CardRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [status, setStatus] = useState<string | undefined>()
|
||||
const [search, setSearch] = useState('')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: pageSize }
|
||||
if (status) params.status = status
|
||||
if (search) params.search = search
|
||||
const res = await cardsApi.getCards(params)
|
||||
setData(res.data.items || res.data)
|
||||
setTotal(res.data.total || 0)
|
||||
} catch {
|
||||
message.error('获取卡片列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, status, search])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const columns: ColumnsType<CardRecord> = [
|
||||
{
|
||||
title: '卡片 ID',
|
||||
dataIndex: 'card_id',
|
||||
key: 'card_id',
|
||||
width: 160,
|
||||
render: (id: string) => (
|
||||
<span style={{ fontFamily: 'monospace' }}>{id?.slice(0, 12)}...</span>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
|
||||
{ title: '持卡人', dataIndex: 'cardholder_name', key: 'cardholder_name' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => <Tag color={statusColors[s] || 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '类型', dataIndex: 'form_factor', key: 'form_factor' },
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>卡片管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
创建卡片
|
||||
</Button>
|
||||
</div>
|
||||
<div className="filter-bar">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 150 }}
|
||||
value={status}
|
||||
onChange={(v) => { setStatus(v); setPage(1) }}
|
||||
options={[
|
||||
{ label: '活跃', value: 'ACTIVE' },
|
||||
{ label: '未激活', value: 'INACTIVE' },
|
||||
{ label: '冻结', value: 'FROZEN' },
|
||||
{ label: '已关闭', value: 'CLOSED' },
|
||||
]}
|
||||
/>
|
||||
<Space.Compact>
|
||||
<Input
|
||||
placeholder="搜索卡片..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onPressEnter={() => { setPage(1); fetchData() }}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Button icon={<SearchOutlined />} onClick={() => { setPage(1); fetchData() }} />
|
||||
</Space.Compact>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="card_id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => navigate(`/cards/${record.card_id}`),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
<CreateCardModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={() => { setModalOpen(false); fetchData() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
frontend/src/pages/Dashboard.tsx
Normal file
103
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Row, Col, Card, Statistic, Button, Skeleton, message } from 'antd'
|
||||
import {
|
||||
DollarOutlined,
|
||||
CreditCardOutlined,
|
||||
CheckCircleOutlined,
|
||||
PlusCircleOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { dashboardApi } from '@/services/api'
|
||||
|
||||
interface DashboardData {
|
||||
account_balance: number
|
||||
balance_currency: string
|
||||
total_cards: number
|
||||
active_cards: number
|
||||
today_new_cards: number
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await dashboardApi.getDashboard()
|
||||
setData(res.data)
|
||||
} catch {
|
||||
message.error('获取仪表板数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: '账户余额',
|
||||
value: data?.account_balance ?? 0,
|
||||
prefix: data?.balance_currency === 'USD' ? '$' : data?.balance_currency,
|
||||
precision: 2,
|
||||
icon: <DollarOutlined style={{ fontSize: 24, color: '#3f8600' }} />,
|
||||
color: '#f6ffed',
|
||||
},
|
||||
{
|
||||
title: '总卡片数',
|
||||
value: data?.total_cards ?? 0,
|
||||
icon: <CreditCardOutlined style={{ fontSize: 24, color: '#1890ff' }} />,
|
||||
color: '#e6f7ff',
|
||||
},
|
||||
{
|
||||
title: '活跃卡片',
|
||||
value: data?.active_cards ?? 0,
|
||||
icon: <CheckCircleOutlined style={{ fontSize: 24, color: '#52c41a' }} />,
|
||||
color: '#f6ffed',
|
||||
},
|
||||
{
|
||||
title: '今日新增',
|
||||
value: data?.today_new_cards ?? 0,
|
||||
icon: <PlusCircleOutlined style={{ fontSize: 24, color: '#722ed1' }} />,
|
||||
color: '#f9f0ff',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>仪表板</h2>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{cards.map((card) => (
|
||||
<Col xs={24} sm={12} lg={6} key={card.title}>
|
||||
<Card className="dashboard-card" style={{ background: card.color }}>
|
||||
{loading ? (
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
) : (
|
||||
<Statistic
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
prefix={card.icon}
|
||||
suffix={card.prefix && card.title === '账户余额' ? '' : undefined}
|
||||
precision={card.precision}
|
||||
formatter={
|
||||
card.title === '账户余额'
|
||||
? (val) => `${card.prefix || ''} ${val}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
frontend/src/pages/Login.tsx
Normal file
51
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, message } from 'antd'
|
||||
import { UserOutlined, LockOutlined, CreditCardOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export default function Login() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(values.username, values.password)
|
||||
message.success('登录成功')
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
message.error(error.response?.data?.detail || '登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<Card className="login-card">
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<CreditCardOutlined style={{ fontSize: 48, color: '#667eea' }} />
|
||||
<h2 className="login-title" style={{ marginTop: 12 }}>
|
||||
Airwallex 发卡管理系统
|
||||
</h2>
|
||||
</div>
|
||||
<Form name="login" onFinish={onFinish} size="large" autoComplete="off">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
frontend/src/pages/Settings.tsx
Normal file
143
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Form, Input, InputNumber, Button, Divider, message, Space } from 'antd'
|
||||
import { SaveOutlined, ApiOutlined } 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
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
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) {
|
||||
data[item.key] = item.value
|
||||
}
|
||||
}
|
||||
form.setFieldsValue(data)
|
||||
} catch {
|
||||
message.error('获取设置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleSave = async (values: SettingsData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Convert form object to list of {key, value} for backend
|
||||
const updates = Object.entries(values)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([key, value]) => ({ key, value: String(value) }))
|
||||
await settingsApi.updateSettings(updates)
|
||||
message.success('设置已保存')
|
||||
} catch {
|
||||
message.error('保存设置失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const res = await api.post('/settings/test-connection')
|
||||
if (res.data.success) {
|
||||
message.success('连接测试成功')
|
||||
} else {
|
||||
message.error(res.data.message || '连接测试失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('连接测试失败')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>系统设置</h2>
|
||||
</div>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave} disabled={loading}>
|
||||
<Card title="Airwallex 凭证" style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name="airwallex_client_id"
|
||||
rules={[{ required: true, message: '请输入 Client ID' }]}
|
||||
>
|
||||
<Input placeholder="请输入 Client ID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="API Key"
|
||||
name="airwallex_api_key"
|
||||
rules={[{ required: true, message: '请输入 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" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
onClick={handleTestConnection}
|
||||
loading={testing}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card title="代理设置" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="代理 IP" name="proxy_host">
|
||||
<Input placeholder="例如: 127.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item label="代理端口" name="proxy_port">
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="例如: 1080" />
|
||||
</Form.Item>
|
||||
<Form.Item label="代理用户名" name="proxy_username">
|
||||
<Input placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item label="代理密码" name="proxy_password">
|
||||
<Input.Password placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card title="卡片限制" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="每日创建限额" name="daily_card_limit">
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={1000} placeholder="例如: 50" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存所有设置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
frontend/src/pages/Transactions.tsx
Normal file
184
frontend/src/pages/Transactions.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
interface Transaction {
|
||||
transaction_id: string
|
||||
card_id: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
transaction_type: string
|
||||
merchant: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Authorization {
|
||||
authorization_id: string
|
||||
card_id: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
merchant: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
COMPLETED: 'green',
|
||||
PENDING: 'orange',
|
||||
FAILED: 'red',
|
||||
REVERSED: 'purple',
|
||||
APPROVED: 'green',
|
||||
DECLINED: 'red',
|
||||
}
|
||||
|
||||
export default function Transactions() {
|
||||
const [activeTab, setActiveTab] = useState('transactions')
|
||||
const [txData, setTxData] = useState<Transaction[]>([])
|
||||
const [authData, setAuthData] = useState<Authorization[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null)
|
||||
const [cardId, setCardId] = useState('')
|
||||
const [status, setStatus] = useState<string | undefined>()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const params: Record<string, unknown> = { page, page_size: pageSize }
|
||||
if (dateRange) {
|
||||
params.start_date = dateRange[0].format('YYYY-MM-DD')
|
||||
params.end_date = dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
if (cardId) params.card_id = cardId
|
||||
if (status) params.status = status
|
||||
try {
|
||||
if (activeTab === 'transactions') {
|
||||
const res = await transactionsApi.getTransactions(params)
|
||||
setTxData(res.data.items || res.data)
|
||||
setTotal(res.data.total || 0)
|
||||
} else {
|
||||
const res = await authorizationsApi.getAuthorizations(params)
|
||||
setAuthData(res.data.items || res.data)
|
||||
setTotal(res.data.total || 0)
|
||||
}
|
||||
} catch {
|
||||
message.error('获取数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeTab, page, pageSize, dateRange, cardId, status])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const txColumns: ColumnsType<Transaction> = [
|
||||
{ title: '交易 ID', dataIndex: 'transaction_id', key: 'transaction_id', width: 160, render: (v: string) => <span style={{ fontFamily: 'monospace' }}>{v?.slice(0, 12)}...</span> },
|
||||
{ title: '卡片 ID', dataIndex: 'card_id', key: 'card_id', width: 140, render: (v: string) => v?.slice(0, 10) + '...' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: Transaction) => `${r.currency} ${v?.toFixed(2)}` },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Tag color={statusColors[s] || 'default'}>{s}</Tag> },
|
||||
{ title: '类型', dataIndex: 'transaction_type', key: 'transaction_type' },
|
||||
{ title: '商户', dataIndex: 'merchant', key: 'merchant' },
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm') },
|
||||
]
|
||||
|
||||
const authColumns: ColumnsType<Authorization> = [
|
||||
{ title: '授权 ID', dataIndex: 'authorization_id', key: 'authorization_id', width: 160, render: (v: string) => <span style={{ fontFamily: 'monospace' }}>{v?.slice(0, 12)}...</span> },
|
||||
{ title: '卡片 ID', dataIndex: 'card_id', key: 'card_id', width: 140, render: (v: string) => v?.slice(0, 10) + '...' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: Authorization) => `${r.currency} ${v?.toFixed(2)}` },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Tag color={statusColors[s] || 'default'}>{s}</Tag> },
|
||||
{ title: '商户', dataIndex: 'merchant', key: 'merchant' },
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>交易记录</h2>
|
||||
</div>
|
||||
<div className="filter-bar">
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => {
|
||||
setDateRange(dates as [Dayjs, Dayjs] | null)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Input
|
||||
placeholder="卡片 ID"
|
||||
value={cardId}
|
||||
onChange={(e) => { setCardId(e.target.value); setPage(1) }}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
style={{ width: 130 }}
|
||||
value={status}
|
||||
onChange={(v) => { setStatus(v); setPage(1) }}
|
||||
options={[
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '待处理', value: 'PENDING' },
|
||||
{ label: '失败', value: 'FAILED' },
|
||||
{ label: '已撤销', value: 'REVERSED' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => { setActiveTab(key); setPage(1) }}
|
||||
items={[
|
||||
{
|
||||
key: 'transactions',
|
||||
label: '交易',
|
||||
children: (
|
||||
<Table
|
||||
columns={txColumns}
|
||||
dataSource={txData}
|
||||
rowKey="transaction_id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'authorizations',
|
||||
label: '授权',
|
||||
children: (
|
||||
<Table
|
||||
columns={authColumns}
|
||||
dataSource={authData}
|
||||
rowKey="authorization_id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
frontend/src/services/api.ts
Normal file
84
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post('/auth/login', { username, password }),
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
getDashboard: () => api.get('/dashboard'),
|
||||
}
|
||||
|
||||
export const cardsApi = {
|
||||
getCards: (params?: Record<string, unknown>) => api.get('/cards', { params }),
|
||||
createCard: (data: Record<string, unknown>) => api.post('/cards', data),
|
||||
getCard: (id: string) => api.get(`/cards/${id}`),
|
||||
getCardDetails: (id: string) => api.get(`/cards/${id}/details`),
|
||||
updateCard: (id: string, data: Record<string, unknown>) => api.put(`/cards/${id}`, data),
|
||||
}
|
||||
|
||||
export const cardholdersApi = {
|
||||
getCardholders: (params?: Record<string, unknown>) => api.get('/cardholders', { params }),
|
||||
createCardholder: (data: Record<string, unknown>) => api.post('/cardholders', data),
|
||||
}
|
||||
|
||||
export const transactionsApi = {
|
||||
getTransactions: (params?: Record<string, unknown>) => api.get('/transactions', { params }),
|
||||
}
|
||||
|
||||
export const authorizationsApi = {
|
||||
getAuthorizations: (params?: Record<string, unknown>) => api.get('/authorizations', { params }),
|
||||
}
|
||||
|
||||
export const tokensApi = {
|
||||
getTokens: () => api.get('/tokens'),
|
||||
createToken: (data: Record<string, unknown>) => api.post('/tokens', data),
|
||||
deleteToken: (id: string) => api.delete(`/tokens/${id}`),
|
||||
}
|
||||
|
||||
// Alias for components that import cardholderApi (singular)
|
||||
export const cardholderApi = {
|
||||
list: (params?: Record<string, unknown>) => api.get('/cardholders', { params }),
|
||||
create: (data: Record<string, unknown>) => api.post('/cardholders', data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
getSettings: () => api.get('/settings'),
|
||||
updateSettings: (data: { key: string; value: string }[]) => api.put('/settings', data),
|
||||
testConnection: () => api.post('/settings/test-connection'),
|
||||
}
|
||||
|
||||
export const cardLogsApi = {
|
||||
getCardLogs: (params?: Record<string, unknown>) => api.get('/card-logs', { params }),
|
||||
}
|
||||
|
||||
export const auditLogsApi = {
|
||||
getAuditLogs: (params?: Record<string, unknown>) => api.get('/audit-logs', { params }),
|
||||
}
|
||||
|
||||
export default api
|
||||
28
frontend/src/stores/auth.ts
Normal file
28
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand'
|
||||
import { authApi } from '@/services/api'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
username: string | null
|
||||
isAuthenticated: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: localStorage.getItem('token'),
|
||||
username: localStorage.getItem('username'),
|
||||
isAuthenticated: !!localStorage.getItem('token'),
|
||||
login: async (username: string, password: string) => {
|
||||
const res = await authApi.login(username, password)
|
||||
const token = res.data.access_token
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('username', username)
|
||||
set({ token, username, isAuthenticated: true })
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
set({ token: null, username: null, isAuthenticated: false })
|
||||
},
|
||||
}))
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
20
frontend/tsconfig.node.json
Normal file
20
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"composite": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
20
frontend/vite.config.ts
Normal file
20
frontend/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user