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:
zqq61
2026-03-15 23:05:08 +08:00
commit 4f53889a8e
98 changed files with 10847 additions and 0 deletions

23
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
frontend/tsconfig.json Normal file
View 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" }]
}

View 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
View 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,
},
},
},
})