重构: 手续费改为百分比/固定二选一,清理旧 Skin/Order/ShipLog 演示链路

手续费:
- 新增 fee_type 字段(rate/fixed),百分比与固定金额二选一,不再叠加
- calculateServiceFee 按 fee_type 分支计算
- 商户创建/更新校验 fee_type,订单落库快照 fee_type
- 前端表单改为下拉选择手续费类型,动态显示对应输入框
- 测试拆为 TestFeeRate + TestFeeFixed

清理旧链路:
- 删除旧 Skin/Order/ShipLog 模型及 OrderService/SkinService
- Dashboard 迁移到 FulfillmentService
- 上游 /api/open/v1 改为基于 FulfillmentOrder 实现,接口契约不变
- 推送留痕改用 AuditLog,不再建 ShipLog 表
- 删除前端 Skins/Orders/ShipLogs/Distributors 页面及路由、菜单、API、类型
- 新增迁移 003: 添加 fee_type 列并 DROP 旧表
This commit is contained in:
yml2213
2026-07-30 14:12:27 +08:00
parent 83429456a6
commit bafe41a3ea
36 changed files with 1247 additions and 2446 deletions
-23
View File
@@ -6,15 +6,10 @@ import MainLayout from './layouts/MainLayout'
import Login from './pages/Login'
import Register from './pages/Register'
import Dashboard from './pages/Dashboard'
import Skins from './pages/Skins'
import Orders from './pages/Orders'
import Distributors from './pages/Distributors'
import ShipLogs from './pages/ShipLogs'
import OpenApiDocs from './pages/OpenApiDocs'
import MerchantCenter from './pages/MerchantCenter'
import PlatformMerchants from './pages/PlatformMerchants'
import type { ReactNode } from 'react'
function PrivateRoute({ children }: { children: ReactNode }) {
const { token } = useAuth()
if (!token) return <Navigate to="/login" replace />
@@ -41,17 +36,7 @@ function AppRoutes() {
}
>
<Route index element={<Dashboard />} />
<Route path="skins" element={<Skins />} />
<Route path="orders" element={<Orders />} />
<Route path="merchant-center" element={<MerchantCenter />} />
<Route
path="distributors"
element={
<AdminRoute>
<Distributors />
</AdminRoute>
}
/>
<Route
path="platform-merchants"
element={
@@ -60,14 +45,6 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="ship-logs"
element={
<AdminRoute>
<ShipLogs />
</AdminRoute>
}
/>
<Route
path="open-api"
element={
+10 -36
View File
@@ -10,10 +10,7 @@ import type {
Merchant,
MerchantMember,
MerchantProduct,
Order,
PageResult,
ShipLog,
Skin,
User,
WalletAccount,
WalletLedgerEntry,
@@ -39,33 +36,6 @@ export const dashboardApi = {
request.get('/dashboard').then((r) => r.data.data as DashboardStats),
}
export const skinApi = {
list: (params?: Record<string, unknown>) =>
request.get('/skins', { params }).then((r) => r.data.data as PageResult<Skin>),
get: (id: number) =>
request.get(`/skins/${id}`).then((r) => r.data.data as Skin),
create: (data: Partial<Skin>) =>
request.post('/skins', data).then((r) => r.data.data as Skin),
update: (id: number, data: Partial<Skin>) =>
request.put(`/skins/${id}`, data).then((r) => r.data.data),
remove: (id: number) =>
request.delete(`/skins/${id}`).then((r) => r.data.data),
}
export const orderApi = {
list: (params?: Record<string, unknown>) =>
request.get('/orders', { params }).then((r) => r.data.data as PageResult<Order>),
create: (data: {
skin_id: number
buyer_name?: string
remark?: string
status?: string
distributor_id?: number
}) => request.post('/orders', data).then((r) => r.data.data as Order),
updateStatus: (id: number, status: string) =>
request.patch(`/orders/${id}/status`, { status }).then((r) => r.data.data),
}
export const userApi = {
list: (params?: Record<string, unknown>) =>
request.get('/users', { params }).then((r) => r.data.data as PageResult<User>),
@@ -75,11 +45,6 @@ export const userApi = {
request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data),
}
export const shipLogApi = {
list: (params?: Record<string, unknown>) =>
request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult<ShipLog>),
}
export const merchantApi = {
current: () =>
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }),
@@ -132,8 +97,17 @@ export const platformApi = {
name: string
contact_name?: string
contact_info?: string
owner_user_id: number
owner_user_id?: number
owner_username?: string
owner_password?: string
owner_nickname?: string
features?: string
fee_type?: 'rate' | 'fixed'
fee_rate_bp?: number
fee_fixed_amount?: number
}) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant),
updateMerchant: (id: number, data: Partial<Merchant>) =>
request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data),
addMember: (
merchantId: number,
data: { user_id: number; role: MerchantMember['role']; is_default?: boolean },
+2 -10
View File
@@ -11,14 +11,10 @@ import {
} from 'antd'
import {
DashboardOutlined,
SkinOutlined,
ShoppingOutlined,
TeamOutlined,
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SendOutlined,
ApiOutlined,
ShopOutlined,
} from '@ant-design/icons'
@@ -39,15 +35,11 @@ export default function MainLayout() {
const menuItems: MenuProps['items'] = useMemo(() => {
const items: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/skins', icon: <SkinOutlined />, label: '皮肤商品' },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单管理' },
{ key: '/merchant-center', icon: <ShopOutlined />, label: '商户中心' },
]
if (isAdmin) {
items.push(
{ key: '/distributors', icon: <TeamOutlined />, label: '分销商' },
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
{ key: '/ship-logs', icon: <SendOutlined />, label: '发货记录' },
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
)
}
@@ -81,7 +73,7 @@ export default function MainLayout() {
letterSpacing: 1,
}}
>
{collapsed ? '皮肤' : '皮肤分销系统'}
{collapsed ? '供货' : '皮肤供货平台'}
</div>
<Menu
theme="dark"
@@ -112,7 +104,7 @@ export default function MainLayout() {
<Avatar size="small" icon={<UserOutlined />} />
<Typography.Text>
{user?.nickname || user?.username}
{isAdmin ? '(管理员)' : '分销商)'}
{isAdmin ? '平台管理员)' : '(商户账号'}
</Typography.Text>
</Space>
</Dropdown>
+5 -6
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'
import { Card, Col, Row, Statistic, Typography, Spin, message } from 'antd'
import {
SkinOutlined,
TeamOutlined,
ShopOutlined,
ShoppingOutlined,
DollarOutlined,
PercentageOutlined,
@@ -39,12 +38,12 @@ export default function Dashboard() {
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Statistic title="皮肤商品" value={stats?.skin_count ?? 0} prefix={<SkinOutlined />} />
<Statistic title="商户商品" value={stats?.product_count ?? 0} prefix={<ShoppingOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Statistic title="分销商" value={stats?.distributor_count ?? 0} prefix={<TeamOutlined />} />
<Statistic title="平台商户" value={stats?.merchant_count ?? 0} prefix={<ShopOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
@@ -66,8 +65,8 @@ export default function Dashboard() {
<Col xs={24} sm={12} lg={8}>
<Card>
<Statistic
title="累计佣金"
value={stats?.total_commission ?? 0}
title="平台手续费"
value={stats?.total_fees ?? 0}
precision={2}
prefix={<PercentageOutlined />}
suffix="元"
-169
View File
@@ -1,169 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Form,
Input,
Modal,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd'
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { userApi } from '../api'
import type { User } from '../types'
export default function Distributors() {
const [list, setList] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [form] = Form.useForm()
const load = useCallback(async () => {
setLoading(true)
try {
const data = await userApi.list({
page,
size,
keyword,
role: 'distributor',
})
setList(data.list || [])
setTotal(data.total)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [page, size, keyword])
useEffect(() => {
load()
}, [load])
const onSubmit = async () => {
const values = await form.validateFields()
try {
await userApi.create({ ...values, role: 'distributor' })
message.success('创建成功')
setOpen(false)
form.resetFields()
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
}
const toggleStatus = async (record: User, checked: boolean) => {
try {
await userApi.updateStatus(record.id, checked ? 1 : 0)
message.success('状态已更新')
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
}
const columns: ColumnsType<User> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '用户名', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{
title: '邀请码',
dataIndex: 'invite_code',
render: (v: string) => <Tag color="blue">{v}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (v: number, record) => (
<Switch
checkedChildren="启用"
unCheckedChildren="禁用"
checked={v === 1}
onChange={(checked) => toggleStatus(record, checked)}
/>
),
},
{
title: '注册时间',
dataIndex: 'created_at',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<Input.Search
placeholder="搜索用户名/昵称"
allowClear
onSearch={(v) => {
setPage(1)
setKeyword(v)
}}
style={{ width: 220 }}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields()
setOpen(true)
}}
>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal title="新增分销商" open={open} onOk={onSubmit} onCancel={() => setOpen(false)} destroyOnClose>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
<Input />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6 }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+2 -2
View File
@@ -36,7 +36,7 @@ export default function Login() {
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
@@ -55,7 +55,7 @@ export default function Login() {
</Form>
<div style={{ textAlign: 'center' }}>
<Typography.Text type="secondary">
<Link to="/register"></Link>
<Link to="/register"></Link>
</Typography.Text>
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0, textAlign: 'center' }}>
+120 -32
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
Descriptions,
@@ -103,11 +103,14 @@ export default function MerchantCenter() {
const canManage = merchantRole === 'owner' || merchantRole === 'operator'
const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
const loadCurrent = useCallback(async () => {
const data = await merchantApi.current()
setMerchant(data.merchant)
setMerchantRole(data.role)
return data
}, [])
const loadProducts = useCallback(async (page = products.page, size = products.size) => {
@@ -120,37 +123,86 @@ export default function MerchantCenter() {
setOrders(data)
}, [orders.page, orders.size])
const loadWallet = useCallback(async (page = ledger.page, size = ledger.size) => {
const [walletData, ledgerData] = await Promise.all([
merchantApi.wallet(),
merchantApi.ledger({ page, size }),
])
const loadWallet = useCallback(async (
page = ledger.page,
size = ledger.size,
role = merchantRole,
) => {
const walletData = await merchantApi.wallet()
setWallet(walletData)
setLedger(ledgerData)
}, [ledger.page, ledger.size])
if (role === 'owner' || role === 'finance') {
const ledgerData = await merchantApi.ledger({ page, size })
setLedger(ledgerData)
} else {
setLedger({ list: [], total: 0, page, size })
}
}, [ledger.page, ledger.size, merchantRole])
const loadIntegrations = useCallback(async () => {
const [clientData, callbackData, memberData] = await Promise.all([
merchantApi.apiClients(),
merchantApi.callbacks(),
merchantApi.members(),
])
const loadAPIClients = useCallback(async (role = merchantRole) => {
if (role !== 'owner' && role !== 'operator') {
setApiClients([])
return
}
const clientData = await merchantApi.apiClients()
setApiClients(clientData || [])
}, [merchantRole])
const loadCallbacks = useCallback(async (role = merchantRole) => {
if (role !== 'owner' && role !== 'operator') {
setCallbacks([])
return
}
const callbackData = await merchantApi.callbacks()
setCallbacks(callbackData || [])
}, [merchantRole])
const loadMembers = useCallback(async (role = merchantRole) => {
if (role !== 'owner' && role !== 'operator') {
setMembers([])
return
}
const memberData = await merchantApi.members()
setMembers(memberData || [])
}, [])
}, [merchantRole])
const loadActiveTab = useCallback(async (role = merchantRole, tab = activeTab) => {
switch (tab) {
case 'products':
await loadProducts()
return
case 'orders':
await loadOrders()
return
case 'wallet':
await loadWallet(undefined, undefined, role)
return
case 'api':
await loadAPIClients(role)
return
case 'callbacks':
await loadCallbacks(role)
return
case 'members':
await loadMembers(role)
return
}
}, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantRole])
const loadAll = useCallback(async () => {
setLoading(true)
try {
await loadCurrent()
await Promise.all([loadProducts(), loadOrders(), loadWallet(), loadIntegrations()])
const current = await loadCurrent()
const nextTab = resolveEnabledTab(activeTab, current.merchant.features)
if (nextTab !== activeTab) {
setActiveTab(nextTab)
}
await loadActiveTab(current.role, nextTab)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [loadCurrent, loadProducts, loadOrders, loadWallet, loadIntegrations])
}, [activeTab, loadActiveTab, loadCurrent])
useEffect(() => {
loadAll()
@@ -232,7 +284,7 @@ export default function MerchantCenter() {
setApiCredential(credential)
setApiClientOpen(false)
message.success('API 客户端已创建')
loadIntegrations()
loadAPIClients()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
@@ -249,7 +301,7 @@ export default function MerchantCenter() {
setCallbackCredential(credential)
setCallbackOpen(false)
message.success('回调已创建')
loadIntegrations()
loadCallbacks()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
@@ -265,7 +317,7 @@ export default function MerchantCenter() {
})
setMemberOpen(false)
message.success('成员已添加')
loadIntegrations()
loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败')
}
@@ -275,7 +327,7 @@ export default function MerchantCenter() {
try {
await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
message.success('状态已更新')
loadIntegrations()
loadAPIClients()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
@@ -285,7 +337,7 @@ export default function MerchantCenter() {
try {
await merchantApi.updateCallbackStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
message.success('状态已更新')
loadIntegrations()
loadCallbacks()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
@@ -314,7 +366,9 @@ export default function MerchantCenter() {
{ title: '商户单号', dataIndex: 'client_order_no', width: 160, ellipsis: true },
{ title: 'SKU', dataIndex: 'product_sku', width: 150, render: (v) => <Typography.Text code>{v}</Typography.Text> },
{ title: '商品', dataIndex: 'product_name', ellipsis: true },
{ title: '金额', dataIndex: 'amount', width: 100, render: money },
{ title: '基础金额', dataIndex: 'base_amount', width: 100, render: money },
{ title: '手续费', dataIndex: 'service_fee_amount', width: 100, render: money },
{ title: '扣款合计', dataIndex: 'amount', width: 100, render: money },
{ title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag },
{ title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag },
{ title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' },
@@ -399,6 +453,7 @@ export default function MerchantCenter() {
{
key: 'products',
label: '商品',
disabled: !hasFeature('products'),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -419,6 +474,7 @@ export default function MerchantCenter() {
{
key: 'orders',
label: '履约订单',
disabled: !hasFeature('orders'),
children: (
<Table
rowKey="id"
@@ -434,6 +490,7 @@ export default function MerchantCenter() {
{
key: 'wallet',
label: '钱包',
disabled: !hasFeature('wallet'),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -455,20 +512,25 @@ export default function MerchantCenter() {
</Button>
)}
</Space>
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
pagination={pageConfig(ledger, loadWallet)}
/>
{canFinance ? (
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
pagination={pageConfig(ledger, loadWallet)}
/>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
),
},
{
key: 'api',
label: 'API 客户端',
disabled: !hasFeature('api'),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -486,6 +548,7 @@ export default function MerchantCenter() {
{
key: 'callbacks',
label: '回调',
disabled: !hasFeature('callbacks'),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -679,6 +742,31 @@ function centsToYuan(value?: number | null) {
return Number(((value || 0) / 100).toFixed(2))
}
function featuresToList(features?: string) {
const list = (features || '')
.split(/[,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks']
}
function resolveEnabledTab(current: string, features?: string) {
const enabled = new Set(featuresToList(features))
const tabFeatures: Record<string, string | null> = {
products: 'products',
orders: 'orders',
wallet: 'wallet',
api: 'api',
callbacks: 'callbacks',
members: null,
}
const feature = tabFeatures[current]
if (feature === null || enabled.has(feature)) {
return current
}
return ['products', 'orders', 'wallet', 'api', 'callbacks'].find((key) => enabled.has(key)) || 'members'
}
function money(value?: number | null) {
return `¥${centsToYuan(value).toFixed(2)}`
}
-419
View File
@@ -1,419 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { orderApi, skinApi } from '../api'
import { useAuth } from '../store/auth'
import type { Order, Skin } from '../types'
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待支付' },
paid: { color: 'blue', text: '已支付' },
delivering: { color: 'cyan', text: '发货中' },
delivered: { color: 'green', text: '已交付' },
ship_failed: { color: 'red', text: '发货失败' },
cancelled: { color: 'default', text: '已取消' },
}
export default function Orders() {
const { isAdmin } = useAuth()
const [list, setList] = useState<Order[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [status, setStatus] = useState<string | undefined>()
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [skins, setSkins] = useState<Skin[]>([])
const [creating, setCreating] = useState(false)
const [createdOrder, setCreatedOrder] = useState<Order | null>(null)
const [form] = Form.useForm()
const load = useCallback(async () => {
setLoading(true)
try {
const data = await orderApi.list({ page, size, status })
setList(data.list || [])
setTotal(data.total)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [page, size, status])
useEffect(() => {
load()
}, [load])
const openCreate = async () => {
form.resetFields()
form.setFieldsValue({
buyer_name: '测试买家',
status: 'paid',
remark: '联调测试订单',
})
setCreatedOrder(null)
setCreateOpen(true)
try {
const data = await skinApi.list({ page: 1, size: 100, status: 1 })
setSkins(data.list || [])
} catch (e) {
message.error(e instanceof Error ? e.message : '加载商品失败')
}
}
const onCreate = async () => {
const values = await form.validateFields()
setCreating(true)
try {
const order = await orderApi.create({
skin_id: values.skin_id,
buyer_name: values.buyer_name,
remark: values.remark,
// 管理员可指定任意初始状态,方便造异常单
status: isAdmin ? values.status : undefined,
})
setCreatedOrder(order)
message.success(`测试订单已创建:${order.order_no}`)
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
setCreating(false)
}
}
const copyText = async (text: string, tip = '已复制') => {
try {
await navigator.clipboard.writeText(text)
message.success(tip)
} catch {
message.error('复制失败,请手动选择')
}
}
const changeStatus = async (id: number, next: string) => {
try {
await orderApi.updateStatus(id, next)
message.success('状态已更新')
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
}
const columns: ColumnsType<Order> = [
{
title: '店铺订单号',
dataIndex: 'order_no',
width: 210,
ellipsis: true,
render: (v: string) => (
<Space size={4}>
<Typography.Text copyable={{ text: v }} style={{ maxWidth: 160 }} ellipsis>
{v}
</Typography.Text>
</Space>
),
},
{
title: '皮肤',
dataIndex: ['skin', 'name'],
width: 140,
ellipsis: true,
render: (_, r) => r.skin?.name || `#${r.skin_id}`,
},
{
title: 'SKU',
width: 150,
ellipsis: true,
render: (_, r) =>
r.skin?.sku ? (
<Typography.Text code copyable={{ text: r.skin.sku }}>
{r.skin.sku}
</Typography.Text>
) : (
'-'
),
},
{
title: '分销商',
dataIndex: ['distributor', 'nickname'],
width: 100,
ellipsis: true,
render: (_, r) => r.distributor?.nickname || r.distributor?.username || `#${r.distributor_id}`,
},
{ title: '买家', dataIndex: 'buyer_name', width: 90, ellipsis: true },
{
title: '金额',
dataIndex: 'amount',
width: 90,
render: (v: number) => `¥${Number(v ?? 0).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => {
const s = statusMap[v] || { color: 'default', text: v }
return <Tag color={s.color}>{s.text}</Tag>
},
},
{
title: '上游单号',
dataIndex: 'provider_order_no',
width: 120,
ellipsis: true,
render: (v?: string) => v || '-',
},
{
title: '时间',
dataIndex: 'created_at',
width: 160,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
]
if (isAdmin) {
columns.push({
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space size={0}>
{record.status === 'pending' && (
<>
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'paid')}>
</Button>
<Button type="link" size="small" danger onClick={() => changeStatus(record.id, 'cancelled')}>
</Button>
</>
)}
{(record.status === 'paid' ||
record.status === 'ship_failed' ||
record.status === 'delivering') && (
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'delivered')}>
</Button>
)}
</Space>
),
})
}
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
order_no can_ship=true
</Typography.Text>
</div>
<Space>
<Select
allowClear
placeholder="订单状态"
style={{ width: 140 }}
value={status}
onChange={(v) => {
setPage(1)
setStatus(v)
}}
options={[
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '已支付' },
{ value: 'delivering', label: '发货中' },
{ value: 'delivered', label: '已交付' },
{ value: 'ship_failed', label: '发货失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title="创建测试订单(联调用)"
open={createOpen}
onCancel={() => setCreateOpen(false)}
footer={
createdOrder
? [
<Button key="close" onClick={() => setCreateOpen(false)}>
</Button>,
<Button
key="again"
type="primary"
onClick={() => {
setCreatedOrder(null)
form.setFieldsValue({ status: 'paid' })
}}
>
</Button>,
]
: [
<Button key="cancel" onClick={() => setCreateOpen(false)}>
</Button>,
<Button key="ok" type="primary" loading={creating} onClick={onCreate}>
</Button>,
]
}
destroyOnClose
width={560}
>
{createdOrder ? (
<div>
<Typography.Paragraph>
<Typography.Text strong></Typography.Text>{' '}
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div
style={{
padding: 16,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
}}
>
<div style={{ marginBottom: 8, color: '#666' }}> order_no</div>
<Space>
<Typography.Title level={4} style={{ margin: 0 }} copyable>
{createdOrder.order_no}
</Typography.Title>
<Button
icon={<CopyOutlined />}
onClick={() => copyText(createdOrder.order_no, '订单号已复制')}
>
</Button>
</Space>
</div>
<div>
<div>{createdOrder.skin?.name || `#${createdOrder.skin_id}`}</div>
<div>
SKU
<Typography.Text code copyable>
{createdOrder.skin?.sku || '-'}
</Typography.Text>
</div>
<div>
<Tag color={statusMap[createdOrder.status]?.color}>
{statusMap[createdOrder.status]?.text || createdOrder.status}
</Tag>
{(createdOrder.status === 'paid' || createdOrder.status === 'ship_failed') && (
<Typography.Text type="success"> can_ship=true</Typography.Text>
)}
{createdOrder.status !== 'paid' && createdOrder.status !== 'ship_failed' && (
<Typography.Text type="secondary"> can_ship=false</Typography.Text>
)}
</div>
<div>{createdOrder.buyer_name}</div>
</div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
GET /api/open/v1/orders/{createdOrder.order_no}
<br />
X-Api-Key / X-Timestamp / X-Nonce / X-Sign
</Typography.Paragraph>
</Space>
</div>
) : (
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="skin_id"
label="商品皮肤"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择要发货的皮肤"
options={skins.map((s) => ({
value: s.id,
label: `${s.name}${s.sku}`,
}))}
/>
</Form.Item>
<Form.Item name="buyer_name" label="买家名称">
<Input placeholder="测试买家" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="联调说明" />
</Form.Item>
{isAdmin && (
<Form.Item
name="status"
label="初始状态(联调造单)"
rules={[{ required: true, message: '请选择状态' }]}
extra="源头 can_ship=true 的只有:已支付、发货失败"
>
<Select
options={[
{ value: 'pending', label: '待支付 pending — can_ship=false' },
{ value: 'paid', label: '已支付 paid — can_ship=true(正常可发)' },
{ value: 'delivering', label: '发货中 delivering — can_ship=false' },
{ value: 'delivered', label: '已交付 delivered — can_ship=false' },
{ value: 'ship_failed', label: '发货失败 ship_failed — can_ship=true(可重试)' },
{ value: 'cancelled', label: '已取消 cancelled — can_ship=false' },
]}
/>
</Form.Item>
)}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
O20260720
</Typography.Paragraph>
</Form>
)}
</Modal>
</div>
)
}
+189 -8
View File
@@ -26,14 +26,24 @@ const memberRoleOptions = [
{ value: 'viewer', label: '只读' },
]
const featureOptions = [
{ value: 'products', label: '商品' },
{ value: 'orders', label: '订单' },
{ value: 'wallet', label: '钱包' },
{ value: 'api', label: 'API' },
{ value: 'callbacks', label: '回调' },
]
export default function PlatformMerchants() {
const navigate = useNavigate()
const [data, setData] = useState<PageResult<Merchant>>({ list: [], total: 0, page: 1, size: 10 })
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [memberOpen, setMemberOpen] = useState(false)
const [selectedMerchant, setSelectedMerchant] = useState<Merchant | null>(null)
const [createForm] = Form.useForm()
const [settingsForm] = Form.useForm()
const [memberForm] = Form.useForm()
const load = useCallback(async (page = data.page, size = data.size) => {
@@ -55,7 +65,13 @@ export default function PlatformMerchants() {
const submitCreate = async () => {
const values = await createForm.validateFields()
try {
await platformApi.createMerchant(values)
await platformApi.createMerchant({
...values,
features: featureListToText(values.features),
fee_type: values.fee_type,
fee_rate_bp: Number(values.fee_rate_bp || 0),
fee_fixed_amount: yuanToCents(values.fee_fixed_yuan),
})
message.success('商户已创建')
setCreateOpen(false)
createForm.resetFields()
@@ -65,6 +81,28 @@ export default function PlatformMerchants() {
}
}
const submitSettings = async () => {
if (!selectedMerchant) return
const values = await settingsForm.validateFields()
try {
await platformApi.updateMerchant(selectedMerchant.id, {
name: values.name,
status: values.status,
contact_name: values.contact_name,
contact_info: values.contact_info,
features: featureListToText(values.features),
fee_type: values.fee_type,
fee_rate_bp: Number(values.fee_rate_bp || 0),
fee_fixed_amount: yuanToCents(values.fee_fixed_yuan),
})
message.success('商户设置已更新')
setSettingsOpen(false)
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
}
const submitMember = async () => {
if (!selectedMerchant) return
const values = await memberForm.validateFields()
@@ -87,6 +125,27 @@ export default function PlatformMerchants() {
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '联系人', dataIndex: 'contact_name', width: 120, render: (v) => v || '-' },
{ title: '联系方式', dataIndex: 'contact_info', width: 160, render: (v) => v || '-' },
{
title: '手续费',
key: 'fee',
width: 160,
render: (_, r) =>
r.fee_type === 'fixed'
? `固定 ¥${centsToYuan(r.fee_fixed_amount).toFixed(2)}/单`
: `${(r.fee_rate_bp / 100).toFixed(2)}%`,
},
{
title: '功能',
dataIndex: 'features',
width: 220,
render: (v) => (
<Space size={4} wrap>
{featuresToList(v).map((feature) => (
<Tag key={feature}>{featureText(feature)}</Tag>
))}
</Space>
),
},
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => v === 'active' ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
@@ -106,6 +165,22 @@ export default function PlatformMerchants() {
>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setSelectedMerchant(record)
settingsForm.setFieldsValue({
...record,
features: featuresToList(record.features),
fee_type: record.fee_type || 'rate',
fee_fixed_yuan: centsToYuan(record.fee_fixed_amount),
})
setSettingsOpen(true)
}}
>
</Button>
<Button
type="link"
size="small"
@@ -142,6 +217,12 @@ export default function PlatformMerchants() {
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields()
createForm.setFieldsValue({
features: featureOptions.map((item) => item.value),
fee_type: 'rate',
fee_rate_bp: 0,
fee_fixed_yuan: 0,
})
setCreateOpen(true)
}}
>
@@ -166,7 +247,7 @@ export default function PlatformMerchants() {
}}
/>
<Modal title="新增商户" open={createOpen} onOk={submitCreate} onCancel={() => setCreateOpen(false)} destroyOnClose>
<Modal title="新增商户" open={createOpen} onOk={submitCreate} onCancel={() => setCreateOpen(false)} destroyOnClose width={680}>
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="code" label="商户编码" rules={[{ required: true }]}>
<Input placeholder="lower-case-code" />
@@ -174,15 +255,92 @@ export default function PlatformMerchants() {
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="owner_user_id" label="负责人用户 ID" rules={[{ required: true }]}>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="contact_name" label="联系人">
<Input />
</Form.Item>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="owner_username" label="负责人用户名" rules={[{ required: true }]} style={{ width: 300 }}>
<Input />
</Form.Item>
<Form.Item name="owner_password" label="负责人密码" rules={[{ required: true, min: 6 }]} style={{ width: 300 }}>
<Input.Password />
</Form.Item>
</Space>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="owner_nickname" label="负责人昵称" style={{ width: 300 }}>
<Input />
</Form.Item>
<Form.Item name="contact_name" label="联系人" style={{ width: 300 }}>
<Input />
</Form.Item>
</Space>
<Form.Item name="contact_info" label="联系方式">
<Input />
</Form.Item>
<Form.Item name="features" label="开通功能" rules={[{ required: true }]}>
<Select mode="multiple" options={featureOptions} />
</Form.Item>
<Form.Item name="fee_type" label="手续费类型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'rate', label: '按百分比(每单按订单金额比例收取)' },
{ value: 'fixed', label: '按固定金额(每单固定手续费)' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.fee_type !== cur.fee_type}>
{({ getFieldValue }) =>
getFieldValue('fee_type') === 'fixed' ? (
<Form.Item name="fee_fixed_yuan" label="每单固定手续费(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
) : (
<Form.Item name="fee_rate_bp" label="手续费比例 BP1BP=0.01%,如 250=2.5%" rules={[{ required: true }]}>
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)
}
</Form.Item>
</Form>
</Modal>
<Modal title={selectedMerchant ? `商户设置:${selectedMerchant.name}` : '商户设置'} open={settingsOpen} onOk={submitSettings} onCancel={() => setSettingsOpen(false)} destroyOnClose width={680}>
<Form form={settingsForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="status" label="状态" style={{ width: 300 }}>
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
<Form.Item name="contact_name" label="联系人" style={{ width: 300 }}>
<Input />
</Form.Item>
</Space>
<Form.Item name="contact_info" label="联系方式">
<Input />
</Form.Item>
<Form.Item name="features" label="开通功能" rules={[{ required: true }]}>
<Select mode="multiple" options={featureOptions} />
</Form.Item>
<Form.Item name="fee_type" label="手续费类型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'rate', label: '按百分比(每单按订单金额比例收取)' },
{ value: 'fixed', label: '按固定金额(每单固定手续费)' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.fee_type !== cur.fee_type}>
{({ getFieldValue }) =>
getFieldValue('fee_type') === 'fixed' ? (
<Form.Item name="fee_fixed_yuan" label="每单固定手续费(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
) : (
<Form.Item name="fee_rate_bp" label="手续费比例 BP1BP=0.01%,如 250=2.5%" rules={[{ required: true }]}>
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)
}
</Form.Item>
</Form>
</Modal>
@@ -208,3 +366,26 @@ export default function PlatformMerchants() {
</div>
)
}
function featuresToList(features?: string) {
return (features || '')
.split(/[,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function featureListToText(features?: string[]) {
return (features || []).join(',')
}
function featureText(feature: string) {
return featureOptions.find((item) => item.value === feature)?.label || feature
}
function yuanToCents(value?: number | null) {
return Math.round(Number(value || 0) * 100)
}
function centsToYuan(value?: number | null) {
return Number(((value || 0) / 100).toFixed(2))
}
+2 -2
View File
@@ -39,9 +39,9 @@ export default function Register() {
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="username" rules={[{ required: true, min: 3, message: '用户名至少3位' }]}>
-245
View File
@@ -1,245 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { shipLogApi } from '../api'
import type { ShipLog } from '../types'
const shipStatusMap: Record<string, { color: string; text: string }> = {
success: { color: 'green', text: '成功' },
failed: { color: 'red', text: '失败' },
processing: { color: 'cyan', text: '发货中' },
}
const orderStatusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待支付' },
paid: { color: 'blue', text: '已支付' },
delivering: { color: 'cyan', text: '发货中' },
delivered: { color: 'green', text: '已交付' },
ship_failed: { color: 'red', text: '发货失败' },
cancelled: { color: 'default', text: '已取消' },
}
export default function ShipLogs() {
const [list, setList] = useState<ShipLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [orderNo, setOrderNo] = useState('')
const [shipStatus, setShipStatus] = useState<string | undefined>()
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<ShipLog | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await shipLogApi.list({
page,
size,
order_no: orderNo || undefined,
ship_status: shipStatus,
})
setList(data.list || [])
setTotal(data.total)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [page, size, orderNo, shipStatus])
useEffect(() => {
load()
}, [load])
const columns: ColumnsType<ShipLog> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '店铺订单号', dataIndex: 'order_no', width: 200, ellipsis: true },
{
title: '推送状态',
dataIndex: 'ship_status',
width: 100,
render: (v: string) => {
const s = shipStatusMap[v] || { color: 'default', text: v }
return <Tag color={s.color}>{s.text}</Tag>
},
},
{
title: '处理后订单状态',
dataIndex: 'result_status',
width: 130,
render: (v: string) => {
if (!v) return '-'
const s = orderStatusMap[v] || { color: 'default', text: v }
return <Tag color={s.color}>{s.text}</Tag>
},
},
{
title: '上游单号',
dataIndex: 'provider_order_no',
width: 160,
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '说明',
dataIndex: 'message',
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '失败原因',
dataIndex: 'fail_reason',
width: 140,
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '时间',
dataIndex: 'created_at',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 90,
render: (_, record) => (
<Button type="link" size="small" onClick={() => setDetail(record)}>
</Button>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<Input.Search
placeholder="店铺订单号"
allowClear
onSearch={(v) => {
setPage(1)
setOrderNo(v)
}}
style={{ width: 220 }}
/>
<Select
allowClear
placeholder="推送状态"
style={{ width: 140 }}
value={shipStatus}
onChange={(v) => {
setPage(1)
setShipStatus(v)
}}
options={[
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'processing', label: '发货中' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
</Space>
</Space>
<Typography.Paragraph type="secondary" style={{ marginTop: -4 }}>
便
</Typography.Paragraph>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title="推送详情"
open={!!detail}
onCancel={() => setDetail(null)}
footer={null}
width={640}
>
{detail && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text copyable>{detail.order_no}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.provider_order_no || '-'}
</div>
<div>
<Typography.Text type="secondary"> / </Typography.Text>
{detail.ship_status} {detail.result_status || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.message || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.fail_reason || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
borderRadius: 6,
maxHeight: 280,
overflow: 'auto',
fontSize: 12,
}}
>
{formatPayload(detail.payload)}
</pre>
</div>
</Space>
)}
</Modal>
</div>
)
}
function formatPayload(raw: string) {
if (!raw) return '-'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
-293
View File
@@ -1,293 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { skinApi, orderApi } from '../api'
import { useAuth } from '../store/auth'
import type { Skin } from '../types'
export default function Skins() {
const { isAdmin } = useAuth()
const [list, setList] = useState<Skin[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Skin | null>(null)
const [form] = Form.useForm()
const load = useCallback(async () => {
setLoading(true)
try {
const data = await skinApi.list({ page, size, keyword })
setList(data.list || [])
setTotal(data.total)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [page, size, keyword])
useEffect(() => {
load()
}, [load])
const openCreate = () => {
setEditing(null)
form.resetFields()
form.setFieldsValue({
game: '和平精英',
stock: -1,
commission: 0,
status: 1,
})
setOpen(true)
}
const openEdit = (record: Skin) => {
setEditing(record)
form.setFieldsValue(record)
setOpen(true)
}
const onSubmit = async () => {
const values = await form.validateFields()
try {
if (editing) {
await skinApi.update(editing.id, values)
message.success('更新成功')
} else {
await skinApi.create(values)
message.success('创建成功')
}
setOpen(false)
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
const onDelete = async (id: number) => {
try {
await skinApi.remove(id)
message.success('已删除')
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const onOrder = async (skin: Skin) => {
try {
await orderApi.create({ skin_id: skin.id, buyer_name: '演示买家' })
message.success('下单成功')
} catch (e) {
message.error(e instanceof Error ? e.message : '下单失败')
}
}
const columns: ColumnsType<Skin> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '中文名', dataIndex: 'name', width: 180, ellipsis: true },
{
title: '英文名',
dataIndex: 'sku',
width: 220,
ellipsis: true,
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{ title: '游戏', dataIndex: 'game', width: 100 },
{
title: '售价',
dataIndex: 'price',
width: 100,
render: (v: number) => `¥${Number(v ?? 0).toFixed(2)}`,
},
{
title: '佣金比例',
dataIndex: 'commission',
width: 100,
render: (v: number) => `${((v ?? 0) * 100).toFixed(0)}%`,
},
{
title: '库存',
dataIndex: 'stock',
width: 80,
render: (v: number) => (v < 0 ? '无限' : v),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v: number) =>
v === 1 ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
key: 'action',
width: isAdmin ? 140 : 80,
render: (_, record) => (
<Space>
{isAdmin ? (
<>
<Button type="link" size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm title="确认删除?" onConfirm={() => onDelete(record.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</>
) : (
<Button
type="link"
size="small"
disabled={record.status !== 1}
onClick={() => onOrder(record)}
>
</Button>
)}
</Space>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<Input.Search
placeholder="搜索中文名 / 英文名"
allowClear
onSearch={(v) => {
setPage(1)
setKeyword(v)
}}
style={{ width: 240 }}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
{isAdmin && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
)}
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title={editing ? '编辑皮肤' : '新增皮肤'}
open={open}
onOk={onSubmit}
onCancel={() => setOpen(false)}
destroyOnClose
width={560}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="中文名" rules={[{ required: true }]}>
<Input placeholder="如:套装-糯粉咩咩" />
</Form.Item>
<Form.Item
name="sku"
label="英文名 (sku)"
rules={[{ required: true, message: '请填写英文固定标识' }]}
>
<Input placeholder="如:suit_pink_sheep" disabled={!!editing} />
</Form.Item>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="game" label="游戏" style={{ width: 240 }}>
<Select
options={[
{ value: '和平精英', label: '和平精英' },
{ value: '王者荣耀', label: '王者荣耀' },
{ value: '英雄联盟', label: '英雄联盟' },
]}
/>
</Form.Item>
<Form.Item name="category" label="品类" style={{ width: 240 }}>
<Select
allowClear
options={[
{ value: '套装', label: '套装' },
{ value: '上衣', label: '上衣' },
{ value: '背包', label: '背包' },
{ value: '头盔', label: '头盔' },
{ value: '枪械', label: '枪械' },
{ value: '投掷物', label: '投掷物' },
{ value: '礼包', label: '礼包' },
]}
/>
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="price" label="售价" rules={[{ required: true }]} style={{ width: 160 }}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="cost_price" label="成本价" style={{ width: 160 }}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="commission" label="佣金比例" style={{ width: 160 }}>
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="stock" label="库存(-1无限)" style={{ width: 240 }}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" style={{ width: 240 }}>
<Select
options={[
{ value: 1, label: '上架' },
{ value: 0, label: '下架' },
]}
/>
</Form.Item>
</Space>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+13 -53
View File
@@ -2,10 +2,8 @@ export interface User {
id: number
username: string
nickname: string
role: 'admin' | 'distributor'
role: 'admin' | 'merchant'
status: number
invite_code: string
parent_id?: number | null
created_at: string
}
@@ -16,6 +14,10 @@ export interface Merchant {
status: 'active' | 'disabled'
contact_name?: string
contact_info?: string
features: string
fee_type: 'rate' | 'fixed'
fee_rate_bp: number
fee_fixed_amount: number
created_at: string
}
@@ -86,6 +88,11 @@ export interface FulfillmentOrder {
product_sku: string
product_name: string
quantity: number
base_amount: number
fee_type: 'rate' | 'fixed'
fee_rate_bp: number
fee_fixed_amount: number
service_fee_amount: number
amount: number
currency: string
payment_status: string
@@ -131,59 +138,12 @@ export interface CallbackCredential {
secret: string
}
export interface Skin {
id: number
name: string
sku: string
game: string
category: string
cover_url: string
price: number
cost_price: number
commission: number
stock: number
status: number
description: string
created_at: string
}
export interface Order {
id: number
order_no: string
skin_id: number
skin?: Skin
distributor_id: number
distributor?: User
buyer_name: string
amount: number
commission_amt: number
status: string
remark: string
provider_order_no?: string
shipped_at?: string | null
ship_fail_reason?: string
created_at: string
}
export interface ShipLog {
id: number
order_no: string
order_id: number
ship_status: string
provider_order_no: string
fail_reason: string
payload: string
result_status: string
message: string
created_at: string
}
export interface DashboardStats {
skin_count: number
distributor_count: number
product_count: number
merchant_count: number
order_count: number
total_sales: number
total_commission: number
total_fees: number
pending_order_count: number
}