✨ 新增几大中心功能
This commit is contained in:
1317
web-admin/package-lock.json
generated
1317
web-admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1",
|
||||
"ua-parser-js": "^2.0.9",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
web-admin/src/assets/avatar_user.jpg
Normal file
BIN
web-admin/src/assets/avatar_user.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,393 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Col, DatePicker, Descriptions, Divider, Drawer, Form, Input, Row, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { appAssetTransactionsAdminApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { AppAssetTransactionRecord } from '@/types/system'
|
||||
|
||||
type Search = {
|
||||
username?: string
|
||||
userId?: string
|
||||
assetType?: string
|
||||
rechargeType?: string
|
||||
operatorUserId?: string
|
||||
keyword?: string
|
||||
range?: [dayjs.Dayjs, dayjs.Dayjs]
|
||||
}
|
||||
|
||||
function formatLiToYuan(li?: number) {
|
||||
const n = typeof li === 'number' && Number.isFinite(li) ? li : 0
|
||||
return (n / 1000).toFixed(3)
|
||||
}
|
||||
|
||||
function parsePositiveInt(input?: string) {
|
||||
const raw = (input ?? '').trim()
|
||||
if (!raw) return undefined
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined
|
||||
return Math.floor(n)
|
||||
}
|
||||
|
||||
function formatAssetType(assetType?: string) {
|
||||
if (assetType === 'account') return '账户余额'
|
||||
if (assetType === 'game_coin') return '游戏币'
|
||||
return assetType ? String(assetType) : '-'
|
||||
}
|
||||
|
||||
function formatRechargeType(rechargeType?: string) {
|
||||
const map: Record<string, string> = {
|
||||
offline: '线下充值',
|
||||
online_not_arrived: '线上未到账',
|
||||
activity_subsidy: '活动补贴',
|
||||
manual_adjustment: '人工调整',
|
||||
}
|
||||
return rechargeType ? map[String(rechargeType)] ?? String(rechargeType) : '-'
|
||||
}
|
||||
|
||||
export function AppAssetTransactionsPage() {
|
||||
const [form] = Form.useForm<Search>()
|
||||
const [rows, setRows] = useState<AppAssetTransactionRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [drawerLoading, setDrawerLoading] = useState(false)
|
||||
const [drawerRecord, setDrawerRecord] = useState<AppAssetTransactionRecord | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const range = values.range
|
||||
const startAt = range?.[0]?.toISOString()
|
||||
const endAt = range?.[1]?.toISOString()
|
||||
const res = await appAssetTransactionsAdminApi.getList({
|
||||
page,
|
||||
pageSize,
|
||||
userId: parsePositiveInt(values.userId),
|
||||
username: values.username?.trim() || undefined,
|
||||
assetType: values.assetType,
|
||||
rechargeType: values.rechargeType,
|
||||
operatorUserId: parsePositiveInt(values.operatorUserId),
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
...(startAt ? { startAt } : {}),
|
||||
...(endAt ? { endAt } : {}),
|
||||
})
|
||||
setRows(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const openDetail = async (record: AppAssetTransactionRecord) => {
|
||||
setDrawerOpen(true)
|
||||
setDrawerLoading(true)
|
||||
try {
|
||||
const detail = await appAssetTransactionsAdminApi.getById(record.id)
|
||||
setDrawerRecord(detail.data)
|
||||
} finally {
|
||||
setDrawerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AppAssetTransactionRecord> = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||
{ title: '玩家ID', dataIndex: 'appUserId', width: 100 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||
{
|
||||
title: '资产类型',
|
||||
dataIndex: 'assetType',
|
||||
width: 120,
|
||||
render: (v) => formatAssetType(v),
|
||||
},
|
||||
{
|
||||
title: '变动类型',
|
||||
dataIndex: 'rechargeType',
|
||||
width: 160,
|
||||
render: (v) => formatRechargeType(v),
|
||||
},
|
||||
{
|
||||
title: '变动(元)',
|
||||
dataIndex: 'deltaLi',
|
||||
width: 120,
|
||||
render: (v: number) => {
|
||||
const yuan = formatLiToYuan(v)
|
||||
const color = v > 0 ? '#1677ff' : v < 0 ? '#cf1322' : undefined
|
||||
return <span style={{ color }}>{yuan}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动前(元)',
|
||||
dataIndex: 'beforeLi',
|
||||
width: 120,
|
||||
render: (v: number) => formatLiToYuan(v),
|
||||
},
|
||||
{
|
||||
title: '变动后(元)',
|
||||
dataIndex: 'afterLi',
|
||||
width: 120,
|
||||
render: (v: number) => formatLiToYuan(v),
|
||||
},
|
||||
{
|
||||
title: '原因',
|
||||
dataIndex: 'reason',
|
||||
ellipsis: true,
|
||||
render: (v: string) => (v ? <Tooltip title={v}>{v}</Tooltip> : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
width: 160,
|
||||
render: (_, r) => (r.operatorName ? `${r.operatorName}(#${r.operatorUserId})` : `#${r.operatorUserId}`),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
width: 190,
|
||||
render: (_, r) => formatDate(r.createdAt),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 110,
|
||||
fixed: 'right',
|
||||
render: (_, r) => (
|
||||
<Button type="link" style={{ padding: 0 }} onClick={() => openDetail(r)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={() => {
|
||||
if (page !== 1) {
|
||||
setPage(1)
|
||||
return
|
||||
}
|
||||
load()
|
||||
}}
|
||||
>
|
||||
<Row gutter={[12, 8]}>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="userId" label="玩家ID" style={{ marginBottom: 0 }}>
|
||||
<Input allowClear placeholder="输入玩家ID" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="username" label="用户名" style={{ marginBottom: 0 }}>
|
||||
<Input allowClear placeholder="输入用户名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="assetType" label="资产类型" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
options={[
|
||||
{ label: '账户余额', value: 'account' },
|
||||
{ label: '游戏币', value: 'game_coin' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="rechargeType" label="变动类型" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
options={[
|
||||
{ label: '线下充值', value: 'offline' },
|
||||
{ label: '线上未到账', value: 'online_not_arrived' },
|
||||
{ label: '活动补贴', value: 'activity_subsidy' },
|
||||
{ label: '人工调整', value: 'manual_adjustment' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="operatorUserId" label="操作人ID" style={{ marginBottom: 0 }}>
|
||||
<Input allowClear placeholder="输入操作人ID" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="keyword" label="原因关键字" style={{ marginBottom: 0 }}>
|
||||
<Input allowClear placeholder="例如:补贴/冲正/误操作" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12} xl={8}>
|
||||
<Form.Item name="range" label="时间范围" style={{ marginBottom: 0 }}>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
presets={[
|
||||
{ label: '近24小时', value: [dayjs().subtract(24, 'hour'), dayjs()] },
|
||||
{ label: '近7天', value: [dayjs().subtract(7, 'day'), dayjs()] },
|
||||
{ label: '近30天', value: [dayjs().subtract(30, 'day'), dayjs()] },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12} xl={4}>
|
||||
<Form.Item label=" " style={{ marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 1600 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
width={560}
|
||||
title={drawerRecord ? `流水详情 #${drawerRecord.id}` : '流水详情'}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false)
|
||||
setDrawerRecord(null)
|
||||
}}
|
||||
>
|
||||
{drawerLoading ? (
|
||||
<Typography.Paragraph>加载中...</Typography.Paragraph>
|
||||
) : drawerRecord ? (
|
||||
(() => {
|
||||
const isPlus = drawerRecord.deltaLi >= 0
|
||||
const deltaYuan = formatLiToYuan(drawerRecord.deltaLi)
|
||||
const beforeYuan = formatLiToYuan(drawerRecord.beforeLi)
|
||||
const afterYuan = formatLiToYuan(drawerRecord.afterLi)
|
||||
const deltaColor = drawerRecord.deltaLi > 0 ? '#1677ff' : drawerRecord.deltaLi < 0 ? '#cf1322' : undefined
|
||||
const operator = drawerRecord.operatorName ? `${drawerRecord.operatorName}(#${drawerRecord.operatorUserId})` : `#${drawerRecord.operatorUserId}`
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card size="small" styles={{ body: { padding: 12 } }}>
|
||||
<Row gutter={[12, 10]}>
|
||||
<Col span={24}>
|
||||
<Space size={8} wrap>
|
||||
<Tag color="default">玩家</Tag>
|
||||
<Typography.Text strong>
|
||||
{drawerRecord.username}(#{drawerRecord.appUserId})
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Row gutter={[12, 10]}>
|
||||
<Col span={12}>
|
||||
<Typography.Text type="secondary">资产类型</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text>{formatAssetType(drawerRecord.assetType)}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text type="secondary">变动类型</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text>{formatRechargeType(drawerRecord.rechargeType)}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text type="secondary">变动金额</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text strong style={{ color: deltaColor, fontSize: 18 }}>
|
||||
{isPlus ? '+' : ''}
|
||||
{deltaYuan}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> 元</Typography.Text>
|
||||
<Tag color={drawerRecord.deltaLi > 0 ? 'blue' : drawerRecord.deltaLi < 0 ? 'red' : 'default'} style={{ marginInlineStart: 8 }}>
|
||||
{drawerRecord.deltaLi > 0 ? '增加' : drawerRecord.deltaLi < 0 ? '减少' : '不变'}
|
||||
</Tag>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text type="secondary">变动前 → 变动后</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text>
|
||||
{beforeYuan} → {afterYuan}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">(元)</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
bordered
|
||||
styles={{ label: { width: 120, color: 'rgba(0,0,0,0.45)' } }}
|
||||
>
|
||||
<Descriptions.Item label="原因">
|
||||
<Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{drawerRecord.reason || '-'}
|
||||
</Typography.Paragraph>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人">{operator}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{formatDate(drawerRecord.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="单位说明">
|
||||
<Tag style={{ marginInlineEnd: 0 }}>1 元 = 1000 厘</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<Typography.Paragraph>暂无数据</Typography.Paragraph>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
207
web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx
Normal file
207
web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { UAParser } from 'ua-parser-js'
|
||||
import { appLoginLogAdminApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { AppLoginLogRecord } from '@/types/system'
|
||||
|
||||
type Search = {
|
||||
username?: string
|
||||
ip?: string
|
||||
status?: boolean
|
||||
range?: [dayjs.Dayjs, dayjs.Dayjs]
|
||||
}
|
||||
|
||||
function formatClientInfo(rawUa?: string) {
|
||||
const ua = (rawUa ?? '').trim()
|
||||
if (!ua) return { summary: '-', detail: '' }
|
||||
const parsed = new UAParser(ua).getResult()
|
||||
const browser = [parsed.browser.name, parsed.browser.version].filter(Boolean).join(' ')
|
||||
const os = [parsed.os.name, parsed.os.version].filter(Boolean).join(' ')
|
||||
const device = [parsed.device.vendor, parsed.device.model, parsed.device.type].filter(Boolean).join(' ')
|
||||
const summary = [browser || '-', os || '-', device].filter(Boolean).join(' / ')
|
||||
return { summary, detail: ua }
|
||||
}
|
||||
|
||||
export function AppLoginLogsPage() {
|
||||
const [form] = Form.useForm<Search>()
|
||||
const [rows, setRows] = useState<AppLoginLogRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const range = values.range
|
||||
const startAt = range?.[0]?.toISOString()
|
||||
const endAt = range?.[1]?.toISOString()
|
||||
const res = await appLoginLogAdminApi.getList({
|
||||
page,
|
||||
pageSize,
|
||||
username: values.username,
|
||||
ip: values.ip,
|
||||
...(typeof values.status === 'boolean' ? { status: values.status } : {}),
|
||||
...(startAt ? { startAt } : {}),
|
||||
...(endAt ? { endAt } : {}),
|
||||
})
|
||||
setRows(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns: ColumnsType<AppLoginLogRecord> = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||
{ title: 'IP', dataIndex: 'ip', width: 160 },
|
||||
{
|
||||
title: '参考地址',
|
||||
dataIndex: 'referer',
|
||||
ellipsis: true,
|
||||
render: (v?: string) => (v ? <Tooltip title={v}>{v}</Tooltip> : '-'),
|
||||
},
|
||||
{
|
||||
title: '客户端信息',
|
||||
dataIndex: 'userAgent',
|
||||
ellipsis: true,
|
||||
width: 280,
|
||||
render: (v?: string) => {
|
||||
const info = formatClientInfo(v)
|
||||
return info.detail ? (
|
||||
<Tooltip title={info.detail}>
|
||||
<span>{info.summary}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{info.summary}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_, r) => <Tag color={r.status ? 'green' : 'red'}>{r.status ? '成功' : '失败'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '失败原因',
|
||||
dataIndex: 'errorMessage',
|
||||
ellipsis: true,
|
||||
render: (v: string | undefined, r) => (r.status ? '-' : v || '-'),
|
||||
},
|
||||
{
|
||||
title: '登录时间',
|
||||
width: 190,
|
||||
render: (_, r) => formatDate(r.createdAt),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
登录日志
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
用于审计玩家登录行为(含失败原因、IP、参考地址与客户端信息)。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
onFinish={() => {
|
||||
if (page !== 1) {
|
||||
setPage(1)
|
||||
return
|
||||
}
|
||||
load()
|
||||
}}
|
||||
>
|
||||
<Form.Item name="username" label="用户名">
|
||||
<Input allowClear placeholder="输入用户名" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="ip" label="IP">
|
||||
<Input allowClear placeholder="输入IP" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '成功', value: true },
|
||||
{ label: '失败', value: false },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="时间范围">
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
allowClear
|
||||
style={{ width: 360 }}
|
||||
presets={[
|
||||
{ label: '近1小时', value: [dayjs().subtract(1, 'hour'), dayjs()] },
|
||||
{ label: '近24小时', value: [dayjs().subtract(24, 'hour'), dayjs()] },
|
||||
{ label: '近7天', value: [dayjs().subtract(7, 'day'), dayjs()] },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
576
web-admin/src/features/appPlayers/AppPlayersPage.tsx
Normal file
576
web-admin/src/features/appPlayers/AppPlayersPage.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Descriptions, Drawer, Form, Input, Modal, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { appPlayersAdminApi } from '@/lib/api'
|
||||
import type { AppPlayerOverview, AppPlayerRecord } from '@/types/system'
|
||||
|
||||
type Search = {
|
||||
keyword?: string
|
||||
isOnline?: boolean
|
||||
}
|
||||
|
||||
type EditFormValues = {
|
||||
enable: boolean
|
||||
welcomePhrase?: string
|
||||
}
|
||||
|
||||
type ResetPasswordValues = {
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
type RechargeValues = {
|
||||
rechargeType: 'offline' | 'online_not_arrived' | 'activity_subsidy' | 'manual_adjustment'
|
||||
assetType: 'account' | 'game_coin'
|
||||
deltaYuan: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
function formatLi(li?: number) {
|
||||
const n = typeof li === 'number' && Number.isFinite(li) ? li : 0
|
||||
return (n / 1000).toFixed(3)
|
||||
}
|
||||
|
||||
function parseDeltaLiFromYuan(input: string) {
|
||||
const trimmed = (input || '').trim()
|
||||
if (!trimmed) return null
|
||||
const n = Number(trimmed)
|
||||
if (!Number.isFinite(n)) return null
|
||||
return Math.round(n * 1000)
|
||||
}
|
||||
|
||||
export function AppPlayersPage() {
|
||||
const [form] = Form.useForm<Search>()
|
||||
const [createForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm<EditFormValues>()
|
||||
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>()
|
||||
const [rechargeForm] = Form.useForm<RechargeValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState<AppPlayerRecord[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<AppPlayerRecord | null>(null)
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [resetRecord, setResetRecord] = useState<AppPlayerRecord | null>(null)
|
||||
const [rechargeOpen, setRechargeOpen] = useState(false)
|
||||
const [recharging, setRecharging] = useState(false)
|
||||
const [rechargeRecord, setRechargeRecord] = useState<AppPlayerRecord | null>(null)
|
||||
const rechargeType = Form.useWatch('rechargeType', rechargeForm)
|
||||
|
||||
const [overviewOpen, setOverviewOpen] = useState(false)
|
||||
const [overviewLoading, setOverviewLoading] = useState(false)
|
||||
const [overview, setOverview] = useState<AppPlayerOverview | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const res = await appPlayersAdminApi.getList({
|
||||
page,
|
||||
pageSize,
|
||||
...values,
|
||||
})
|
||||
setItems(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns: ColumnsType<AppPlayerRecord> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||
{ title: '昵称', dataIndex: 'nickName', width: 160 },
|
||||
{
|
||||
title: '账户余额(元)',
|
||||
width: 140,
|
||||
render: (_, record) => <span>{formatLi(record.accountBalanceLi)}</span>,
|
||||
},
|
||||
{
|
||||
title: '游戏币(元)',
|
||||
width: 140,
|
||||
render: (_, record) => <span>{formatLi(record.gameCoinBalanceLi)}</span>,
|
||||
},
|
||||
{
|
||||
title: '在线',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Tag color={record.isOnline ? 'green' : 'default'}>{record.isOnline ? '在线' : '离线'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 190 },
|
||||
{ title: '最后活跃', dataIndex: 'lastActiveAt', width: 190 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<Switch
|
||||
checked={record.enable === 1}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="封禁"
|
||||
onChange={async () => {
|
||||
await appPlayersAdminApi.toggleEnable(record.id)
|
||||
message.success(record.enable === 1 ? '已封禁' : '已解封')
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 370,
|
||||
render: (_, record) => (
|
||||
<Space size={8}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#722ed1' }}
|
||||
onClick={async () => {
|
||||
setOverviewOpen(true)
|
||||
setOverviewLoading(true)
|
||||
try {
|
||||
const res = await appPlayersAdminApi.getOverview(record.id)
|
||||
setOverview(res.data)
|
||||
} finally {
|
||||
setOverviewLoading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#13c2c2' }}
|
||||
onClick={() => {
|
||||
setRechargeRecord(record)
|
||||
rechargeForm.resetFields()
|
||||
rechargeForm.setFieldsValue({ rechargeType: 'offline', assetType: 'account' })
|
||||
setRechargeOpen(true)
|
||||
}}
|
||||
>
|
||||
充值
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#1677ff' }}
|
||||
onClick={async () => {
|
||||
setEditingRecord(record)
|
||||
const detail = await appPlayersAdminApi.getById(record.id)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({
|
||||
enable: detail.data.enable === 1,
|
||||
welcomePhrase: detail.data.welcomePhrase ?? '',
|
||||
})
|
||||
setEditOpen(true)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#fa8c16' }}
|
||||
onClick={() => {
|
||||
setResetRecord(record)
|
||||
resetPasswordForm.resetFields()
|
||||
setResetOpen(true)
|
||||
}}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#cf1322' }}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认删除该玩家?',
|
||||
content: `用户名:${record.username}(软删除)`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await appPlayersAdminApi.delete(record.id)
|
||||
message.success('已删除')
|
||||
load()
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
onFinish={() => {
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', gap: 12 }}>
|
||||
<Space wrap>
|
||||
<Form.Item name="keyword">
|
||||
<Input placeholder="输入用户名关键字" allowClear style={{ width: 320 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isOnline">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="在线状态"
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '在线', value: true },
|
||||
{ label: '离线', value: false },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
createForm.resetFields()
|
||||
createForm.setFieldsValue({ enable: true })
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
新建玩家
|
||||
</Button>
|
||||
<Button onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="新建玩家"
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
confirmLoading={creating}
|
||||
onOk={async () => {
|
||||
const values = await createForm.validateFields()
|
||||
setCreating(true)
|
||||
try {
|
||||
await appPlayersAdminApi.create({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
enable: values.enable,
|
||||
nickName: values.nickName,
|
||||
welcomePhrase: values.welcomePhrase,
|
||||
})
|
||||
message.success('玩家已创建')
|
||||
setCreateOpen(false)
|
||||
load()
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3, message: '至少 3 位' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="登录密码" rules={[{ required: true, min: 6, message: '至少 6 位' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="nickName" label="昵称">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="welcomePhrase" label="安全欢迎词">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enable" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={editOpen}
|
||||
title="编辑玩家"
|
||||
onCancel={() => {
|
||||
setEditOpen(false)
|
||||
setEditingRecord(null)
|
||||
}}
|
||||
confirmLoading={editing}
|
||||
onOk={async () => {
|
||||
if (!editingRecord) return
|
||||
const values = await editForm.validateFields()
|
||||
setEditing(true)
|
||||
try {
|
||||
await appPlayersAdminApi.update(editingRecord.id, {
|
||||
enable: values.enable,
|
||||
welcomePhrase: values.welcomePhrase ?? '',
|
||||
})
|
||||
message.success('已更新')
|
||||
setEditOpen(false)
|
||||
setEditingRecord(null)
|
||||
load()
|
||||
} finally {
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="enable" label="启用" valuePropName="checked" rules={[{ required: true }]}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="welcomePhrase" label="安全欢迎词">
|
||||
<Input.TextArea rows={3} allowClear />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={resetOpen}
|
||||
title="重置密码"
|
||||
onCancel={() => {
|
||||
setResetOpen(false)
|
||||
setResetRecord(null)
|
||||
}}
|
||||
confirmLoading={resetting}
|
||||
onOk={async () => {
|
||||
if (!resetRecord) return
|
||||
const values = await resetPasswordForm.validateFields()
|
||||
setResetting(true)
|
||||
try {
|
||||
await appPlayersAdminApi.resetPassword(resetRecord.id, { newPassword: values.newPassword })
|
||||
message.success('密码已重置')
|
||||
setResetOpen(false)
|
||||
setResetRecord(null)
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={resetPasswordForm} layout="vertical">
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '至少 6 位' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={rechargeOpen}
|
||||
title={`充值${rechargeRecord ? ` · ${rechargeRecord.username}` : ''}`}
|
||||
onCancel={() => {
|
||||
setRechargeOpen(false)
|
||||
setRechargeRecord(null)
|
||||
}}
|
||||
confirmLoading={recharging}
|
||||
onOk={async () => {
|
||||
if (!rechargeRecord) return
|
||||
const values = await rechargeForm.validateFields()
|
||||
const deltaLi = parseDeltaLiFromYuan(values.deltaYuan)
|
||||
if (deltaLi === null || deltaLi === 0) {
|
||||
message.error('请输入有效的变动金额(支持正负,最多 3 位小数)')
|
||||
return
|
||||
}
|
||||
setRecharging(true)
|
||||
try {
|
||||
await appPlayersAdminApi.recharge(rechargeRecord.id, {
|
||||
assetType: values.assetType,
|
||||
rechargeType: values.rechargeType,
|
||||
deltaLi,
|
||||
reason: values.reason.trim(),
|
||||
})
|
||||
message.success('充值已提交')
|
||||
setRechargeOpen(false)
|
||||
setRechargeRecord(null)
|
||||
load()
|
||||
} finally {
|
||||
setRecharging(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={rechargeForm}
|
||||
layout="vertical"
|
||||
initialValues={{ rechargeType: 'offline', assetType: 'account' }}
|
||||
onValuesChange={(changed) => {
|
||||
if (changed.rechargeType === 'activity_subsidy') {
|
||||
rechargeForm.setFieldsValue({ assetType: 'game_coin' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="rechargeType" label="充值类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '线下充值', value: 'offline' },
|
||||
{ label: '线上充值未到账', value: 'online_not_arrived' },
|
||||
{ label: '活动补贴(仅游戏币)', value: 'activity_subsidy' },
|
||||
{ label: '其他/人工调整', value: 'manual_adjustment' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="assetType" label="资产类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={rechargeType === 'activity_subsidy'}
|
||||
options={[
|
||||
{ label: '账户余额', value: 'account' },
|
||||
{ label: '游戏币余额', value: 'game_coin' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="deltaYuan"
|
||||
label="变动金额(元)"
|
||||
rules={[
|
||||
{ required: true, message: '请输入变动金额' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const parsed = parseDeltaLiFromYuan(String(value ?? ''))
|
||||
return parsed === null ? Promise.reject(new Error('请输入数字(支持正负与 3 位小数)')) : Promise.resolve()
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:10.000 或 -3.500" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因" rules={[{ required: true, message: '请输入原因' }]}>
|
||||
<Input.TextArea rows={3} placeholder="必填,用于审计" />
|
||||
</Form.Item>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
单位换算:1 元 = 1000 厘。明细会写入后台流水用于审计。
|
||||
</Typography.Paragraph>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
open={overviewOpen}
|
||||
width={720}
|
||||
title={overview ? `玩家详情 · ${overview.user.username}(#${overview.user.id})` : '玩家详情'}
|
||||
onClose={() => {
|
||||
setOverviewOpen(false)
|
||||
setOverview(null)
|
||||
}}
|
||||
>
|
||||
{overviewLoading ? (
|
||||
<Typography.Paragraph>加载中...</Typography.Paragraph>
|
||||
) : overview ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="用户名">{overview.user.username}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{overview.user.nickName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={overview.user.enable === 1 ? 'green' : 'red'}>{overview.user.enable === 1 ? '启用' : '封禁'}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="在线">
|
||||
<Tag color={overview.activity.isOnline ? 'green' : 'default'}>
|
||||
{overview.activity.isOnline ? '在线' : '离线'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账户余额(元)">{formatLi(overview.asset.accountBalanceLi)}</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏币(元)">{formatLi(overview.asset.gameCoinBalanceLi)}</Descriptions.Item>
|
||||
<Descriptions.Item label="最后登录">{overview.activity.lastLoginAt || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="最后活跃">{overview.activity.lastActiveAt || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="最后活跃IP">{overview.activity.lastActiveIp || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="邀请人">
|
||||
{overview.invite.inviterUsername ? `${overview.invite.inviterUsername}(#${overview.invite.inviterUserId})` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已邀请人数">{overview.invite.invitedCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="未使用邀请码" span={2}>
|
||||
{overview.invite.hasUnusedCode
|
||||
? `尾号 ${overview.invite.unusedCodeLast4 || '-'},过期时间:${overview.invite.unusedExpiresAt || '-'}`
|
||||
: '无'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Card size="small" title="最近登录日志(20条)">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '时间', width: 170, dataIndex: 'createdAt' },
|
||||
{ title: 'IP', width: 140, dataIndex: 'ip' },
|
||||
{
|
||||
title: '状态',
|
||||
width: 90,
|
||||
render: (_, r) => (
|
||||
<Tag color={r.status ? 'green' : 'red'}>{r.status ? '成功' : '失败'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '原因', dataIndex: 'errorMessage' },
|
||||
]}
|
||||
dataSource={overview.recentLoginLogs}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="最近资产流水(20条)">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '时间', width: 170, dataIndex: 'createdAt' },
|
||||
{ title: '资产', width: 100, dataIndex: 'assetType' },
|
||||
{
|
||||
title: '变动(元)',
|
||||
width: 120,
|
||||
render: (_, r) => formatLi(r.deltaLi),
|
||||
},
|
||||
{ title: '原因', dataIndex: 'reason' },
|
||||
]}
|
||||
dataSource={overview.recentTx}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Paragraph>暂无数据</Typography.Paragraph>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Card, Col, Form, InputNumber, Radio, Row, Space, Switch, Typography, message } from 'antd'
|
||||
import { sysParamsApi } from '@/lib/api'
|
||||
|
||||
const policyParamKeys = {
|
||||
transferEnabled: 'asset.transfer_enabled',
|
||||
transferMode: 'asset.transfer_mode', // warn_only|guardrail
|
||||
transferFeeBps: 'asset.transfer_fee_bps', // 0-10000
|
||||
transferDailyLimitLi: 'asset.transfer_daily_limit_li', // 0 means unlimited
|
||||
transferDirection: 'asset.transfer_direction', // two_way|balance_to_coin|coin_to_balance
|
||||
withdrawEnabled: 'asset.withdraw_enabled',
|
||||
withdrawFeeBps: 'asset.withdraw_fee_bps',
|
||||
withdrawDailyLimitLi: 'asset.withdraw_daily_limit_li',
|
||||
rewardDestination: 'asset.reward_destination', // account|game_coin
|
||||
} as const
|
||||
|
||||
type Values = {
|
||||
transferEnabled: boolean
|
||||
transferMode: 'warn_only' | 'guardrail'
|
||||
transferFeeBps: number
|
||||
transferDailyLimitLi: number
|
||||
transferDirection: 'two_way' | 'balance_to_coin' | 'coin_to_balance'
|
||||
withdrawEnabled: boolean
|
||||
withdrawFeeBps: number
|
||||
withdrawDailyLimitLi: number
|
||||
rewardDestination: 'account' | 'game_coin'
|
||||
}
|
||||
|
||||
function toBool(v?: string) {
|
||||
return String(v ?? '') === '1'
|
||||
}
|
||||
|
||||
function toInt(v?: string, fallback = 0) {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
export function AssetPolicyCenterPage() {
|
||||
const [form] = Form.useForm<Values>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [records, setRecords] = useState<Record<string, { ID: number; key: string } | undefined>>({})
|
||||
|
||||
const warnings = useMemo(() => {
|
||||
const v = form.getFieldsValue()
|
||||
const list: string[] = []
|
||||
if (v.transferEnabled && v.transferMode === 'warn_only') {
|
||||
list.push('互转已开启且为“仅提示不拦截”:活动赠送游戏币可能通过互转进入可提现资金池。')
|
||||
}
|
||||
if (v.withdrawEnabled && v.withdrawDailyLimitLi === 0) {
|
||||
list.push('提现已开启且未设置日限额(0=不限制):建议至少配置日限额以降低风险。')
|
||||
}
|
||||
return list
|
||||
}, [form])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const keys = Object.values(policyParamKeys)
|
||||
const res = await Promise.all(keys.map((k) => sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: k })))
|
||||
const nextRecords: Record<string, { ID: number; key: string } | undefined> = {}
|
||||
const values: Partial<Values> = {}
|
||||
|
||||
keys.forEach((k, i) => {
|
||||
const list = res[i].data.list as Array<{ ID: number; key: string; value: string }>
|
||||
const exact = list.find((it) => it.key === k) || list[0]
|
||||
if (exact) nextRecords[k] = { ID: exact.ID, key: exact.key }
|
||||
|
||||
switch (k) {
|
||||
case policyParamKeys.transferEnabled:
|
||||
values.transferEnabled = toBool(exact?.value)
|
||||
break
|
||||
case policyParamKeys.transferMode:
|
||||
values.transferMode = (exact?.value as Values['transferMode']) || 'warn_only'
|
||||
break
|
||||
case policyParamKeys.transferFeeBps:
|
||||
values.transferFeeBps = toInt(exact?.value, 0)
|
||||
break
|
||||
case policyParamKeys.transferDailyLimitLi:
|
||||
values.transferDailyLimitLi = toInt(exact?.value, 0)
|
||||
break
|
||||
case policyParamKeys.transferDirection:
|
||||
values.transferDirection = (exact?.value as Values['transferDirection']) || 'two_way'
|
||||
break
|
||||
case policyParamKeys.withdrawEnabled:
|
||||
values.withdrawEnabled = toBool(exact?.value)
|
||||
break
|
||||
case policyParamKeys.withdrawFeeBps:
|
||||
values.withdrawFeeBps = toInt(exact?.value, 0)
|
||||
break
|
||||
case policyParamKeys.withdrawDailyLimitLi:
|
||||
values.withdrawDailyLimitLi = toInt(exact?.value, 0)
|
||||
break
|
||||
case policyParamKeys.rewardDestination:
|
||||
values.rewardDestination = (exact?.value as Values['rewardDestination']) || 'account'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
setRecords(nextRecords)
|
||||
form.setFieldsValue({
|
||||
transferEnabled: values.transferEnabled ?? false,
|
||||
transferMode: values.transferMode ?? 'warn_only',
|
||||
transferFeeBps: values.transferFeeBps ?? 0,
|
||||
transferDailyLimitLi: values.transferDailyLimitLi ?? 0,
|
||||
transferDirection: values.transferDirection ?? 'two_way',
|
||||
withdrawEnabled: values.withdrawEnabled ?? false,
|
||||
withdrawFeeBps: values.withdrawFeeBps ?? 0,
|
||||
withdrawDailyLimitLi: values.withdrawDailyLimitLi ?? 0,
|
||||
rewardDestination: values.rewardDestination ?? 'account',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const upsert = async (key: string, payload: { name: string; desc: string; value: string }) => {
|
||||
const existing = records[key]
|
||||
if (existing?.ID) {
|
||||
await sysParamsApi.updateParam({ ID: existing.ID, key, ...payload })
|
||||
} else {
|
||||
await sysParamsApi.createParam({ key, ...payload })
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const v = await form.validateFields()
|
||||
|
||||
const tasks: Array<Promise<any>> = []
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.transferEnabled, { name: '互转开关', desc: '0/1(1=开启互转)', value: v.transferEnabled ? '1' : '0' })
|
||||
)
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.transferMode, {
|
||||
name: '互转风险模式',
|
||||
desc: 'warn_only|guardrail(仅提示/护栏模式)',
|
||||
value: v.transferMode,
|
||||
})
|
||||
)
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.transferFeeBps, {
|
||||
name: '互转手续费(万分比)',
|
||||
desc: '0-10000(例如 50=0.5%)',
|
||||
value: String(v.transferFeeBps ?? 0),
|
||||
})
|
||||
)
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.transferDailyLimitLi, {
|
||||
name: '互转日限额(厘)',
|
||||
desc: '0 表示不限制(单位:厘)',
|
||||
value: String(v.transferDailyLimitLi ?? 0),
|
||||
})
|
||||
)
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.transferDirection, {
|
||||
name: '互转方向',
|
||||
desc: 'two_way|balance_to_coin|coin_to_balance',
|
||||
value: v.transferDirection,
|
||||
})
|
||||
)
|
||||
|
||||
tasks.push(upsert(policyParamKeys.withdrawEnabled, { name: '提现开关', desc: '0/1(1=开启提现)', value: v.withdrawEnabled ? '1' : '0' }))
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.withdrawFeeBps, {
|
||||
name: '提现手续费(万分比)',
|
||||
desc: '0-10000(例如 100=1%)',
|
||||
value: String(v.withdrawFeeBps ?? 0),
|
||||
})
|
||||
)
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.withdrawDailyLimitLi, {
|
||||
name: '提现日限额(厘)',
|
||||
desc: '0 表示不限制(单位:厘)',
|
||||
value: String(v.withdrawDailyLimitLi ?? 0),
|
||||
})
|
||||
)
|
||||
|
||||
tasks.push(
|
||||
upsert(policyParamKeys.rewardDestination, { name: '奖励入账去向', desc: 'account|game_coin', value: v.rewardDestination })
|
||||
)
|
||||
|
||||
await Promise.all(tasks)
|
||||
message.success('资产策略已保存(已自动写入变更审计)')
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel" loading={loading}>
|
||||
<div className="section-heading" style={{ marginBottom: 8 }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 0 }}>
|
||||
资产策略中心
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
互转/提现/奖励去向等关键策略集中在此配置;所有修改会写入“参数变更审计”。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" onClick={save} loading={loading}>
|
||||
保存策略
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{warnings.length ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="风险提示"
|
||||
description={
|
||||
<div>
|
||||
{warnings.map((w) => (
|
||||
<div key={w}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
transferEnabled: false,
|
||||
transferMode: 'warn_only',
|
||||
transferFeeBps: 0,
|
||||
transferDailyLimitLi: 0,
|
||||
transferDirection: 'two_way',
|
||||
withdrawEnabled: false,
|
||||
withdrawFeeBps: 0,
|
||||
withdrawDailyLimitLi: 0,
|
||||
rewardDestination: 'account',
|
||||
}}
|
||||
disabled={loading}
|
||||
onValuesChange={() => {
|
||||
// 触发 warnings 刷新
|
||||
setTimeout(() => {}, 0)
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title="互转策略">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="transferEnabled" label="互转开关" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="transferMode" label="风险模式" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: '仅提示不拦截', value: 'warn_only' },
|
||||
{ label: '护栏模式(预留)', value: 'guardrail' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="transferFeeBps" label="手续费(万分比)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={10000} step={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="transferDailyLimitLi" label="日限额(厘,0=不限制)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} step={1000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<Form.Item name="transferDirection" label="互转方向" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: '双向互转', value: 'two_way' },
|
||||
{ label: '仅 余额 → 游戏币', value: 'balance_to_coin' },
|
||||
{ label: '仅 游戏币 → 余额', value: 'coin_to_balance' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title="提现与奖励策略">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="withdrawEnabled" label="提现开关" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="rewardDestination" label="奖励入账去向" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: '账户余额', value: 'account' },
|
||||
{ label: '游戏币', value: 'game_coin' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="withdrawFeeBps" label="提现手续费(万分比)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={10000} step={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="withdrawDailyLimitLi" label="提现日限额(厘,0=不限制)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} step={1000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,51 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
|
||||
features: ['项目说明', '技术栈梳理', '外部链接入口'],
|
||||
endpoints: [],
|
||||
},
|
||||
userCenter: {
|
||||
name: 'userCenter',
|
||||
title: '用户中心',
|
||||
group: '业务中心',
|
||||
status: 'partial',
|
||||
summary: '聚合玩家/用户相关的运营能力与策略配置入口。',
|
||||
features: ['玩家管理', '注册策略', '邀请关系', '封禁与风控(规划)'],
|
||||
endpoints: ['/admin/app-users', '/public/app/register/status'],
|
||||
},
|
||||
financeCenter: {
|
||||
name: 'financeCenter',
|
||||
title: '财务中心',
|
||||
group: '业务中心',
|
||||
status: 'partial',
|
||||
summary: '聚合充值、积分、账单、对账与风控策略等财务能力入口。',
|
||||
features: ['订单/充值(规划)', '积分与余额(规划)', '账单流水(规划)'],
|
||||
endpoints: [],
|
||||
},
|
||||
withdrawManage: {
|
||||
name: 'withdrawManage',
|
||||
title: '提现管理',
|
||||
group: '业务中心',
|
||||
status: 'partial',
|
||||
summary: '管理提现订单的审核、打款与失败回滚,提供财务流程的统一入口。',
|
||||
features: ['提现订单列表', '订单详情', '审核通过/拒绝', '打款确认/失败回滚'],
|
||||
endpoints: ['/admin/app-withdraw-orders', '/admin/app-withdraw-orders/:id'],
|
||||
},
|
||||
gameCenter: {
|
||||
name: 'gameCenter',
|
||||
title: '游戏中心',
|
||||
group: '业务中心',
|
||||
status: 'partial',
|
||||
summary: '聚合游戏内容、配置、审核与数据看板等能力入口。',
|
||||
features: ['内容管理(规划)', '游戏配置(规划)', '数据看板(规划)'],
|
||||
endpoints: [],
|
||||
},
|
||||
opsConfig: {
|
||||
name: 'opsConfig',
|
||||
title: '运营配置',
|
||||
group: '平台治理',
|
||||
status: 'partial',
|
||||
summary: '聚合功能开关、策略与运营配置入口,用于承载高频变更项。',
|
||||
features: ['注册策略', '验证码/风控策略(规划)', '功能开关(规划)'],
|
||||
endpoints: ['/sysParams/getSysParamsList'],
|
||||
},
|
||||
superAdmin: {
|
||||
name: 'superAdmin',
|
||||
title: '超级管理员',
|
||||
|
||||
363
web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx
Normal file
363
web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { appInviteCodesAdminApi } from '@/lib/api'
|
||||
import type { AppInviteCodeDetail, AppInviteCodeRecord, AppInviteCodeStatus } from '@/types/system'
|
||||
|
||||
type SearchValues = {
|
||||
status?: AppInviteCodeStatus
|
||||
createdByUserId?: string
|
||||
usedByUserId?: string
|
||||
codeLast4?: string
|
||||
}
|
||||
|
||||
type IssueValues = {
|
||||
userId: string
|
||||
expireHours?: string
|
||||
}
|
||||
|
||||
type ClearValues = {
|
||||
userId: string
|
||||
scope: 'active' | 'all'
|
||||
}
|
||||
|
||||
const statusLabel: Record<AppInviteCodeStatus, string> = {
|
||||
unused: '未使用',
|
||||
used: '已使用',
|
||||
revoked: '已作废',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
function asNumber(value: string | undefined) {
|
||||
if (!value) return undefined
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||
}
|
||||
|
||||
export function AdminInviteCodesPage() {
|
||||
const [form] = Form.useForm<SearchValues>()
|
||||
const [issueForm] = Form.useForm<IssueValues>()
|
||||
const [clearForm] = Form.useForm<ClearValues>()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState<AppInviteCodeRecord[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<AppInviteCodeDetail | null>(null)
|
||||
|
||||
const [issueOpen, setIssueOpen] = useState(false)
|
||||
const [issuing, setIssuing] = useState(false)
|
||||
const [issuedCode, setIssuedCode] = useState<string | null>(null)
|
||||
|
||||
const [clearOpen, setClearOpen] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const res = await appInviteCodesAdminApi.getList({
|
||||
page,
|
||||
pageSize,
|
||||
status: values.status,
|
||||
createdByUserId: asNumber(values.createdByUserId),
|
||||
usedByUserId: asNumber(values.usedByUserId),
|
||||
codeLast4: values.codeLast4?.trim() || undefined,
|
||||
})
|
||||
setItems(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const openDetail = async (record: AppInviteCodeRecord) => {
|
||||
setDetailOpen(true)
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const res = await appInviteCodesAdminApi.getById(record.id)
|
||||
setDetail(res.data)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AppInviteCodeRecord> = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||
{ title: '邀请人ID', dataIndex: 'createdByUserId', width: 120 },
|
||||
{ title: '末4位', dataIndex: 'codeLast4', width: 110 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (value: AppInviteCodeStatus) => (
|
||||
<Tag color={value === 'used' ? 'green' : value === 'unused' ? 'blue' : value === 'expired' ? 'orange' : 'red'}>
|
||||
{statusLabel[value]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '过期时间', dataIndex: 'expiresAt', width: 190 },
|
||||
{ title: '使用人ID', dataIndex: 'usedByUserId', width: 120 },
|
||||
{ title: '使用时间', dataIndex: 'usedAt', width: 190 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 190 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space size={8}>
|
||||
<Button type="link" onClick={() => openDetail(record)} style={{ padding: 0 }}>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#cf1322' }}
|
||||
disabled={record.status !== 'unused'}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认作废该邀请码?',
|
||||
content: `ID=${record.id},末4位=${record.codeLast4}`,
|
||||
okText: '作废',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await appInviteCodesAdminApi.revoke(record.id)
|
||||
message.success('已作废')
|
||||
load()
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
作废
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[load],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
onFinish={() => {
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', gap: 12 }}>
|
||||
<Space wrap>
|
||||
<Form.Item name="status">
|
||||
<Select
|
||||
placeholder="状态"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '未使用', value: 'unused' },
|
||||
{ label: '已使用', value: 'used' },
|
||||
{ label: '已作废', value: 'revoked' },
|
||||
{ label: '已过期', value: 'expired' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="codeLast4">
|
||||
<Input placeholder="末4位" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="createdByUserId">
|
||||
<Input placeholder="邀请人ID" allowClear style={{ width: 130 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="usedByUserId">
|
||||
<Input placeholder="使用人ID" allowClear style={{ width: 130 }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
issueForm.resetFields()
|
||||
setIssuedCode(null)
|
||||
setIssueOpen(true)
|
||||
}}
|
||||
>
|
||||
代生成
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
clearForm.resetFields()
|
||||
clearForm.setFieldsValue({ scope: 'active' })
|
||||
setClearOpen(true)
|
||||
}}
|
||||
>
|
||||
清空未使用码
|
||||
</Button>
|
||||
<Button onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
scroll={{ x: 1250 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
open={detailOpen}
|
||||
title="邀请码详情"
|
||||
onClose={() => {
|
||||
setDetailOpen(false)
|
||||
setDetail(null)
|
||||
}}
|
||||
width={520}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<Typography.Text className="text-muted">加载中...</Typography.Text>
|
||||
) : detail ? (
|
||||
<div className="page-stack">
|
||||
<Card size="small" className="glass-panel">
|
||||
<Space direction="vertical" size={6}>
|
||||
<div>邀请码ID:{detail.code.id}</div>
|
||||
<div>末4位:{detail.code.codeLast4}</div>
|
||||
<div>状态:{statusLabel[detail.code.status]}</div>
|
||||
<div>过期时间:{detail.code.expiresAt || '-'}</div>
|
||||
<div>创建时间:{detail.code.createdAt}</div>
|
||||
<div>使用时间:{detail.code.usedAt || '-'}</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" className="glass-panel" title="关联用户">
|
||||
<Space direction="vertical" size={6}>
|
||||
<div>
|
||||
邀请人:{detail.inviter.username}(ID={detail.inviter.id})
|
||||
</div>
|
||||
<div>
|
||||
被邀请人:{detail.invitee ? `${detail.invitee.username}(ID=${detail.invitee.id})` : '-'}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text className="text-muted">暂无数据</Typography.Text>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={issueOpen}
|
||||
title="后台代生成邀请码"
|
||||
onCancel={() => setIssueOpen(false)}
|
||||
confirmLoading={issuing}
|
||||
onOk={async () => {
|
||||
const values = await issueForm.validateFields()
|
||||
const userId = Number(values.userId)
|
||||
const expireHours = values.expireHours ? Number(values.expireHours) : undefined
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
message.error('用户ID不合法')
|
||||
return
|
||||
}
|
||||
setIssuing(true)
|
||||
try {
|
||||
const res = await appInviteCodesAdminApi.issue({
|
||||
userId,
|
||||
expireHours: Number.isFinite(expireHours) && (expireHours as number) > 0 ? (expireHours as number) : undefined,
|
||||
})
|
||||
setIssuedCode(res.data.code)
|
||||
} finally {
|
||||
setIssuing(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={issueForm} layout="vertical">
|
||||
<Form.Item name="userId" label="用户ID" rules={[{ required: true, message: '请输入用户ID' }]}>
|
||||
<Input inputMode="numeric" placeholder="例如:123" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expireHours" label="过期小时数(可选)">
|
||||
<Input inputMode="numeric" placeholder="默认使用系统配置" />
|
||||
</Form.Item>
|
||||
{issuedCode ? (
|
||||
<Card size="small">
|
||||
<Typography.Text strong>邀请码(仅展示一次,请及时复制):</Typography.Text>
|
||||
<div style={{ marginTop: 8, wordBreak: 'break-all' }}>{issuedCode}</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={clearOpen}
|
||||
title="强制清空未使用码"
|
||||
onCancel={() => setClearOpen(false)}
|
||||
confirmLoading={clearing}
|
||||
onOk={async () => {
|
||||
const values = await clearForm.validateFields()
|
||||
const userId = Number(values.userId)
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
message.error('用户ID不合法')
|
||||
return
|
||||
}
|
||||
setClearing(true)
|
||||
try {
|
||||
await appInviteCodesAdminApi.clearUnused({ userId, scope: values.scope })
|
||||
message.success('已清空')
|
||||
setClearOpen(false)
|
||||
load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={clearForm} layout="vertical" initialValues={{ scope: 'active' }}>
|
||||
<Form.Item name="userId" label="用户ID" rules={[{ required: true, message: '请输入用户ID' }]}>
|
||||
<Input inputMode="numeric" placeholder="例如:123" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scope" label="范围" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '仅未过期未使用(active)', value: 'active' },
|
||||
{ label: '全部未使用(all)', value: 'all' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, Dropdown, Menu, Select, Space, Typography, message } from 'antd'
|
||||
import type { ItemType } from 'antd/es/menu/interface'
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
CodeOutlined,
|
||||
DashboardOutlined,
|
||||
LockOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PartitionOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
@@ -68,6 +70,8 @@ type Props = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const sidebarCollapsedStorageKey = 'admin_sidebar_collapsed'
|
||||
|
||||
export function AdminShell({ children }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -77,6 +81,12 @@ export function AdminShell({ children }: Props) {
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const setMenus = useAuthStore((state) => state.setMenus)
|
||||
const selectedKey = location.pathname.replace(/^\//, '')
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(sidebarCollapsedStorageKey)
|
||||
setCollapsed(stored === '1')
|
||||
}, [])
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const target = flattenMenus(menus).find((menu) => menu.fullPath === location.pathname)
|
||||
@@ -146,26 +156,45 @@ export function AdminShell({ children }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev
|
||||
localStorage.setItem(sidebarCollapsedStorageKey, next ? '1' : '0')
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<div className={collapsed ? 'admin-shell is-collapsed' : 'admin-shell'}>
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-brand">
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.65)' }}>React Admin</Typography.Text>
|
||||
<Typography.Title level={3} style={{ color: 'rgba(255,255,255,0.96)', margin: '8px 0 6px' }}>
|
||||
RMK Control Deck
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.72)', marginBottom: 0 }}>
|
||||
基于现有 Gin 后端协议构建的新管理台。
|
||||
</Typography.Paragraph>
|
||||
<div className="admin-brand-header">
|
||||
<div>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.65)' }}>React Admin</Typography.Text>
|
||||
<Typography.Title level={3} style={{ color: 'rgba(255,255,255,0.96)', margin: '8px 0 6px' }}>
|
||||
RMK Control Deck
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.72)', marginBottom: 0 }}>
|
||||
基于现有 Gin 后端协议构建的新管理台。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={collapsed ? '展开菜单' : '收缩菜单'}
|
||||
onClick={toggleCollapsed}
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
style={{ color: 'rgba(255,255,255,0.86)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
className="admin-nav-menu"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
inlineIndent={16}
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={defaultOpenKeys}
|
||||
motion={false}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ width: '100%', background: 'transparent', borderInlineEnd: 'none' }}
|
||||
|
||||
@@ -139,6 +139,20 @@ function resolveMenuComponentDisplay(menu: Pick<MenuNode, 'name' | 'component'>)
|
||||
return getComponentLabelByRouteName(menu.name) || resolveMenuComponentValue(menu)
|
||||
}
|
||||
|
||||
function buildDepthMap(nodes: MenuNode[]) {
|
||||
const map = new WeakMap<MenuNode, number>()
|
||||
const walk = (items: MenuNode[], depth: number) => {
|
||||
for (const item of items) {
|
||||
map.set(item, depth)
|
||||
if (item.children?.length) {
|
||||
walk(item.children, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(nodes, 0)
|
||||
return map
|
||||
}
|
||||
|
||||
export function MenuManagementPage() {
|
||||
const [form] = Form.useForm<MenuFormValues>()
|
||||
const [menus, setMenus] = useState<MenuNode[]>([])
|
||||
@@ -159,6 +173,8 @@ export function MenuManagementPage() {
|
||||
[menus],
|
||||
)
|
||||
|
||||
const depthMap = useMemo(() => buildDepthMap(menus), [menus])
|
||||
|
||||
const reloadMenus = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -286,11 +302,19 @@ export function MenuManagementPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<MenuNode> = [
|
||||
{ title: 'ID', dataIndex: 'ID', width: 80 },
|
||||
{ title: 'ID', dataIndex: 'ID', width: 96 },
|
||||
{
|
||||
title: '展示名称',
|
||||
width: 180,
|
||||
render: (_, record) => record.meta.title,
|
||||
width: 240,
|
||||
render: (_, record) => {
|
||||
const depth = depthMap.get(record) ?? 0
|
||||
const hasChildren = Boolean(record.children?.length)
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: hasChildren ? 600 : 400 }}>{record.meta.title}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '图标',
|
||||
@@ -366,9 +390,9 @@ export function MenuManagementPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={menus}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
expandable={{ defaultExpandAllRows: true, indentSize: 18 }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1700 }}
|
||||
scroll={{ x: 1760 }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -13,10 +13,46 @@ type ComponentTreeNode = {
|
||||
const componentOptions: ComponentOption[] = [
|
||||
{ value: 'features/dashboard/DashboardPage', label: 'DashboardPage · 仪表盘', routeName: 'dashboard' },
|
||||
{ value: 'features/discovery/ModuleLandingPage:about', label: 'ModuleLandingPage · 关于系统', routeName: 'about' },
|
||||
{ value: 'features/discovery/ModuleLandingPage:userCenter', label: 'ModuleLandingPage · 用户中心', routeName: 'userCenter' },
|
||||
{ value: 'features/discovery/ModuleLandingPage:financeCenter', label: 'ModuleLandingPage · 财务中心', routeName: 'financeCenter' },
|
||||
{ value: 'features/discovery/ModuleLandingPage:withdrawManage', label: 'ModuleLandingPage · 提现管理(分组)', routeName: 'withdrawManage' },
|
||||
{ value: 'features/discovery/ModuleLandingPage:gameCenter', label: 'ModuleLandingPage · 游戏中心', routeName: 'gameCenter' },
|
||||
{ value: 'features/discovery/ModuleLandingPage:opsConfig', label: 'ModuleLandingPage · 运营配置', routeName: 'opsConfig' },
|
||||
{ value: 'features/roles/RoleManagementPage', label: 'RoleManagementPage · 角色管理', routeName: 'authority' },
|
||||
{ value: 'features/menus/MenuManagementPage', label: 'MenuManagementPage · 菜单管理', routeName: 'menu' },
|
||||
{ value: 'features/apis/ApiManagementPage', label: 'ApiManagementPage · API 管理', routeName: 'api' },
|
||||
{ value: 'features/users/UserManagementPage', label: 'UserManagementPage · 用户管理', routeName: 'user' },
|
||||
{ value: 'features/appPlayers/AppPlayersPage', label: 'AppPlayersPage · 玩家管理', routeName: 'appPlayers' },
|
||||
{
|
||||
value: 'features/appAssetTransactions/AppAssetTransactionsPage',
|
||||
label: 'AppAssetTransactionsPage · 玩家资产流水',
|
||||
routeName: 'appAssetTransactions',
|
||||
},
|
||||
{
|
||||
value: 'features/withdrawOrders/WithdrawOrdersPage',
|
||||
label: 'WithdrawOrdersPage · 提现订单',
|
||||
routeName: 'withdrawOrders',
|
||||
},
|
||||
{
|
||||
value: 'features/assetPolicyCenter/AssetPolicyCenterPage',
|
||||
label: 'AssetPolicyCenterPage · 资产策略中心',
|
||||
routeName: 'assetPolicyCenter',
|
||||
},
|
||||
{
|
||||
value: 'features/riskTags/RiskTagsPage',
|
||||
label: 'RiskTagsPage · 风险标签',
|
||||
routeName: 'riskTags',
|
||||
},
|
||||
{
|
||||
value: 'features/registerPolicy/RegisterPolicyPage',
|
||||
label: 'RegisterPolicyPage · 玩家注册策略',
|
||||
routeName: 'registerPolicy',
|
||||
},
|
||||
{
|
||||
value: 'features/inviteCodes/AdminInviteCodesPage',
|
||||
label: 'AdminInviteCodesPage · 邀请码管理',
|
||||
routeName: 'inviteCodes',
|
||||
},
|
||||
{
|
||||
value: 'features/appManage/AppManageLandingPage',
|
||||
label: 'AppManageLandingPage · 应用管理总览',
|
||||
@@ -149,9 +185,15 @@ const componentOptions: ComponentOption[] = [
|
||||
},
|
||||
{ value: 'features/logs/OperationLogPage', label: 'OperationLogPage · 操作历史', routeName: 'operation' },
|
||||
{ value: 'features/params/ParamsManagementPage', label: 'ParamsManagementPage · 参数管理', routeName: 'sysParams' },
|
||||
{
|
||||
value: 'features/params/SysParamChangeLogPage',
|
||||
label: 'SysParamChangeLogPage · 参数变更审计',
|
||||
routeName: 'sysParamChangeLog',
|
||||
},
|
||||
{ value: 'features/system/SystemConfigPage', label: 'SystemConfigPage · 系统配置', routeName: 'system' },
|
||||
{ value: 'features/tokens/ApiTokenPage', label: 'ApiTokenPage · API Token', routeName: 'apiToken' },
|
||||
{ value: 'features/logs/LoginLogPage', label: 'LoginLogPage · 登录日志', routeName: 'loginLog' },
|
||||
{ value: 'features/appLoginLogs/AppLoginLogsPage', label: 'AppLoginLogsPage · 玩家登录日志', routeName: 'appLoginLog' },
|
||||
{ value: 'features/errors/ErrorLogPage', label: 'ErrorLogPage · 错误日志', routeName: 'sysError' },
|
||||
{ value: 'features/person/ProfilePage', label: 'ProfilePage · 个人中心', routeName: 'person' },
|
||||
{ value: 'features/server/ServerStatePage', label: 'ServerStatePage · 服务器状态', routeName: 'state' },
|
||||
|
||||
@@ -156,6 +156,7 @@ export function ParamsManagementPage() {
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="ID"
|
||||
|
||||
154
web-admin/src/features/params/SysParamChangeLogPage.tsx
Normal file
154
web-admin/src/features/params/SysParamChangeLogPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Space, Table, Tag, Typography } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { sysParamsApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
|
||||
type ChangeLog = {
|
||||
id: number
|
||||
paramId: number
|
||||
key: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
oldValue?: string
|
||||
newValue?: string
|
||||
operatorUserId: number
|
||||
operatorName?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type Search = {
|
||||
key?: string
|
||||
operatorUserId?: string
|
||||
}
|
||||
|
||||
export function SysParamChangeLogPage() {
|
||||
const [form] = Form.useForm<Search>()
|
||||
const [rows, setRows] = useState<ChangeLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const res = await sysParamsApi.getChangeLogList({
|
||||
page,
|
||||
pageSize,
|
||||
key: values.key?.trim() || undefined,
|
||||
operatorUserId: values.operatorUserId?.trim() || undefined,
|
||||
})
|
||||
setRows(res.data.list as unknown as ChangeLog[])
|
||||
setTotal(res.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns: ColumnsType<ChangeLog> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||
{ title: 'Key', dataIndex: 'key', width: 260 },
|
||||
{
|
||||
title: '动作',
|
||||
dataIndex: 'action',
|
||||
width: 110,
|
||||
render: (v) => {
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
create: { label: '新建', color: 'green' },
|
||||
update: { label: '更新', color: 'blue' },
|
||||
delete: { label: '删除', color: 'red' },
|
||||
}
|
||||
const it = map[String(v)] ?? { label: String(v), color: 'default' }
|
||||
return <Tag color={it.color}>{it.label}</Tag>
|
||||
},
|
||||
},
|
||||
{ title: '旧值', dataIndex: 'oldValue', ellipsis: true },
|
||||
{ title: '新值', dataIndex: 'newValue', ellipsis: true },
|
||||
{
|
||||
title: '操作人',
|
||||
width: 170,
|
||||
render: (_, r) => (r.operatorName ? `${r.operatorName}(#${r.operatorUserId})` : `#${r.operatorUserId}`),
|
||||
},
|
||||
{ title: '时间', width: 190, render: (_, r) => formatDate(r.createdAt) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
参数变更审计
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
用于追溯运营开关/策略配置的变更记录(谁改的、改了什么)。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
onFinish={() => {
|
||||
if (page !== 1) {
|
||||
setPage(1)
|
||||
return
|
||||
}
|
||||
load()
|
||||
}}
|
||||
>
|
||||
<Form.Item name="key" label="Key">
|
||||
<Input allowClear placeholder="模糊搜索 key" style={{ width: 320 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="operatorUserId" label="操作人ID">
|
||||
<Input allowClear placeholder="例如 1" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
153
web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx
Normal file
153
web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Form, Radio, Row, Select, Space, Switch, Typography, message } from 'antd'
|
||||
import { sysParamsApi } from '@/lib/api'
|
||||
|
||||
const registerParamKeys = {
|
||||
mode: 'auth.register_mode',
|
||||
requireCaptcha: 'auth.register_require_captcha',
|
||||
inviteExpireHours: 'auth.invite_code_expire_hours',
|
||||
} as const
|
||||
|
||||
type RegisterPolicyValues = {
|
||||
mode: 'closed' | 'open' | 'invite'
|
||||
requireCaptcha: boolean
|
||||
inviteExpireHours: number
|
||||
}
|
||||
|
||||
export function RegisterPolicyPage() {
|
||||
const [form] = Form.useForm<RegisterPolicyValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [records, setRecords] = useState<Partial<Record<keyof typeof registerParamKeys, { ID: number; key: string }>>>(
|
||||
{},
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [modeRes, captchaRes, expireRes] = await Promise.all([
|
||||
sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: registerParamKeys.mode }),
|
||||
sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: registerParamKeys.requireCaptcha }),
|
||||
sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: registerParamKeys.inviteExpireHours }),
|
||||
])
|
||||
|
||||
const pickExact = (list: { ID: number; key: string; value: string }[], key: string) =>
|
||||
list.find((item) => item.key === key) || list[0]
|
||||
const modeRecord = pickExact(modeRes.data.list, registerParamKeys.mode)
|
||||
const captchaRecord = pickExact(captchaRes.data.list, registerParamKeys.requireCaptcha)
|
||||
const expireRecord = pickExact(expireRes.data.list, registerParamKeys.inviteExpireHours)
|
||||
|
||||
setRecords({
|
||||
mode: modeRecord ? { ID: modeRecord.ID, key: modeRecord.key } : undefined,
|
||||
requireCaptcha: captchaRecord ? { ID: captchaRecord.ID, key: captchaRecord.key } : undefined,
|
||||
inviteExpireHours: expireRecord ? { ID: expireRecord.ID, key: expireRecord.key } : undefined,
|
||||
})
|
||||
|
||||
form.setFieldsValue({
|
||||
mode: (modeRecord?.value as RegisterPolicyValues['mode']) || 'closed',
|
||||
requireCaptcha: captchaRecord?.value === '1',
|
||||
inviteExpireHours: Number(expireRecord?.value || 24) || 24,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields()
|
||||
|
||||
const upsert = async (key: string, payload: { name: string; desc: string; value: string }) => {
|
||||
const existing = Object.values(records).find((item) => item?.key === key) ?? null
|
||||
if (existing?.ID) {
|
||||
await sysParamsApi.updateParam({ ID: existing.ID, key, ...payload })
|
||||
} else {
|
||||
await sysParamsApi.createParam({ key, ...payload })
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
upsert(registerParamKeys.mode, {
|
||||
name: '注册模式',
|
||||
desc: 'closed|open|invite',
|
||||
value: values.mode,
|
||||
}),
|
||||
upsert(registerParamKeys.requireCaptcha, {
|
||||
name: '注册验证码开关',
|
||||
desc: '0/1(1 表示注册需要验证码)',
|
||||
value: values.requireCaptcha ? '1' : '0',
|
||||
}),
|
||||
upsert(registerParamKeys.inviteExpireHours, {
|
||||
name: '邀请码过期小时数',
|
||||
desc: '12|24|72|168|720(单位:小时)',
|
||||
value: String(values.inviteExpireHours),
|
||||
}),
|
||||
])
|
||||
|
||||
message.success('注册策略已保存')
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel" loading={loading}>
|
||||
<div className="section-heading" style={{ marginBottom: 8 }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 0 }}>
|
||||
玩家注册策略
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" onClick={save} loading={loading}>
|
||||
保存策略
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ mode: 'closed', requireCaptcha: false, inviteExpireHours: 24 }}
|
||||
disabled={loading}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12} lg={10}>
|
||||
<Form.Item name="mode" label="玩家注册模式" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: '关闭(closed)', value: 'closed' },
|
||||
{ label: '开放(open)', value: 'open' },
|
||||
{ label: '邀请码(invite)', value: 'invite' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} lg={6}>
|
||||
<Form.Item name="requireCaptcha" label="玩家注册需要验证码" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} lg={8}>
|
||||
<Form.Item name="inviteExpireHours" label="邀请码过期时间(小时)" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '12 小时', value: 12 },
|
||||
{ label: '24 小时', value: 24 },
|
||||
{ label: '3 天(72 小时)', value: 72 },
|
||||
{ label: '7 天(168 小时)', value: 168 },
|
||||
{ label: '30 天(720 小时)', value: 720 },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
259
web-admin/src/features/riskTags/RiskTagsPage.tsx
Normal file
259
web-admin/src/features/riskTags/RiskTagsPage.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, ColorPicker, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { appRiskTagsAdminApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { AppRiskTagRecord } from '@/types/system'
|
||||
|
||||
type EditValues = {
|
||||
name: string
|
||||
level: number
|
||||
color?: string
|
||||
desc?: string
|
||||
}
|
||||
|
||||
export function RiskTagsPage() {
|
||||
const [searchForm] = Form.useForm<{ keyword?: string }>()
|
||||
const [editForm] = Form.useForm<EditValues>()
|
||||
const [rows, setRows] = useState<AppRiskTagRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AppRiskTagRecord | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await appRiskTagsAdminApi.getTags({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: (searchForm.getFieldValue('keyword') || '').trim() || undefined,
|
||||
})
|
||||
setRows(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({ level: 1, color: '#cf1322' })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (r: AppRiskTagRecord) => {
|
||||
setEditing(r)
|
||||
editForm.setFieldsValue({ name: r.name, level: r.level, color: r.color, desc: r.desc })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const v = await editForm.validateFields()
|
||||
const payload: EditValues = {
|
||||
...v,
|
||||
color:
|
||||
typeof v.color === 'string'
|
||||
? v.color
|
||||
: // ColorPicker 可能返回 Color 对象,这里统一转成 hex 字符串
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((v.color as any)?.toHexString?.() as string | undefined) ?? undefined,
|
||||
}
|
||||
if (editing) {
|
||||
await appRiskTagsAdminApi.updateTag(editing.id, payload)
|
||||
message.success('已更新')
|
||||
} else {
|
||||
await appRiskTagsAdminApi.createTag(payload)
|
||||
message.success('已创建')
|
||||
}
|
||||
setModalOpen(false)
|
||||
load()
|
||||
}
|
||||
|
||||
const del = (r: AppRiskTagRecord) => {
|
||||
Modal.confirm({
|
||||
title: `删除标签「${r.name}」?`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await appRiskTagsAdminApi.deleteTag(r.id)
|
||||
message.success('已删除')
|
||||
load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const levelColor = useMemo(() => {
|
||||
return (lvl: number) => {
|
||||
if (lvl >= 5) return 'red'
|
||||
if (lvl === 4) return 'volcano'
|
||||
if (lvl === 3) return 'gold'
|
||||
if (lvl === 2) return 'blue'
|
||||
return 'default'
|
||||
}
|
||||
}, [])
|
||||
|
||||
const columns: ColumnsType<AppRiskTagRecord> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||
{
|
||||
title: '标签名',
|
||||
dataIndex: 'name',
|
||||
width: 200,
|
||||
render: (v, r) => <Tag color={r.color || levelColor(r.level)}>{String(v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
width: 110,
|
||||
render: (v: number) => <Tag color={levelColor(v)}>{`L${v}`}</Tag>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'desc', ellipsis: true },
|
||||
{ title: '创建时间', width: 190, render: (_, r) => formatDate(r.createdAt) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openEdit(r)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => del(r)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading" style={{ marginBottom: 8 }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 0 }}>
|
||||
风险标签
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
人工版框架:用于给玩家打标,后续可扩展批量处置策略。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建标签
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={() => {
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
<Form.Item name="keyword" label="关键字">
|
||||
<Input allowClear placeholder="标签名" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal open={modalOpen} title={editing ? '编辑标签' : '新建标签'} onCancel={() => setModalOpen(false)} onOk={save}>
|
||||
<Form form={editForm} layout="vertical" initialValues={{ level: 1 }}>
|
||||
<Form.Item name="name" label="标签名" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如:疑似套利/高风险/人工复核" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="level"
|
||||
label="风险级别(1-5)"
|
||||
rules={[
|
||||
{ required: true, message: '请选择风险级别' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n < 1 || n > 5) throw new Error('风险级别只能为 1-5')
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择 1-5"
|
||||
options={[
|
||||
{ label: '1(低)', value: 1 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '3(中)', value: 3 },
|
||||
{ label: '4', value: 4 },
|
||||
{ label: '5(高)', value: 5 },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="color"
|
||||
label="颜色(可选)"
|
||||
// 将 ColorPicker 的值规范化为 hex 字符串(提交后端用)
|
||||
getValueFromEvent={(color: any) => (typeof color === 'string' ? color : color?.toHexString?.())}
|
||||
>
|
||||
<ColorPicker
|
||||
showText
|
||||
format="hex"
|
||||
presets={[
|
||||
{
|
||||
label: '常用',
|
||||
colors: ['#1677ff', '#2f54eb', '#13c2c2', '#52c41a', '#faad14', '#fa8c16', '#f5222d', '#cf1322', '#722ed1', '#262626'],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="desc" label="说明(可选)">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,14 +174,14 @@ export function RoleManagementPage() {
|
||||
|
||||
const defaultRouterOptions = useMemo(
|
||||
() =>
|
||||
collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
|
||||
collectMenusByIds(menuTree, new Set(menuChecked))
|
||||
.filter((menu) => menu.name && !menu.name.startsWith('http://') && !menu.name.startsWith('https://'))
|
||||
.filter((menu) => !(menu.children || []).length)
|
||||
.map((menu) => ({
|
||||
label: menu.meta.title,
|
||||
value: menu.name,
|
||||
})),
|
||||
[menuChecked, menuHalfChecked, menuTree],
|
||||
[menuChecked, menuTree],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -253,15 +253,14 @@ export function RoleManagementPage() {
|
||||
defaultRouter,
|
||||
(menu) => void openButtonModal(menu),
|
||||
(menu) => {
|
||||
const checkedSet = new Set([...menuChecked, ...menuHalfChecked])
|
||||
if (!checkedSet.has(menu.ID)) {
|
||||
if (!new Set(menuChecked).has(menu.ID)) {
|
||||
message.warning('请先勾选菜单,再将其设为首页')
|
||||
return
|
||||
}
|
||||
setDefaultRouter(menu.name)
|
||||
},
|
||||
),
|
||||
[defaultRouter, filteredMenus, menuChecked, menuHalfChecked, openButtonModal],
|
||||
[defaultRouter, filteredMenus, menuChecked, openButtonModal],
|
||||
)
|
||||
const apiTreeData = useMemo(() => buildApiTree(apis, apiNameFilter, apiPathFilter), [apiNameFilter, apiPathFilter, apis])
|
||||
|
||||
@@ -394,7 +393,7 @@ export function RoleManagementPage() {
|
||||
setSavingPermission(true)
|
||||
try {
|
||||
if (activeTab === 'menus') {
|
||||
const checkedMenus = collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
|
||||
const checkedMenus = collectMenusByIds(menuTree, new Set(menuChecked))
|
||||
await menuApi.addMenuAuthority({
|
||||
authorityId: activeRole.authorityId,
|
||||
menus: checkedMenus,
|
||||
@@ -601,8 +600,9 @@ export function RoleManagementPage() {
|
||||
/>
|
||||
<Tree
|
||||
checkable
|
||||
checkStrictly
|
||||
defaultExpandAll
|
||||
checkedKeys={menuChecked}
|
||||
checkedKeys={{ checked: menuChecked, halfChecked: menuHalfChecked }}
|
||||
treeData={menuTreeData}
|
||||
onCheck={(checkedKeys, info) => {
|
||||
if (Array.isArray(checkedKeys)) {
|
||||
|
||||
406
web-admin/src/features/withdrawOrders/WithdrawOrdersPage.tsx
Normal file
406
web-admin/src/features/withdrawOrders/WithdrawOrdersPage.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Col, DatePicker, Drawer, Form, Input, Modal, Row, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { appWithdrawOrdersAdminApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { AppWithdrawOrderRecord } from '@/types/system'
|
||||
|
||||
type Search = {
|
||||
userId?: string
|
||||
username?: string
|
||||
status?: string
|
||||
range?: [dayjs.Dayjs, dayjs.Dayjs]
|
||||
}
|
||||
|
||||
function formatLiToYuan(li?: number) {
|
||||
const n = typeof li === 'number' && Number.isFinite(li) ? li : 0
|
||||
return (n / 1000).toFixed(3)
|
||||
}
|
||||
|
||||
function parsePositiveInt(input?: string) {
|
||||
const raw = (input ?? '').trim()
|
||||
if (!raw) return undefined
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined
|
||||
return Math.floor(n)
|
||||
}
|
||||
|
||||
function statusLabel(s?: string) {
|
||||
const map: Record<string, { text: string; color?: string }> = {
|
||||
pending: { text: '待审核', color: 'gold' },
|
||||
approved: { text: '待打款', color: 'blue' },
|
||||
rejected: { text: '已拒绝', color: 'default' },
|
||||
paid: { text: '已打款', color: 'green' },
|
||||
failed: { text: '打款失败', color: 'red' },
|
||||
}
|
||||
return map[String(s)] ?? { text: String(s ?? '-'), color: 'default' }
|
||||
}
|
||||
|
||||
export function WithdrawOrdersPage() {
|
||||
const [form] = Form.useForm<Search>()
|
||||
const [rows, setRows] = useState<AppWithdrawOrderRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [drawerLoading, setDrawerLoading] = useState(false)
|
||||
const [drawerRecord, setDrawerRecord] = useState<any>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const range = values.range
|
||||
const startAt = range?.[0]?.toISOString()
|
||||
const endAt = range?.[1]?.toISOString()
|
||||
const res = await appWithdrawOrdersAdminApi.getList({
|
||||
page,
|
||||
pageSize,
|
||||
userId: parsePositiveInt(values.userId),
|
||||
username: values.username?.trim() || undefined,
|
||||
status: values.status || undefined,
|
||||
...(startAt ? { startAt } : {}),
|
||||
...(endAt ? { endAt } : {}),
|
||||
})
|
||||
setRows(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [form, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const openDetail = async (record: AppWithdrawOrderRecord) => {
|
||||
setDrawerOpen(true)
|
||||
setDrawerLoading(true)
|
||||
try {
|
||||
const detail = await appWithdrawOrdersAdminApi.getById(record.id)
|
||||
setDrawerRecord(detail.data)
|
||||
} finally {
|
||||
setDrawerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doAction = async (title: string, action: () => Promise<any>) => {
|
||||
await action()
|
||||
await load()
|
||||
Modal.success({ title, content: '操作成功' })
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AppWithdrawOrderRecord> = useMemo(
|
||||
() => [
|
||||
{ title: '订单ID', dataIndex: 'id', width: 100 },
|
||||
{ title: '玩家ID', dataIndex: 'appUserId', width: 100 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||
{
|
||||
title: '提现金额(元)',
|
||||
dataIndex: 'amountLi',
|
||||
width: 130,
|
||||
render: (v: number) => formatLiToYuan(v),
|
||||
},
|
||||
{
|
||||
title: '手续费(元)',
|
||||
dataIndex: 'feeLi',
|
||||
width: 120,
|
||||
render: (v: number) => formatLiToYuan(v),
|
||||
},
|
||||
{
|
||||
title: '实付(元)',
|
||||
dataIndex: 'netLi',
|
||||
width: 120,
|
||||
render: (v: number) => formatLiToYuan(v),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (v) => {
|
||||
const s = statusLabel(v)
|
||||
return <Tag color={s.color}>{s.text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 190,
|
||||
render: (v: string) => formatDate(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 260,
|
||||
fixed: 'right',
|
||||
render: (_, r) => (
|
||||
<Space size={6} wrap>
|
||||
<Button type="link" style={{ padding: 0 }} onClick={() => openDetail(r)}>
|
||||
详情
|
||||
</Button>
|
||||
{r.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#1677ff' }}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '审核通过',
|
||||
content: '确认将订单置为“待打款”?',
|
||||
onOk: () => doAction('审核通过', () => appWithdrawOrdersAdminApi.approve(r.id)),
|
||||
})
|
||||
}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => {
|
||||
let rejectReason = ''
|
||||
let auditRemark = ''
|
||||
Modal.confirm({
|
||||
title: '审核拒绝(将解冻)',
|
||||
content: (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Input placeholder="拒绝原因(必填)" onChange={(e) => (rejectReason = e.target.value)} />
|
||||
<Input placeholder="审核备注(可选)" onChange={(e) => (auditRemark = e.target.value)} />
|
||||
</Space>
|
||||
),
|
||||
onOk: async () => {
|
||||
const reason = rejectReason.trim()
|
||||
if (!reason) throw new Error('请填写拒绝原因')
|
||||
await doAction('已拒绝并解冻', () => appWithdrawOrdersAdminApi.reject(r.id, { rejectReason: reason, auditRemark: auditRemark.trim() || undefined }))
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{r.status === 'approved' ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0, color: '#389e0d' }}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '确认已打款',
|
||||
content: '确认将订单置为“已打款”(将扣减余额并解冻)?',
|
||||
onOk: () => doAction('已确认打款', () => appWithdrawOrdersAdminApi.markPaid(r.id)),
|
||||
})
|
||||
}
|
||||
>
|
||||
已打款
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => {
|
||||
let failureReason = ''
|
||||
let externalTxId = ''
|
||||
Modal.confirm({
|
||||
title: '标记打款失败(将解冻)',
|
||||
content: (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Input placeholder="失败原因(必填)" onChange={(e) => (failureReason = e.target.value)} />
|
||||
<Input placeholder="外部流水号(可选)" onChange={(e) => (externalTxId = e.target.value)} />
|
||||
</Space>
|
||||
),
|
||||
onOk: async () => {
|
||||
const reason = failureReason.trim()
|
||||
if (!reason) throw new Error('请填写失败原因')
|
||||
await doAction('已标记失败并解冻', () => appWithdrawOrdersAdminApi.markFailed(r.id, { failureReason: reason, externalTxId: externalTxId.trim() || undefined }))
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
打款失败
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={() => {
|
||||
if (page !== 1) {
|
||||
setPage(1)
|
||||
return
|
||||
}
|
||||
load()
|
||||
}}
|
||||
>
|
||||
<Row gutter={[12, 8]}>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="userId" label="玩家ID" style={{ marginBottom: 0 }}>
|
||||
<Input allowClear placeholder="输入玩家ID" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="username" label="用户名" style={{ marginBottom: 0 }}>
|
||||
<Input allowClear placeholder="输入用户名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<Form.Item name="status" label="状态" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
options={[
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '待打款', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
{ label: '已打款', value: 'paid' },
|
||||
{ label: '打款失败', value: 'failed' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} lg={12} xl={8}>
|
||||
<Form.Item name="range" label="申请时间" style={{ marginBottom: 0 }}>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
presets={[
|
||||
{ label: '近24小时', value: [dayjs().subtract(24, 'hour'), dayjs()] },
|
||||
{ label: '近7天', value: [dayjs().subtract(7, 'day'), dayjs()] },
|
||||
{ label: '近30天', value: [dayjs().subtract(30, 'day'), dayjs()] },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} lg={12} xl={4}>
|
||||
<Form.Item label=" " style={{ marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
width={640}
|
||||
title={drawerRecord ? `提现订单 #${drawerRecord.id}` : '提现订单'}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false)
|
||||
setDrawerRecord(null)
|
||||
}}
|
||||
>
|
||||
{drawerLoading ? (
|
||||
<Typography.Paragraph>加载中...</Typography.Paragraph>
|
||||
) : drawerRecord ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card size="small" styles={{ body: { padding: 12 } }}>
|
||||
<Row gutter={[12, 10]}>
|
||||
<Col span={24}>
|
||||
<Space size={8} wrap>
|
||||
<Tag color="default">玩家</Tag>
|
||||
<Typography.Text strong>
|
||||
{drawerRecord.username}(#{drawerRecord.appUserId})
|
||||
</Typography.Text>
|
||||
<Tag color={statusLabel(drawerRecord.status).color}>{statusLabel(drawerRecord.status).text}</Tag>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Row gutter={[12, 10]}>
|
||||
<Col span={8}>
|
||||
<Typography.Text type="secondary">提现金额</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text strong style={{ fontSize: 18 }}>
|
||||
{formatLiToYuan(drawerRecord.amountLi)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> 元</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text type="secondary">手续费</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text>{formatLiToYuan(drawerRecord.feeLi)} 元</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text type="secondary">实付</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text>{formatLiToYuan(drawerRecord.netLi)} 元</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="订单信息">
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">申请时间</Typography.Text>
|
||||
<Typography.Text>{formatDate(drawerRecord.createdAt)}</Typography.Text>
|
||||
<Typography.Text type="secondary">审核时间</Typography.Text>
|
||||
<Typography.Text>{drawerRecord.approvedAt ? formatDate(drawerRecord.approvedAt) : '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary">打款时间</Typography.Text>
|
||||
<Typography.Text>{drawerRecord.paidAt ? formatDate(drawerRecord.paidAt) : '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary">备注/原因</Typography.Text>
|
||||
<Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{drawerRecord.rejectReason || drawerRecord.failureReason || drawerRecord.auditRemark || drawerRecord.applyRemark || '-'}
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Paragraph>暂无数据</Typography.Paragraph>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ img {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -78,9 +82,65 @@ img {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(16, 37, 66, 0.96) 0%, rgba(19, 51, 84, 0.94) 100%);
|
||||
color: var(--text-light);
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .admin-sidebar {
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
padding: 18px 10px;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .admin-brand {
|
||||
padding: 14px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.admin-brand-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .admin-brand-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .admin-brand-header > div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .admin-brand-header .ant-btn {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.admin-sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.admin-sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.admin-sidebar:hover::-webkit-scrollbar-thumb,
|
||||
.admin-sidebar:focus-within::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.admin-sidebar::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
AdminUserExpLogItem,
|
||||
LevelExpConfig,
|
||||
LoginLog,
|
||||
AppLoginLogRecord,
|
||||
LoginResult,
|
||||
McpContent,
|
||||
McpManagedStatus,
|
||||
@@ -66,6 +67,14 @@ import type {
|
||||
CreateAnnouncementDto,
|
||||
AdminFeedbackListItem,
|
||||
FeedbackDetailResponse,
|
||||
AppPlayerRecord,
|
||||
AppInviteCodeRecord,
|
||||
AppInviteCodeDetail,
|
||||
AppAssetTransactionRecord,
|
||||
AppPlayerOverview,
|
||||
AppWithdrawOrderRecord,
|
||||
AppRiskTagRecord,
|
||||
AppUserRiskTagRecord,
|
||||
} from '@/types/system'
|
||||
|
||||
export const authApi = {
|
||||
@@ -231,6 +240,69 @@ export const appUserAdminApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const appPlayersAdminApi = {
|
||||
getList(params: { page: number; pageSize: number; keyword?: string; isOnline?: boolean }) {
|
||||
return http.get<PagePayload<AppPlayerRecord>>('/admin/app-users', { params })
|
||||
},
|
||||
create(payload: { username: string; password: string; enable?: boolean; nickName?: string; welcomePhrase?: string }) {
|
||||
return http.post<Record<string, never>>('/admin/app-users', payload)
|
||||
},
|
||||
getById(id: number) {
|
||||
return http.get<AppPlayerRecord>(`/admin/app-users/${id}`)
|
||||
},
|
||||
getOverview(id: number) {
|
||||
return http.get<AppPlayerOverview>(`/admin/app-users/${id}/overview`)
|
||||
},
|
||||
update(id: number, payload: { enable?: boolean; welcomePhrase?: string }) {
|
||||
return http.patch<Record<string, never>>(`/admin/app-users/${id}`, payload)
|
||||
},
|
||||
resetPassword(id: number, payload: { newPassword: string }) {
|
||||
return http.post<Record<string, never>>(`/admin/app-users/${id}/reset-password`, payload)
|
||||
},
|
||||
toggleEnable(id: number) {
|
||||
return http.post<Record<string, never>>(`/admin/app-users/${id}/toggle-enable`)
|
||||
},
|
||||
delete(id: number) {
|
||||
return http.delete<Record<string, never>>(`/admin/app-users/${id}`)
|
||||
},
|
||||
recharge(
|
||||
id: number,
|
||||
payload: {
|
||||
assetType: 'account' | 'game_coin'
|
||||
rechargeType: 'offline' | 'online_not_arrived' | 'activity_subsidy' | 'manual_adjustment'
|
||||
deltaLi: number
|
||||
reason: string
|
||||
}
|
||||
) {
|
||||
return http.post<Record<string, never>>(`/admin/app-users/${id}/recharge`, payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const appInviteCodesAdminApi = {
|
||||
getList(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
status?: string
|
||||
createdByUserId?: number
|
||||
usedByUserId?: number
|
||||
codeLast4?: string
|
||||
}) {
|
||||
return http.get<PagePayload<AppInviteCodeRecord>>('/admin/app-invite-codes', { params })
|
||||
},
|
||||
getById(id: number) {
|
||||
return http.get<AppInviteCodeDetail>(`/admin/app-invite-codes/${id}`)
|
||||
},
|
||||
revoke(id: number) {
|
||||
return http.post<Record<string, never>>(`/admin/app-invite-codes/${id}/revoke`)
|
||||
},
|
||||
issue(payload: { userId: number; expireHours?: number }) {
|
||||
return http.post<{ code: string; codeLast4: string; expiresAt?: string | null }>('/admin/app-invite-codes/issue', payload)
|
||||
},
|
||||
clearUnused(payload: { userId: number; scope?: 'active' | 'all' }) {
|
||||
return http.post<Record<string, never>>('/admin/app-invite-codes/clear-unused', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const aiConfigApi = {
|
||||
getList() {
|
||||
return http.get<AIConfigRecord[]>('/admin/ai-config')
|
||||
@@ -404,6 +476,9 @@ export const sysParamsApi = {
|
||||
getParamsList(params?: Record<string, unknown>) {
|
||||
return http.get<PagePayload<SysParam>>('/sysParams/getSysParamsList', { params })
|
||||
},
|
||||
getChangeLogList(params?: Record<string, unknown>) {
|
||||
return http.get<PagePayload<Record<string, unknown>>>('/sysParams/getChangeLogList', { params })
|
||||
},
|
||||
createParam(payload: Partial<SysParam>) {
|
||||
return http.post<Record<string, never>>('/sysParams/createSysParams', payload)
|
||||
},
|
||||
@@ -427,6 +502,104 @@ export const loginLogApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const appLoginLogAdminApi = {
|
||||
getList(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
username?: string
|
||||
ip?: string
|
||||
status?: boolean
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
}) {
|
||||
return http.get<PagePayload<AppLoginLogRecord>>('/admin/app-login-logs', { params })
|
||||
},
|
||||
}
|
||||
|
||||
export const appAssetTransactionsAdminApi = {
|
||||
getList(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
userId?: number
|
||||
username?: string
|
||||
assetType?: string
|
||||
rechargeType?: string
|
||||
operatorUserId?: number
|
||||
keyword?: string
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
}) {
|
||||
return http.get<PagePayload<AppAssetTransactionRecord>>('/admin/app-asset-transactions', { params })
|
||||
},
|
||||
getById(id: number) {
|
||||
return http.get<AppAssetTransactionRecord>(`/admin/app-asset-transactions/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
export const appWithdrawOrdersAdminApi = {
|
||||
getList(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
userId?: number
|
||||
username?: string
|
||||
status?: string
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
}) {
|
||||
return http.get<PagePayload<AppWithdrawOrderRecord>>('/admin/app-withdraw-orders', { params })
|
||||
},
|
||||
getById(id: number) {
|
||||
return http.get<any>(`/admin/app-withdraw-orders/${id}`)
|
||||
},
|
||||
create(payload: {
|
||||
appUserId: number
|
||||
amountLi: number
|
||||
feeLi?: number
|
||||
channel?: string
|
||||
payeeAccount?: string
|
||||
payeeRealName?: string
|
||||
applyRemark?: string
|
||||
}) {
|
||||
return http.post<Record<string, never>>('/admin/app-withdraw-orders', payload)
|
||||
},
|
||||
approve(id: number, payload?: { auditRemark?: string }) {
|
||||
return http.post<Record<string, never>>(`/admin/app-withdraw-orders/${id}/approve`, payload ?? {})
|
||||
},
|
||||
reject(id: number, payload: { rejectReason: string; auditRemark?: string }) {
|
||||
return http.post<Record<string, never>>(`/admin/app-withdraw-orders/${id}/reject`, payload)
|
||||
},
|
||||
markPaid(id: number, payload?: { externalTxId?: string }) {
|
||||
return http.post<Record<string, never>>(`/admin/app-withdraw-orders/${id}/mark-paid`, payload ?? {})
|
||||
},
|
||||
markFailed(id: number, payload: { failureReason: string; externalTxId?: string }) {
|
||||
return http.post<Record<string, never>>(`/admin/app-withdraw-orders/${id}/mark-failed`, payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const appRiskTagsAdminApi = {
|
||||
getTags(params: { page: number; pageSize: number; keyword?: string }) {
|
||||
return http.get<PagePayload<AppRiskTagRecord>>('/admin/app-risk-tags', { params })
|
||||
},
|
||||
createTag(payload: { name: string; level: number; color?: string; desc?: string }) {
|
||||
return http.post<Record<string, never>>('/admin/app-risk-tags', payload)
|
||||
},
|
||||
updateTag(id: number, payload: { name: string; level: number; color?: string; desc?: string }) {
|
||||
return http.put<Record<string, never>>(`/admin/app-risk-tags/${id}`, payload)
|
||||
},
|
||||
deleteTag(id: number) {
|
||||
return http.delete<Record<string, never>>(`/admin/app-risk-tags/${id}`)
|
||||
},
|
||||
getUserTags(userId: number) {
|
||||
return http.get<AppUserRiskTagRecord[]>(`/admin/app-users/${userId}/risk-tags`)
|
||||
},
|
||||
addUserTag(userId: number, payload: { tagId: number; remark?: string }) {
|
||||
return http.post<Record<string, never>>(`/admin/app-users/${userId}/risk-tags`, payload)
|
||||
},
|
||||
removeUserTag(relationId: number) {
|
||||
return http.delete<Record<string, never>>(`/admin/app-user-risk-tags/${relationId}`)
|
||||
},
|
||||
}
|
||||
|
||||
export const operationApi = {
|
||||
getOperationList(params?: Record<string, unknown>) {
|
||||
return http.get<PagePayload<OperationRecord>>('/sysOperationRecord/getSysOperationRecordList', { params })
|
||||
|
||||
@@ -57,6 +57,9 @@ export const http = {
|
||||
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, data, method: 'put' })
|
||||
},
|
||||
patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, data, method: 'patch' })
|
||||
},
|
||||
delete<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'delete' })
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AppAssetTransactionsPage } from '@/features/appAssetTransactions/AppAssetTransactionsPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'financeAppAssetTransactions',
|
||||
}
|
||||
|
||||
export default AppAssetTransactionsPage
|
||||
|
||||
8
web-admin/src/router/pages/financeCenter/page.tsx
Normal file
8
web-admin/src/router/pages/financeCenter/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'financeCenter',
|
||||
}
|
||||
|
||||
export default createModulePage('financeCenter')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'withdrawManage',
|
||||
}
|
||||
|
||||
export default createModulePage('withdrawManage')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { WithdrawOrdersPage } from '@/features/withdrawOrders/WithdrawOrdersPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'withdrawOrders',
|
||||
}
|
||||
|
||||
export default WithdrawOrdersPage
|
||||
|
||||
8
web-admin/src/router/pages/gameCenter/page.tsx
Normal file
8
web-admin/src/router/pages/gameCenter/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'gameCenter',
|
||||
}
|
||||
|
||||
export default createModulePage('gameCenter')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AssetPolicyCenterPage } from '@/features/assetPolicyCenter/AssetPolicyCenterPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'assetPolicyCenter',
|
||||
}
|
||||
|
||||
export default AssetPolicyCenterPage
|
||||
|
||||
8
web-admin/src/router/pages/opsConfig/page.tsx
Normal file
8
web-admin/src/router/pages/opsConfig/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'opsConfig',
|
||||
}
|
||||
|
||||
export default createModulePage('opsConfig')
|
||||
|
||||
8
web-admin/src/router/pages/opsConfig/riskTags/page.tsx
Normal file
8
web-admin/src/router/pages/opsConfig/riskTags/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { RiskTagsPage } from '@/features/riskTags/RiskTagsPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'riskTags',
|
||||
}
|
||||
|
||||
export default RiskTagsPage
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SysParamChangeLogPage } from '@/features/params/SysParamChangeLogPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'sysParamChangeLog',
|
||||
}
|
||||
|
||||
export default SysParamChangeLogPage
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AppAssetTransactionsPage } from '@/features/appAssetTransactions/AppAssetTransactionsPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'appAssetTransactions',
|
||||
}
|
||||
|
||||
export default AppAssetTransactionsPage
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { AppLoginLogsPage } from '@/features/appLoginLogs/AppLoginLogsPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'appLoginLog',
|
||||
}
|
||||
|
||||
export default AppLoginLogsPage
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AppPlayersPage } from '@/features/appPlayers/AppPlayersPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'appPlayers',
|
||||
}
|
||||
|
||||
export default AppPlayersPage
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AdminInviteCodesPage } from '@/features/inviteCodes/AdminInviteCodesPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'inviteCodes',
|
||||
}
|
||||
|
||||
export default AdminInviteCodesPage
|
||||
|
||||
8
web-admin/src/router/pages/userCenter/page.tsx
Normal file
8
web-admin/src/router/pages/userCenter/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'userCenter',
|
||||
}
|
||||
|
||||
export default createModulePage('userCenter')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RegisterPolicyPage } from '@/features/registerPolicy/RegisterPolicyPage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'registerPolicy',
|
||||
}
|
||||
|
||||
export default RegisterPolicyPage
|
||||
|
||||
@@ -86,6 +86,133 @@ export type AppUserDetail = {
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export type AppPlayerRecord = {
|
||||
id: number
|
||||
uuid: string
|
||||
username: string
|
||||
enable: number
|
||||
nickName?: string
|
||||
welcomePhrase?: string
|
||||
accountBalanceLi?: number
|
||||
gameCoinBalanceLi?: number
|
||||
lastLoginAt?: string | null
|
||||
lastActiveAt?: string | null
|
||||
isOnline?: boolean
|
||||
}
|
||||
|
||||
export type AppPlayerOverview = {
|
||||
user: {
|
||||
id: number
|
||||
uuid: string
|
||||
username: string
|
||||
enable: number
|
||||
nickName?: string
|
||||
}
|
||||
asset: { accountBalanceLi: number; gameCoinBalanceLi: number }
|
||||
activity: {
|
||||
lastLoginAt?: string | null
|
||||
lastActiveAt?: string | null
|
||||
lastActiveIp?: string
|
||||
isOnline: boolean
|
||||
}
|
||||
recentLoginLogs: AppLoginLogRecord[]
|
||||
recentTx: Array<{
|
||||
id: number
|
||||
appUserId: number
|
||||
assetType: string
|
||||
rechargeType: string
|
||||
deltaLi: number
|
||||
beforeLi: number
|
||||
afterLi: number
|
||||
reason: string
|
||||
operatorUserId: number
|
||||
createdAt: string
|
||||
}>
|
||||
invite: {
|
||||
inviterUserId?: number
|
||||
inviterUsername?: string
|
||||
invitedCount: number
|
||||
hasUnusedCode: boolean
|
||||
unusedCodeLast4?: string
|
||||
unusedExpiresAt?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type AppInviteCodeStatus = 'unused' | 'used' | 'revoked' | 'expired'
|
||||
|
||||
export type AppInviteCodeRecord = {
|
||||
id: number
|
||||
createdByUserId: number
|
||||
codeLast4: string
|
||||
status: AppInviteCodeStatus
|
||||
expiresAt?: string | null
|
||||
usedByUserId?: number | null
|
||||
usedAt?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AppInviteCodeDetail = {
|
||||
code: {
|
||||
id: number
|
||||
createdByUserId: number
|
||||
codeLast4: string
|
||||
status: AppInviteCodeStatus
|
||||
expiresAt?: string | null
|
||||
usedByUserId?: number | null
|
||||
usedAt?: string |null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
inviter: { id: number; username: string }
|
||||
invitee?: { id: number; username: string }
|
||||
relation?: {
|
||||
inviterUserId: number
|
||||
inviteeUserId: number
|
||||
inviteCodeId: number
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
|
||||
export type AppWithdrawOrderStatus = 'pending' | 'approved' | 'rejected' | 'paid' | 'failed' | string
|
||||
|
||||
export type AppWithdrawOrderRecord = {
|
||||
id: number
|
||||
appUserId: number
|
||||
username: string
|
||||
amountLi: number
|
||||
feeLi: number
|
||||
netLi: number
|
||||
status: AppWithdrawOrderStatus
|
||||
operatorUserId: number
|
||||
approvedBy?: number
|
||||
approvedAt?: string | null
|
||||
paidBy?: number
|
||||
paidAt?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AppRiskTagRecord = {
|
||||
id: number
|
||||
name: string
|
||||
level: number
|
||||
color?: string
|
||||
desc?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AppUserRiskTagRecord = {
|
||||
id: number
|
||||
appUserId: number
|
||||
tagId: number
|
||||
operatorUserId: number
|
||||
remark?: string
|
||||
createdAt: string
|
||||
tagName?: string
|
||||
tagLevel?: number
|
||||
tagColor?: string
|
||||
}
|
||||
|
||||
export type RiskReviewStatus = 'none' | 'pending_review' | 'reviewed'
|
||||
|
||||
export type RiskAccountRecord = {
|
||||
@@ -324,6 +451,33 @@ export type LoginLog = BaseEntity & {
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export type AppLoginLogRecord = {
|
||||
id: number
|
||||
appUserId: number
|
||||
username: string
|
||||
ip: string
|
||||
referer?: string
|
||||
userAgent?: string
|
||||
status: boolean
|
||||
errorMessage?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AppAssetTransactionRecord = {
|
||||
id: number
|
||||
appUserId: number
|
||||
username: string
|
||||
assetType: 'account' | 'game_coin' | string
|
||||
rechargeType: 'offline' | 'online_not_arrived' | 'activity_subsidy' | 'manual_adjustment' | string
|
||||
deltaLi: number
|
||||
beforeLi: number
|
||||
afterLi: number
|
||||
reason: string
|
||||
operatorUserId: number
|
||||
operatorName?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type OperationRecord = BaseEntity & {
|
||||
ip: string
|
||||
method: string
|
||||
|
||||
Reference in New Issue
Block a user