新增几大中心功能

This commit is contained in:
Administrator
2026-04-23 15:29:07 +08:00
parent 5c2a146a44
commit c6354ee065
123 changed files with 13074 additions and 229 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

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

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

View File

@@ -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/11=开启互转)', 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/11=开启提现)', 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>
)
}

View File

@@ -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: '超级管理员',

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

View File

@@ -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' }}

View File

@@ -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>

View File

@@ -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' },

View File

@@ -156,6 +156,7 @@ export function ParamsManagementPage() {
</Space>
</Form>
</Card>
<Card className="glass-panel page-panel">
<Table
rowKey="ID"

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

View 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/11 表示注册需要验证码)',
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>
)
}

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

View File

@@ -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)) {

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

View File

@@ -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 {

View File

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

View File

@@ -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' })
},

View File

@@ -0,0 +1,8 @@
import { AppAssetTransactionsPage } from '@/features/appAssetTransactions/AppAssetTransactionsPage'
export const routeMeta = {
menuName: 'financeAppAssetTransactions',
}
export default AppAssetTransactionsPage

View File

@@ -0,0 +1,8 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'financeCenter',
}
export default createModulePage('financeCenter')

View File

@@ -0,0 +1,8 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'withdrawManage',
}
export default createModulePage('withdrawManage')

View File

@@ -0,0 +1,8 @@
import { WithdrawOrdersPage } from '@/features/withdrawOrders/WithdrawOrdersPage'
export const routeMeta = {
menuName: 'withdrawOrders',
}
export default WithdrawOrdersPage

View File

@@ -0,0 +1,8 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'gameCenter',
}
export default createModulePage('gameCenter')

View File

@@ -0,0 +1,8 @@
import { AssetPolicyCenterPage } from '@/features/assetPolicyCenter/AssetPolicyCenterPage'
export const routeMeta = {
menuName: 'assetPolicyCenter',
}
export default AssetPolicyCenterPage

View File

@@ -0,0 +1,8 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'opsConfig',
}
export default createModulePage('opsConfig')

View File

@@ -0,0 +1,8 @@
import { RiskTagsPage } from '@/features/riskTags/RiskTagsPage'
export const routeMeta = {
menuName: 'riskTags',
}
export default RiskTagsPage

View File

@@ -0,0 +1,7 @@
import { SysParamChangeLogPage } from '@/features/params/SysParamChangeLogPage'
export const routeMeta = {
menuName: 'sysParamChangeLog',
}
export default SysParamChangeLogPage

View File

@@ -0,0 +1,8 @@
import { AppAssetTransactionsPage } from '@/features/appAssetTransactions/AppAssetTransactionsPage'
export const routeMeta = {
menuName: 'appAssetTransactions',
}
export default AppAssetTransactionsPage

View File

@@ -0,0 +1,7 @@
import { AppLoginLogsPage } from '@/features/appLoginLogs/AppLoginLogsPage'
export const routeMeta = {
menuName: 'appLoginLog',
}
export default AppLoginLogsPage

View File

@@ -0,0 +1,8 @@
import { AppPlayersPage } from '@/features/appPlayers/AppPlayersPage'
export const routeMeta = {
menuName: 'appPlayers',
}
export default AppPlayersPage

View File

@@ -0,0 +1,8 @@
import { AdminInviteCodesPage } from '@/features/inviteCodes/AdminInviteCodesPage'
export const routeMeta = {
menuName: 'inviteCodes',
}
export default AdminInviteCodesPage

View File

@@ -0,0 +1,8 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'userCenter',
}
export default createModulePage('userCenter')

View File

@@ -0,0 +1,8 @@
import { RegisterPolicyPage } from '@/features/registerPolicy/RegisterPolicyPage'
export const routeMeta = {
menuName: 'registerPolicy',
}
export default RegisterPolicyPage

View File

@@ -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