draft
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
4630
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
56
frontend/src/App.tsx
Normal 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
21
frontend/src/api/app.ts
Normal 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
13
frontend/src/api/auth.ts
Normal 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
|
||||
}
|
||||
39
frontend/src/api/client.ts
Normal file
39
frontend/src/api/client.ts
Normal 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
10
frontend/src/api/match.ts
Normal 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),
|
||||
}
|
||||
25
frontend/src/api/merchant.ts
Normal file
25
frontend/src/api/merchant.ts
Normal 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`),
|
||||
}
|
||||
13
frontend/src/api/reconciliation.ts
Normal file
13
frontend/src/api/reconciliation.ts
Normal 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
20
frontend/src/api/trade.ts
Normal 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 }),
|
||||
}
|
||||
53
frontend/src/components/layout/AppLayout.tsx
Normal file
53
frontend/src/components/layout/AppLayout.tsx
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
173
frontend/src/pages/app/index.tsx
Normal file
173
frontend/src/pages/app/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
53
frontend/src/pages/dashboard/index.tsx
Normal file
53
frontend/src/pages/dashboard/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/pages/login/index.tsx
Normal file
58
frontend/src/pages/login/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
frontend/src/pages/match/index.tsx
Normal file
113
frontend/src/pages/match/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
frontend/src/pages/merchant/index.tsx
Normal file
124
frontend/src/pages/merchant/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
frontend/src/pages/reconciliation/index.tsx
Normal file
119
frontend/src/pages/reconciliation/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/pages/refund/index.tsx
Normal file
48
frontend/src/pages/refund/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
frontend/src/pages/trade/index.tsx
Normal file
97
frontend/src/pages/trade/index.tsx
Normal 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
147
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
36
frontend/src/utils/format.ts
Normal file
36
frontend/src/utils/format.ts
Normal 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: '银行卡快捷',
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user