This commit is contained in:
2026-03-13 15:51:59 +08:00
parent 4db2386bbf
commit 4e91f4cede
133 changed files with 19502 additions and 37 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4630
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@tanstack/react-query": "^5.90.21",
"antd": "^6.3.1",
"axios": "^1.13.6",
"dayjs": "^1.11.19",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

56
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import AppLayout from './components/layout/AppLayout'
import Dashboard from './pages/dashboard'
import TradePage from './pages/trade'
import RefundPage from './pages/refund'
import MerchantPage from './pages/merchant'
import MatchPage from './pages/match'
import ReconciliationPage from './pages/reconciliation'
import AppPage from './pages/app'
import LoginPage from './pages/login'
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 30_000 },
},
})
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('admin_token')
if (!token) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/trade" element={<TradePage />} />
<Route path="/refund" element={<RefundPage />} />
<Route path="/merchant" element={<MerchantPage />} />
<Route path="/match" element={<MatchPage />} />
<Route path="/reconciliation" element={<ReconciliationPage />} />
<Route path="/app" element={<AppPage />} />
</Route>
</Routes>
</BrowserRouter>
</ConfigProvider>
</QueryClientProvider>
)
}

21
frontend/src/api/app.ts Normal file
View File

@@ -0,0 +1,21 @@
import client from './client'
import type { App, CreateAppResult, PageResult } from '../types'
export const appApi = {
list: (params?: { limit?: number; offset?: number }) =>
client.get<{ data: PageResult<App> }>('/admin/app', { params }),
create: (data: { app_name: string }) =>
client.post<{ data: CreateAppResult }>('/admin/app', data),
disable: (appID: string) =>
client.post(`/admin/app/${appID}/disable`),
enable: (appID: string) =>
client.post(`/admin/app/${appID}/enable`),
resetSecret: (appID: string) =>
client.post<{ data: { app_id: string; app_secret: string; secret_tip: string } }>(
`/admin/app/${appID}/reset-secret`
),
}

13
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,13 @@
import client from './client'
export interface LoginResponse {
token: string
}
export async function login(username: string, password: string): Promise<LoginResponse> {
const res = await client.post<{ code: string; data: LoginResponse }>('/admin/login', {
username,
password,
})
return res.data.data
}

View File

@@ -0,0 +1,39 @@
import axios from 'axios'
import { message } from 'antd'
import type { ApiResponse } from '../types'
const client = axios.create({
baseURL: '/api/v1',
timeout: 15000,
})
// 请求拦截器:注入 JWT token
client.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
client.interceptors.response.use(
(response) => {
const data: ApiResponse<unknown> = response.data
if (data.code !== '0') {
message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return response
},
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token')
window.location.href = '/login'
return Promise.reject(error)
}
message.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default client

10
frontend/src/api/match.ts Normal file
View File

@@ -0,0 +1,10 @@
import client from './client'
import type { PaymentMatchLog, PageResult } from '../types'
export const matchApi = {
listPending: (params: { app_id?: string; limit?: number; offset?: number }) =>
client.get<{ data: PageResult<PaymentMatchLog> }>('/admin/match/pending', { params }),
bind: (data: { match_id: number; trade_no: string; operator: string }) =>
client.post('/admin/match/bind', data),
}

View File

@@ -0,0 +1,25 @@
import client from './client'
import type { Merchant, MerchantApplication, MerchantStatus } from '../types'
export const merchantApi = {
list: (params: { status?: MerchantStatus; limit?: number; offset?: number }) =>
client.get<{ data: { list: Merchant[] } }>('/admin/merchant', { params }),
get: (merchantID: string) =>
client.get<{ data: Merchant }>(`/admin/merchant/${merchantID}`),
create: (data: Partial<Merchant>) =>
client.post<{ data: Merchant }>('/admin/merchant', data),
freeze: (merchantID: string) =>
client.post(`/admin/merchant/${merchantID}/freeze`),
unfreeze: (merchantID: string) =>
client.post(`/admin/merchant/${merchantID}/unfreeze`),
apply: (merchantID: string, data: { channel_code: string; submit_data?: Record<string, unknown> }) =>
client.post<{ data: { application_id: string } }>(`/admin/merchant/${merchantID}/apply`, data),
auditStatus: (merchantID: string) =>
client.get<{ data: MerchantApplication }>(`/admin/merchant/${merchantID}/audit`),
}

View File

@@ -0,0 +1,13 @@
import client from './client'
import type { ReconciliationReport, ReconciliationException } from '../types'
export const reconciliationApi = {
trigger: () =>
client.post('/admin/reconciliation/trigger'),
getReport: (params: { app_id: string; bill_date: string; channel_code: string }) =>
client.get<{ data: ReconciliationReport }>('/admin/reconciliation/report', { params }),
getExceptions: (reportID: number) =>
client.get<{ data: ReconciliationException[] }>(`/admin/reconciliation/report/${reportID}/exceptions`),
}

20
frontend/src/api/trade.ts Normal file
View File

@@ -0,0 +1,20 @@
import client from './client'
import type { TradeOrder, PageResult } from '../types'
export interface TradeListParams {
status?: string
app_id?: string
limit?: number
offset?: number
}
export const tradeApi = {
list: (params: TradeListParams) =>
client.get<{ data: PageResult<TradeOrder> }>('/admin/trade', { params }),
get: (tradeNo: string) =>
client.get<{ data: TradeOrder }>(`/pay/query/${tradeNo}`),
close: (tradeNo: string) =>
client.post('/pay/close', { trade_no: tradeNo }),
}

View File

@@ -0,0 +1,53 @@
import { Layout, Menu } from 'antd'
import {
DashboardOutlined,
TransactionOutlined,
RollbackOutlined,
ShopOutlined,
BankOutlined,
FileSearchOutlined,
AppstoreOutlined,
} from '@ant-design/icons'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
const { Sider, Header, Content } = Layout
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/trade', icon: <TransactionOutlined />, label: '交易管理' },
{ key: '/refund', icon: <RollbackOutlined />, label: '退款管理' },
{ key: '/merchant', icon: <ShopOutlined />, label: '商户管理' },
{ key: '/match', icon: <BankOutlined />, label: '收款匹配' },
{ key: '/reconciliation',icon: <FileSearchOutlined />, label: '对账管理' },
{ key: '/app', icon: <AppstoreOutlined />, label: '接入应用' },
]
export default function AppLayout() {
const navigate = useNavigate()
const location = useLocation()
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider width={200} theme="dark">
<div style={{ height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#fff', fontWeight: 700, fontSize: 16 }}>Pay Bridge</span>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header style={{ background: '#fff', padding: '0 24px', borderBottom: '1px solid #f0f0f0' }}>
<span style={{ fontSize: 14, color: '#666' }}></span>
</Header>
<Content style={{ margin: 24, minHeight: 280 }}>
<Outlet />
</Content>
</Layout>
</Layout>
)
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'antd/dist/reset.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import {
Table, Tag, Button, Space, Card, Modal, Form, Input,
message, Popconfirm, Alert, Typography,
} from 'antd'
import { KeyOutlined, PlusOutlined } from '@ant-design/icons'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { appApi } from '../../api/app'
import type { App } from '../../types'
const { Text } = Typography
export default function AppPage() {
const [createOpen, setCreateOpen] = useState(false)
const [secretModal, setSecretModal] = useState<{ appID: string; secret: string; tip: string } | null>(null)
const [form] = Form.useForm()
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['apps'],
queryFn: () => appApi.list({ limit: 50, offset: 0 }),
})
const createMutation = useMutation({
mutationFn: appApi.create,
onSuccess: (res) => {
setCreateOpen(false)
form.resetFields()
qc.invalidateQueries({ queryKey: ['apps'] })
const d = res.data.data
setSecretModal({ appID: d.app_id, secret: d.app_secret, tip: d.secret_tip })
},
})
const disableMutation = useMutation({
mutationFn: appApi.disable,
onSuccess: () => {
message.success('已禁用')
qc.invalidateQueries({ queryKey: ['apps'] })
},
})
const enableMutation = useMutation({
mutationFn: appApi.enable,
onSuccess: () => {
message.success('已启用')
qc.invalidateQueries({ queryKey: ['apps'] })
},
})
const resetMutation = useMutation({
mutationFn: appApi.resetSecret,
onSuccess: (res) => {
const d = res.data.data
setSecretModal({ appID: d.app_id, secret: d.app_secret, tip: d.secret_tip })
},
})
const columns: ColumnsType<App> = [
{ title: 'App ID', dataIndex: 'app_id', width: 200 },
{ title: '应用名称', dataIndex: 'app_name', width: 160 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (v: number) =>
v === 1 ? <Tag color="success"></Tag> : <Tag color="default"></Tag>,
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
width: 220,
render: (_, record) => (
<Space>
<Popconfirm
title="重置后旧密钥立即失效,确认重置?"
onConfirm={() => resetMutation.mutate(record.app_id)}
>
<Button size="small" icon={<KeyOutlined />}></Button>
</Popconfirm>
{record.status === 1 ? (
<Popconfirm title="禁用后该应用将无法访问,确认?" onConfirm={() => disableMutation.mutate(record.app_id)}>
<Button size="small" danger></Button>
</Popconfirm>
) : (
<Popconfirm title="确认启用该应用?" onConfirm={() => enableMutation.mutate(record.app_id)}>
<Button size="small"></Button>
</Popconfirm>
)}
</Space>
),
},
]
const list: App[] = data?.data?.data?.list ?? []
return (
<>
<Card
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>
}
>
<Table
rowKey="app_id"
columns={columns}
dataSource={list}
loading={isLoading}
scroll={{ x: 800 }}
/>
</Card>
{/* 新建应用弹窗 */}
<Modal
title="新建接入应用"
open={createOpen}
onOk={() => form.submit()}
onCancel={() => { setCreateOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
>
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
<Form.Item name="app_name" label="应用名称" rules={[{ required: true, message: '请输入应用名称' }]}>
<Input placeholder="例如商城系统、ERP系统" />
</Form.Item>
</Form>
<Alert
type="info"
showIcon
message="App ID 和 App Secret 将在创建后自动生成Secret 仅展示一次,请妥善保存。"
style={{ marginTop: 8 }}
/>
</Modal>
{/* Secret 展示弹窗(创建/重置后) */}
<Modal
title="请妥善保存以下凭证"
open={!!secretModal}
footer={<Button type="primary" onClick={() => setSecretModal(null)}></Button>}
onCancel={() => setSecretModal(null)}
closable={false}
maskClosable={false}
>
{secretModal && (
<>
<Alert
type="warning"
showIcon
message={secretModal.tip}
style={{ marginBottom: 16 }}
/>
<Form layout="vertical">
<Form.Item label="App ID">
<Text copyable code>{secretModal.appID}</Text>
</Form.Item>
<Form.Item label="App Secret">
<Text copyable code>{secretModal.secret}</Text>
</Form.Item>
</Form>
</>
)}
</Modal>
</>
)
}

View File

@@ -0,0 +1,53 @@
import { Card, Col, Row, Statistic } from 'antd'
import {
TransactionOutlined,
CheckCircleOutlined,
RollbackOutlined,
WarningOutlined,
} from '@ant-design/icons'
export default function Dashboard() {
return (
<div>
<h2 style={{ marginBottom: 24 }}></h2>
<Row gutter={16}>
<Col span={6}>
<Card>
<Statistic
title="今日交易笔数"
value={0}
prefix={<TransactionOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="今日交易金额"
value="¥0.00"
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="今日退款笔数"
value={0}
prefix={<RollbackOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="待人工确认匹配"
value={0}
prefix={<WarningOutlined style={{ color: '#faad14' }} />}
/>
</Card>
</Col>
</Row>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { Form, Input, Button, Card } from 'antd'
import { UserOutlined, LockOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { login } from '../../api/auth'
interface LoginForm {
username: string
password: string
}
export default function LoginPage() {
const navigate = useNavigate()
const [form] = Form.useForm<LoginForm>()
const handleSubmit = async (values: LoginForm) => {
try {
const { token } = await login(values.username, values.password)
localStorage.setItem('admin_token', token)
navigate('/', { replace: true })
} catch {
// 错误已由 axios 响应拦截器统一提示
}
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f0f2f5',
}}
>
<Card title="Pay-Bridge 管理后台" style={{ width: 360 }}>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" size="large" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block size="large">
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import { useState } from 'react'
import { Table, Tag, Button, Card, Modal, Form, Input, message } from 'antd'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { matchApi } from '../../api/match'
import { formatAmount } from '../../utils/format'
import type { PaymentMatchLog } from '../../types'
export default function MatchPage() {
const [bindOpen, setBindOpen] = useState(false)
const [current, setCurrent] = useState<PaymentMatchLog | null>(null)
const [form] = Form.useForm()
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['match-pending'],
queryFn: () => matchApi.listPending({ limit: 50, offset: 0 }),
})
const bindMutation = useMutation({
mutationFn: matchApi.bind,
onSuccess: () => {
message.success('关联成功')
setBindOpen(false)
form.resetFields()
qc.invalidateQueries({ queryKey: ['match-pending'] })
},
})
const columns: ColumnsType<PaymentMatchLog> = [
{ title: '渠道流水号', dataIndex: 'channel_bill_no', width: 160 },
{ title: '入账金额', dataIndex: 'incoming_amount', width: 100, render: (v) => formatAmount(v) },
{ title: '付款方', dataIndex: 'payer_name', width: 140 },
{ title: '转账备注', dataIndex: 'incoming_remark', width: 200, ellipsis: true },
{
title: '状态',
dataIndex: 'match_status',
width: 110,
render: (v) => {
const map: Record<string, [string, string]> = {
PENDING_MANUAL: ['待人工确认', 'warning'],
NAME_DIFF: ['名称差异', 'orange'],
MATCHED: ['已匹配', 'success'],
}
const [label, color] = map[v] ?? [v, 'default']
return <Tag color={color}>{label}</Tag>
},
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 160,
render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
width: 100,
render: (_, record) =>
record.match_status === 'PENDING_MANUAL' || record.match_status === 'NAME_DIFF' ? (
<Button
size="small"
type="primary"
onClick={() => { setCurrent(record); setBindOpen(true) }}
>
</Button>
) : null,
},
]
const list: PaymentMatchLog[] = data?.data?.data?.list ?? []
return (
<Card title="收款匹配(待人工确认)">
<Table
rowKey="id"
columns={columns}
dataSource={list}
loading={isLoading}
scroll={{ x: 900 }}
/>
<Modal
title="人工关联订单"
open={bindOpen}
onOk={() => form.submit()}
onCancel={() => { setBindOpen(false); form.resetFields() }}
confirmLoading={bindMutation.isPending}
>
{current && (
<p style={{ marginBottom: 12, color: '#666' }}>
{formatAmount(current.incoming_amount)} {current.payer_name}
</p>
)}
<Form
form={form}
layout="vertical"
onFinish={(v) =>
bindMutation.mutate({ match_id: current!.id, trade_no: v.trade_no, operator: v.operator })
}
>
<Form.Item name="trade_no" label="交易号" rules={[{ required: true }]}>
<Input placeholder="输入要关联的 pay-bridge 交易号" />
</Form.Item>
<Form.Item name="operator" label="操作人" rules={[{ required: true }]}>
<Input placeholder="操作人姓名" />
</Form.Item>
</Form>
</Modal>
</Card>
)
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { Table, Tag, Button, Space, Card, Modal, Form, Input, message, Popconfirm } from 'antd'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { merchantApi } from '../../api/merchant'
import { merchantStatusMap } from '../../utils/format'
import type { Merchant, MerchantStatus } from '../../types'
export default function MerchantPage() {
const [createOpen, setCreateOpen] = useState(false)
const [form] = Form.useForm()
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['merchants'],
queryFn: () => merchantApi.list({ limit: 50, offset: 0 }),
})
const createMutation = useMutation({
mutationFn: merchantApi.create,
onSuccess: () => {
message.success('商户创建成功')
setCreateOpen(false)
form.resetFields()
qc.invalidateQueries({ queryKey: ['merchants'] })
},
})
const freezeMutation = useMutation({
mutationFn: (id: string) => merchantApi.freeze(id),
onSuccess: () => {
message.success('操作成功')
qc.invalidateQueries({ queryKey: ['merchants'] })
},
})
const unfreezeMutation = useMutation({
mutationFn: (id: string) => merchantApi.unfreeze(id),
onSuccess: () => {
message.success('操作成功')
qc.invalidateQueries({ queryKey: ['merchants'] })
},
})
const columns: ColumnsType<Merchant> = [
{ title: '商户ID', dataIndex: 'merchant_id', width: 140 },
{ title: '商户名称', dataIndex: 'merchant_name', width: 160 },
{ title: '法人', dataIndex: 'legal_person', width: 100 },
{ title: '营业执照', dataIndex: 'license_no', width: 140 },
{ title: '渠道商户ID', dataIndex: 'channel_merchant_id', width: 140 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v: MerchantStatus) => {
const s = merchantStatusMap[v]
return s ? <Tag color={s.color}>{s.label}</Tag> : v
},
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 160,
render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
{record.status === 'ACTIVE' ? (
<Popconfirm title="确认冻结该商户?" onConfirm={() => freezeMutation.mutate(record.merchant_id)}>
<Button size="small" danger></Button>
</Popconfirm>
) : record.status === 'FROZEN' ? (
<Popconfirm title="确认解冻该商户?" onConfirm={() => unfreezeMutation.mutate(record.merchant_id)}>
<Button size="small"></Button>
</Popconfirm>
) : null}
</Space>
),
},
]
const list: Merchant[] = data?.data?.data?.list ?? []
return (
<Card
extra={<Button type="primary" onClick={() => setCreateOpen(true)}></Button>}
>
<Table
rowKey="merchant_id"
columns={columns}
dataSource={list}
loading={isLoading}
scroll={{ x: 1100 }}
/>
<Modal
title="新建商户"
open={createOpen}
onOk={() => form.submit()}
onCancel={() => setCreateOpen(false)}
confirmLoading={createMutation.isPending}
>
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
<Form.Item name="merchant_id" label="商户ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="merchant_name" label="商户名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="legal_person" label="法人姓名">
<Input />
</Form.Item>
<Form.Item name="license_no" label="营业执照号">
<Input />
</Form.Item>
</Form>
</Modal>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { Card, Form, Input, Button, DatePicker, Descriptions, Tag, Table, message, Space } from 'antd'
import { useMutation, useQuery } from '@tanstack/react-query'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { reconciliationApi } from '../../api/reconciliation'
import { formatAmount } from '../../utils/format'
import type { ReconciliationException } from '../../types'
const exceptionTypeMap: Record<string, string> = {
MISSING_LOCAL: '本地缺失',
MISSING_CHANNEL: '渠道缺失',
AMOUNT_MISMATCH: '金额不符',
}
export default function ReconciliationPage() {
const [queryForm] = Form.useForm()
const [queryParams, setQueryParams] = useState<{ app_id: string; bill_date: string; channel_code: string } | null>(null)
const triggerMutation = useMutation({
mutationFn: reconciliationApi.trigger,
onSuccess: () => message.success('对账任务已触发'),
})
const { data: reportData, isLoading: reportLoading } = useQuery({
queryKey: ['recon-report', queryParams],
queryFn: () => reconciliationApi.getReport(queryParams!),
enabled: !!queryParams,
})
const report = reportData?.data?.data
const { data: exData } = useQuery({
queryKey: ['recon-exceptions', report?.id],
queryFn: () => reconciliationApi.getExceptions(report!.id),
enabled: !!report?.id && report.exception_count > 0,
})
const exColumns: ColumnsType<ReconciliationException> = [
{ title: '交易号', dataIndex: 'trade_no', width: 160 },
{ title: '渠道流水号', dataIndex: 'channel_bill_no', width: 160 },
{
title: '异常类型',
dataIndex: 'exception_type',
width: 110,
render: (v) => <Tag color="error">{exceptionTypeMap[v] ?? v}</Tag>,
},
{ title: '本地金额', dataIndex: 'local_amount', width: 100, render: (v) => v ? formatAmount(v) : '-' },
{ title: '渠道金额', dataIndex: 'channel_amount', width: 100, render: (v) => v ? formatAmount(v) : '-' },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
]
const exceptions: ReconciliationException[] = exData?.data?.data ?? []
return (
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Card title="对账管理" extra={
<Button
type="primary"
loading={triggerMutation.isPending}
onClick={() => triggerMutation.mutate()}
>
</Button>
}>
<Form
form={queryForm}
layout="inline"
onFinish={(v) =>
setQueryParams({
app_id: v.app_id,
bill_date: dayjs(v.bill_date).format('YYYY-MM-DD'),
channel_code: v.channel_code,
})
}
>
<Form.Item name="app_id" label="应用ID" rules={[{ required: true }]}>
<Input style={{ width: 140 }} />
</Form.Item>
<Form.Item name="channel_code" label="渠道" rules={[{ required: true }]}>
<Input style={{ width: 100 }} placeholder="HEPAY" />
</Form.Item>
<Form.Item name="bill_date" label="账单日期" rules={[{ required: true }]}>
<DatePicker />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={reportLoading}></Button>
</Form.Item>
</Form>
</Card>
{report && (
<Card title="对账报告">
<Descriptions bordered column={3}>
<Descriptions.Item label="账单日期">{report.bill_date}</Descriptions.Item>
<Descriptions.Item label="渠道">{report.channel_code}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={report.status === 'MATCHED' ? 'success' : report.status === 'EXCEPTION' ? 'error' : 'warning'}>
{{ PENDING: '对账中', MATCHED: '已对账', EXCEPTION: '有异常' }[report.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="渠道总笔数">{report.total_count}</Descriptions.Item>
<Descriptions.Item label="渠道总金额">{formatAmount(report.total_amount)}</Descriptions.Item>
<Descriptions.Item label="匹配笔数">{report.matched_count}</Descriptions.Item>
<Descriptions.Item label="异常笔数" span={3}>
<Tag color={report.exception_count > 0 ? 'error' : 'success'}>{report.exception_count}</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
)}
{exceptions.length > 0 && (
<Card title="异常明细">
<Table rowKey="id" columns={exColumns} dataSource={exceptions} scroll={{ x: 800 }} />
</Card>
)}
</Space>
)
}

View File

@@ -0,0 +1,48 @@
import { Table, Tag, Card } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { formatAmount } from '../../utils/format'
import type { RefundOrder, RefundStatus } from '../../types'
const refundStatusMap: Record<RefundStatus, { label: string; color: string }> = {
PENDING: { label: '待处理', color: 'default' },
PROCESSING: { label: '退款中', color: 'processing' },
SUCCESS: { label: '退款成功', color: 'success' },
FAILED: { label: '退款失败', color: 'error' },
}
export default function RefundPage() {
const columns: ColumnsType<RefundOrder> = [
{ title: '退款单号', dataIndex: 'refund_no', width: 160 },
{ title: '交易号', dataIndex: 'trade_no', width: 160 },
{ title: '退款金额', dataIndex: 'refund_amount', width: 100, render: (v) => formatAmount(v) },
{ title: '退款原因', dataIndex: 'reason', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: RefundStatus) => {
const s = refundStatusMap[v]
return <Tag color={s.color}>{s.label}</Tag>
},
},
{
title: '退款时间',
dataIndex: 'refund_time',
width: 160,
render: (v) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 160,
render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
]
return (
<Card>
<Table rowKey="refund_no" columns={columns} dataSource={[]} scroll={{ x: 900 }} />
</Card>
)
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react'
import { Table, Tag, Input, Select, Space, Card } from 'antd'
import { useQuery } from '@tanstack/react-query'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { tradeApi } from '../../api/trade'
import { formatAmount, tradeStatusMap, payMethodMap } from '../../utils/format'
import type { TradeOrder, TradeStatus } from '../../types'
const { Search } = Input
const statusOptions = [
{ label: '全部', value: '' },
{ label: '待支付', value: 'PAYING' },
{ label: '已支付', value: 'PAID' },
{ label: '已关闭', value: 'CLOSED' },
{ label: '已退款', value: 'REFUNDED' },
]
export default function TradePage() {
const [status, setStatus] = useState('')
const [offset, setOffset] = useState(0)
const limit = 20
const { data, isLoading } = useQuery({
queryKey: ['trades', status, offset],
queryFn: () => tradeApi.list({ status, limit, offset }),
})
const columns: ColumnsType<TradeOrder> = [
{ title: '交易号', dataIndex: 'trade_no', width: 180 },
{ title: '商户订单号', dataIndex: 'merchant_order_no', width: 160 },
{ title: '应用', dataIndex: 'app_id', width: 120 },
{
title: '金额',
dataIndex: 'amount',
width: 100,
render: (v) => formatAmount(v),
},
{
title: '支付方式',
dataIndex: 'pay_method',
width: 120,
render: (v) => payMethodMap[v] ?? v,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v: TradeStatus) => {
const s = tradeStatusMap[v]
return s ? <Tag color={s.color}>{s.label}</Tag> : v
},
},
{
title: '支付时间',
dataIndex: 'pay_time',
width: 160,
render: (v) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 160,
render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
]
const list: TradeOrder[] = data?.data?.data?.list ?? []
return (
<Card>
<Space style={{ marginBottom: 16 }}>
<Select
options={statusOptions}
value={status}
onChange={(v) => { setStatus(v); setOffset(0) }}
style={{ width: 120 }}
placeholder="状态筛选"
/>
<Search placeholder="搜索交易号/商户订单号" style={{ width: 260 }} allowClear />
</Space>
<Table
rowKey="trade_no"
columns={columns}
dataSource={list}
loading={isLoading}
scroll={{ x: 1100 }}
pagination={{
pageSize: limit,
current: Math.floor(offset / limit) + 1,
onChange: (page) => setOffset((page - 1) * limit),
}}
/>
</Card>
)
}

147
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,147 @@
// 统一 API 响应格式
export interface ApiResponse<T> {
code: string
message: string
data: T
trace_id?: string
}
export interface PageResult<T> {
list: T[]
limit: number
offset: number
}
// 交易订单
export type TradeStatus =
| 'CREATING'
| 'PAYING'
| 'PAID'
| 'CLOSED'
| 'FAILED'
| 'CREATE_FAILED'
| 'REFUNDED'
export type PayMethod =
| 'WECHAT_JSAPI'
| 'WECHAT_H5'
| 'WECHAT_NATIVE'
| 'WECHAT_MINI'
| 'ALIPAY'
| 'QUICK_PAY'
export interface TradeOrder {
id: number
trade_no: string
merchant_order_no: string
app_id: string
channel_code: string
pay_method: PayMethod
amount: number
subject: string
status: TradeStatus
pay_time?: string
created_at: string
updated_at: string
}
// 退款
export type RefundStatus = 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILED'
export interface RefundOrder {
id: number
refund_no: string
trade_no: string
app_id: string
refund_amount: number
reason: string
status: RefundStatus
refund_time?: string
created_at: string
}
// 接入应用
export interface App {
app_id: string
app_name: string
status: number // 1=启用 0=禁用
created_at: string
updated_at: string
}
export interface CreateAppResult extends App {
app_secret: string
secret_tip: string
}
// 商户
export type MerchantStatus = 'PENDING' | 'ACTIVE' | 'FROZEN' | 'REJECTED'
export type AuditStatus = 'SUBMITTING' | 'REVIEWING' | 'APPROVED' | 'REJECTED'
export interface Merchant {
id: number
merchant_id: string
merchant_name: string
license_no: string
legal_person: string
bank_account: string
channel_merchant_id: string
status: MerchantStatus
created_at: string
}
export interface MerchantApplication {
id: number
application_id: string
merchant_id: string
channel_code: string
audit_status: AuditStatus
reject_reason: string
submitted_at: string
audited_at?: string
}
// 收款匹配
export type MatchStatus = 'MATCHED' | 'PENDING_MANUAL' | 'NAME_DIFF'
export interface PaymentMatchLog {
id: number
account_id: number
trade_no: string
incoming_amount: number
incoming_remark: string
payer_name: string
channel_bill_no: string
match_status: MatchStatus
name_diff: number
match_time?: string
operator: string
created_at: string
}
// 对账
export type ReconciliationStatus = 'PENDING' | 'MATCHED' | 'EXCEPTION'
export interface ReconciliationReport {
id: number
app_id: string
channel_code: string
bill_date: string
total_count: number
total_amount: number
matched_count: number
exception_count: number
status: ReconciliationStatus
created_at: string
}
export interface ReconciliationException {
id: number
report_id: number
trade_no: string
channel_bill_no: string
exception_type: 'MISSING_LOCAL' | 'MISSING_CHANNEL' | 'AMOUNT_MISMATCH'
local_amount: number
channel_amount: number
remark: string
}

View File

@@ -0,0 +1,36 @@
// 金额:分 → 元
export const formatAmount = (fen: number): string => {
return `¥${(fen / 100).toFixed(2)}`
}
export const tradeStatusMap: Record<string, { label: string; color: string }> = {
CREATING: { label: '创建中', color: 'processing' },
PAYING: { label: '待支付', color: 'warning' },
PAID: { label: '已支付', color: 'success' },
CLOSED: { label: '已关闭', color: 'default' },
FAILED: { label: '失败', color: 'error' },
CREATE_FAILED: { label: '创建失败', color: 'error' },
REFUNDED: { label: '已退款', color: 'default' },
}
export const merchantStatusMap: Record<string, { label: string; color: string }> = {
PENDING: { label: '待审核', color: 'warning' },
ACTIVE: { label: '正常', color: 'success' },
FROZEN: { label: '冻结', color: 'error' },
REJECTED: { label: '已拒绝', color: 'error' },
}
export const matchStatusMap: Record<string, { label: string; color: string }> = {
MATCHED: { label: '已匹配', color: 'success' },
PENDING_MANUAL: { label: '待人工确认', color: 'warning' },
NAME_DIFF: { label: '名称差异', color: 'orange' },
}
export const payMethodMap: Record<string, string> = {
WECHAT_JSAPI: '微信 JSAPI',
WECHAT_H5: '微信 H5',
WECHAT_NATIVE: '微信 Native',
WECHAT_MINI: '微信小程序',
ALIPAY: '支付宝',
QUICK_PAY: '银行卡快捷',
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
},
},
},
})