新增开发 Mock 后台菜单与造单能力

非生产环境在后台提供 lewan/feifei 领取造单与 91 查单模拟,CLI 复用同一服务,避免依赖真实平台联调。
This commit is contained in:
yml2213
2026-07-10 13:35:42 +08:00
parent bb060e81a4
commit 3c0d1f108c
13 changed files with 1593 additions and 533 deletions
+2
View File
@@ -21,6 +21,7 @@ const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPl
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
@@ -67,6 +68,7 @@ export default function App() {
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
<Route path="dev-mock" element={<AdminDevMockPage />} />
<Route element={<RequireRole roles={['admin']} />}>
<Route path="users" element={<AdminUsersPage />} />
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
+23 -2
View File
@@ -1,5 +1,6 @@
import {
AuditOutlined,
BugOutlined,
DashboardOutlined,
FileSearchOutlined,
LogoutOutlined,
@@ -15,10 +16,11 @@ import {
} from '@ant-design/icons'
import { App, Button, Dropdown, Layout, Menu, Space, Typography } from 'antd'
import type { MenuProps } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router'
import { logoutAdmin } from '@/services/admin'
import { fetchDevMockStatus, logoutAdmin } from '@/services/admin'
import {
clearAdminSession,
getAdminRole,
@@ -40,6 +42,14 @@ export default function AdminLayout() {
const username = getAdminUsername() || 'admin'
const expiresAt = getAdminTokenExpiresAt()
const devMockStatusQuery = useQuery({
queryKey: ['admin-dev-mock-status'],
queryFn: () => fetchDevMockStatus(),
staleTime: 60_000,
retry: false,
})
const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled)
const menuItems = useMemo<MenuProps['items']>(() => {
const operationItems: MenuProps['items'] = [
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
@@ -58,6 +68,17 @@ export default function AdminLayout() {
},
]
if (devMockEnabled) {
items.push({
key: 'group-dev',
type: 'group',
label: '开发',
children: [
{ key: '/admin/dev-mock', icon: <BugOutlined />, label: '开发 Mock' },
],
})
}
if (role === 'admin') {
items.push(
{
@@ -80,7 +101,7 @@ export default function AdminLayout() {
}
return items
}, [role])
}, [devMockEnabled, role])
async function submitLogout() {
try {
@@ -0,0 +1,359 @@
import { BugOutlined, CopyOutlined, LinkOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Col,
Divider,
Form,
Input,
Result,
Row,
Select,
Space,
Typography,
} from 'antd'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import PageHeader from '@/components/admin/PageHeader'
import {
buildOpen91QueryDevMock,
createFeifeiDevMock,
createLewanDevMock,
fetchDevMockStatus,
type DevMockCreateResult,
type DevMockOpen91QueryResult,
} from '@/services/admin'
export default function AdminDevMockPage() {
const { message } = App.useApp()
const [lastCreate, setLastCreate] = useState<DevMockCreateResult | null>(null)
const [lastQuery, setLastQuery] = useState<DevMockOpen91QueryResult | null>(null)
const statusQuery = useQuery({
queryKey: ['admin-dev-mock-status'],
queryFn: () => fetchDevMockStatus(),
retry: false,
})
const status = statusQuery.data?.data
const enabled = Boolean(status?.enabled)
const lewanMutation = useMutation({
mutationFn: createLewanDevMock,
onSuccess: (response) => {
setLastCreate(response.data)
message.success('lewan mock 已生成')
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '生成失败')
},
})
const feifeiMutation = useMutation({
mutationFn: createFeifeiDevMock,
onSuccess: (response) => {
setLastCreate(response.data)
message.success('feifei mock 已生成')
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '生成失败')
},
})
const open91Mutation = useMutation({
mutationFn: buildOpen91QueryDevMock,
onSuccess: (response) => {
setLastQuery(response.data)
message.success('91 查单请求已生成')
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '生成失败')
},
})
const defaultOrderNo = useMemo(
() => lastCreate?.orderNo || '',
[lastCreate?.orderNo],
)
if (statusQuery.isLoading) {
return (
<section className="page-stack">
<PageHeader title="开发 Mock" description="加载中…" />
</section>
)
}
if (statusQuery.error || !enabled) {
return (
<section className="page-stack">
<PageHeader title="开发 Mock" description="仅开发环境可用。" />
<Result
status="403"
title="当前环境未开启开发 Mock"
subTitle={
statusQuery.error instanceof Error
? statusQuery.error.message
: '生产环境默认关闭。本地开发请使用 docker-compose.dev 或设置 ENABLE_DEV_MOCK=1。'
}
/>
</section>
)
}
return (
<section className="page-stack">
<PageHeader
title="开发 Mock"
description="一键造单、测领取页与 91 查单,无需真实 91 / 快手 / feifei 环境。"
extra={
<Typography.Text type="secondary">
NODE_ENV={status?.nodeEnv || '-'}
</Typography.Text>
}
/>
<Alert
type="info"
showIcon
icon={<BugOutlined />}
message="Mock 会写入开发库真实订单/任务,但 lewan 兑换走 mock 成功路径,feifei 不调用真实平台下单。"
description="建议 UID 与默认角色一致:lewan 默认 10001feifei 默认 166909256。"
/>
<Row gutter={[16, 16]}>
<Col xs={24} xl={12}>
<Card title="1. lewan 领取(强制 UID" bordered={false}>
<Form
layout="vertical"
initialValues={{
step: 'binding',
uid: status?.defaultUid || '10001',
productNo: '套餐_1----3676797936',
}}
onFinish={(values) => lewanMutation.mutate(values)}
>
<Form.Item
label="页面步骤"
name="step"
tooltip="uid=只到填 UIDbinding=已预填 UID 且角色匹配;confirm/result=后续步骤"
>
<Select
options={[
{ value: 'uid', label: 'uid · 待填 UID' },
{ value: 'binding', label: 'binding · 绑定且 UID 已匹配(推荐)' },
{ value: 'confirm', label: 'confirm · 待确认兑换' },
{ value: 'result', label: 'result · 已完成' },
]}
/>
</Form.Item>
<Form.Item label="游戏 UID" name="uid" rules={[{ required: true, message: '请填 UID' }]}>
<Input placeholder="10001" />
</Form.Item>
<Form.Item label="91 商品 productNo" name="productNo">
<Input placeholder="套餐_1----3676797936" />
</Form.Item>
<Form.Item label="订单号 orderNo(可空自动生成)" name="orderNo">
<Input placeholder="留空自动 MOCK91…" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={lewanMutation.isPending} block>
lewan Mock
</Button>
</Form>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card title="2. feifei 领取(拼 UID 到 H5" bordered={false}>
<Form
layout="vertical"
initialValues={{
step: 'ready',
uid: '166909256',
productName: '套装-浪漫天命',
}}
onFinish={(values) => feifeiMutation.mutate(values)}
>
<Form.Item label="页面步骤" name="step">
<Select
options={[
{ value: 'uid', label: 'uid · 待填 UID' },
{ value: 'ready', label: 'ready · 已填 UID,可打开 H5(推荐)' },
{ value: 'completed', label: 'completed · 模拟成功' },
{ value: 'failed', label: 'failed · 模拟失败' },
]}
/>
</Form.Item>
<Form.Item label="游戏 UID" name="uid" rules={[{ required: true, message: '请填 UID' }]}>
<Input placeholder="166909256" />
</Form.Item>
<Form.Item label="商品名" name="productName">
<Input />
</Form.Item>
<Form.Item label="H5 链接(可空用 mock 地址)" name="h5Url">
<Input placeholder="http://skin-exchange.../h5/bind?code=..." />
</Form.Item>
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
<Input placeholder="留空自动 MOCKFF…" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={feifeiMutation.isPending} block>
feifei Mock
</Button>
</Form>
</Card>
</Col>
<Col xs={24}>
<Card title="3. 模拟 91 查单(open-91/query" bordered={false}>
<Form
layout="inline"
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
key={defaultOrderNo}
onFinish={(values) =>
open91Mutation.mutate({
orderNo: values.orderNo,
execute: values.execute === '1',
})
}
>
<Form.Item
label="orderNo"
name="orderNo"
rules={[{ required: true, message: '填写上方生成的订单号' }]}
>
<Input style={{ width: 280 }} placeholder="MOCK91…" />
</Form.Item>
<Form.Item label="执行" name="execute">
<Select
style={{ width: 180 }}
options={[
{ value: '1', label: '生成并请求本机' },
{ value: '0', label: '仅生成请求体' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={open91Mutation.isPending}>
91
</Button>
</Form.Item>
</Form>
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
POST /api/v1/open/91/orders/query KAQUAN91_SECRET32
</Typography.Paragraph>
</Card>
</Col>
</Row>
{lastCreate ? (
<Card
title="最近生成结果"
extra={
<Space>
<Link to={`/admin/orders/${lastCreate.orderId}`}> #{lastCreate.orderId}</Link>
<Link to={`/admin/tasks/${lastCreate.taskId}`}> #{lastCreate.taskId}</Link>
</Space>
}
>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<Typography.Text type="secondary"></Typography.Text>
<div>
<Typography.Text strong>{lastCreate.platform}</Typography.Text>
</div>
</Col>
<Col xs={24} md={12}>
<Typography.Text type="secondary"> / UID</Typography.Text>
<div>
<Typography.Text strong>
{lastCreate.step} / {lastCreate.expectedUid}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={12}>
<Typography.Text type="secondary">91 </Typography.Text>
<div>
<Typography.Text copyable>{lastCreate.orderNo}</Typography.Text>
</div>
</Col>
<Col xs={24} md={12}>
<Typography.Text type="secondary"></Typography.Text>
<div>{lastCreate.productName}</div>
</Col>
<Col span={24}>
<Typography.Text type="secondary"></Typography.Text>
<div>
<Typography.Link href={lastCreate.claimUrl} target="_blank" rel="noreferrer">
<LinkOutlined /> {lastCreate.claimUrl}
</Typography.Link>
</div>
<Space style={{ marginTop: 8 }} wrap>
<Button
icon={<CopyOutlined />}
onClick={async () => {
await navigator.clipboard.writeText(lastCreate.claimUrl)
message.success('已复制领取链接')
}}
>
</Button>
<Button type="primary" href={lastCreate.claimUrl} target="_blank">
</Button>
</Space>
</Col>
<Col span={24}>
<Alert
type="success"
showIcon
message="操作提示"
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{lastCreate.tips.map((tip) => (
<li key={tip}>{tip}</li>
))}
<li>{lastCreate.open91QueryHint}</li>
</ul>
}
/>
</Col>
</Row>
</Card>
) : null}
{lastQuery ? (
<Card title="91 查单结果">
<Typography.Paragraph>
<Typography.Text type="secondary">Endpoint</Typography.Text>
<Typography.Text code copyable>
{lastQuery.endpoint}
</Typography.Text>
</Typography.Paragraph>
<Typography.Paragraph>
<Typography.Text type="secondary">Request</Typography.Text>
</Typography.Paragraph>
<pre className="dev-mock-pre">{JSON.stringify(lastQuery.requestBody, null, 2)}</pre>
<Typography.Paragraph>
<Typography.Text type="secondary">curl</Typography.Text>
<Typography.Text code copyable>
{lastQuery.curl}
</Typography.Text>
</Typography.Paragraph>
{lastQuery.response ? (
<>
<Divider />
<Typography.Paragraph>
<Typography.Text type="secondary">Response</Typography.Text>
</Typography.Paragraph>
<pre className="dev-mock-pre">{JSON.stringify(lastQuery.response, null, 2)}</pre>
</>
) : null}
</Card>
) : null}
</section>
)
}
@@ -0,0 +1,68 @@
import { apiGet, apiPost } from '@/lib/http'
export type DevMockStatus = {
enabled: boolean
nodeEnv: string
defaultUid: string
platforms: Array<{
key: string
name: string
description: string
steps: string[]
}>
}
export type DevMockCreateResult = {
platform: 'lewan' | 'feifei'
orderId: number
orderNo: string
taskId: number
taskNo: string
claimToken: string
claimUrl: string
frontendClaimUrl: string
expectedUid: string
step: string
productName: string
tips: string[]
open91QueryHint: string
}
export type DevMockOpen91QueryResult = {
endpoint: string
requestBody: Record<string, unknown>
curl: string
response: unknown
}
export function fetchDevMockStatus() {
return apiGet<DevMockStatus>('/api/v1/admin/dev-mock/status')
}
export function createLewanDevMock(payload: {
step?: string
uid?: string
orderNo?: string
productNo?: string
}) {
return apiPost<DevMockCreateResult>('/api/v1/admin/dev-mock/lewan', payload)
}
export function createFeifeiDevMock(payload: {
step?: string
uid?: string
orderNo?: string
productName?: string
productCode?: string
h5Url?: string
}) {
return apiPost<DevMockCreateResult>('/api/v1/admin/dev-mock/feifei', payload)
}
export function buildOpen91QueryDevMock(payload: {
orderNo: string
execute?: boolean
baseUrl?: string
}) {
return apiPost<DevMockOpen91QueryResult>('/api/v1/admin/dev-mock/open91/query', payload)
}
@@ -6,3 +6,4 @@ export * from './platform-config'
export * from './kuaishou-industry'
export * from './orders'
export * from './tasks'
export * from './dev-mock'
+12
View File
@@ -1263,3 +1263,15 @@ select {
max-width: 116px;
}
}
.dev-mock-pre {
margin: 0;
padding: 12px 14px;
overflow: auto;
max-height: 360px;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.5;
}