@@ -1,4 +1,5 @@
import { useCallback , useEffect , useMemo , useState } from 'react'
import { useLocation } from 'react-router-dom'
import {
Button ,
Descriptions ,
@@ -81,10 +82,19 @@ const testOrderStatusOptions = [
{ value : 'failed' , label : '发货失败,可重试' } ,
]
export default function MerchantCenter() {
type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members'
interface MerchantCenterProps {
fixedTab? : MerchantCenterTab
title? : string
}
export default function MerchantCenter ( { fixedTab , title = '商户中心' } : MerchantCenterProps = { } ) {
const location = useLocation ( )
const routeTab = useMemo ( ( ) = > fixedTab ? ? tabFromSearch ( location . search ) , [ fixedTab , location . search ] )
const [ merchant , setMerchant ] = useState < Merchant | null > ( null )
const [ merchantRole , setMerchantRole ] = useState < MerchantMember [ 'role' ] > ( )
const [ activeTab , setActiveTab ] = useState ( 'products' )
const [ activeTab , setActiveTab ] = useState < MerchantCenterTab > ( routeTab ? ? 'products' )
const [ products , setProducts ] = useState < PageResult < MerchantProduct > > ( { list : [ ] , total : 0 , page : 1 , size : 10 } )
const [ orders , setOrders ] = useState < PageResult < FulfillmentOrder > > ( { list : [ ] , total : 0 , page : 1 , size : 10 } )
const [ ledger , setLedger ] = useState < PageResult < WalletLedgerEntry > > ( { list : [ ] , total : 0 , page : 1 , size : 10 } )
@@ -252,8 +262,8 @@ export default function MerchantCenter() {
setLoading ( true )
try {
const current = await loadCurrent ( )
const nextTab = resolveEnabledTab ( activeTab , current . merchant . features )
if ( nextTab !== activeTab ) {
const nextTab = fixedTab ? ? resolveEnabledTab ( routeTab ? ? activeTab , current . merchant . features )
if ( ! fixedTab && nextTab !== activeTab ) {
setActiveTab ( nextTab )
}
await loadActiveTab ( current . role , nextTab )
@@ -262,12 +272,18 @@ export default function MerchantCenter() {
} finally {
setLoading ( false )
}
} , [ activeTab , loadActiveTab , loadCurrent ] )
} , [ activeTab , fixedTab , loadActiveTab , loadCurrent , routeTab ] )
useEffect ( ( ) = > {
loadAll ( )
} , [ loadAll ] )
useEffect ( ( ) = > {
if ( routeTab && activeTab !== routeTab ) {
setActiveTab ( routeTab )
}
} , [ activeTab , routeTab ] )
const openProductCreate = ( ) = > {
setEditingProduct ( null )
productForm . resetFields ( )
@@ -380,7 +396,7 @@ export default function MerchantCenter() {
} )
setApiCredential ( credential )
setApiClientOpen ( false )
message . success ( 'API 客户端 已创建' )
message . success ( 'API 密钥 已创建' )
loadAPIClients ( )
} catch ( e ) {
message . error ( e instanceof Error ? e . message : '创建失败' )
@@ -459,27 +475,39 @@ export default function MerchantCenter() {
]
const orderColumns : ColumnsType < FulfillmentOrder > = [
{ title : '平台订单号' , dataIndex : 'order_no' , width : 210 , render : ( v ) = > < Typography.Text copyable > { v } < / Typography.Text > } ,
{ 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 : '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 || '-' } ,
{
title : '平台订 单号' ,
dataIndex : 'order_no' ,
width : 190 ,
ellipsis : true ,
render : ( v ) = > < Typography.Text copyable ellipsis style = { { maxWidth : '100%' } } > { v } < / Typography.Text > ,
} ,
{ title : '商户单号 ' , dataIndex : 'client_order_no ' , width : 140 , ellipsis : true } ,
{
title : 'SKU' ,
dataIndex : 'product_sku' ,
width : 130 ,
ellipsis : true ,
render : ( v ) = > < Typography.Text code ellipsis style = { { maxWidth : '100%' } } > { v } < / Typography.Text > ,
} ,
{ title : '商品' , dataIndex : 'product_name' , width : 220 , ellipsis : true } ,
{ title : '基础金额' , dataIndex : 'base_amount' , width : 88 , render : money } ,
{ title : '手续费' , dataIndex : 'service_fee_amount' , width : 82 , render : money } ,
{ title : '扣款合计' , dataIndex : 'amount' , width : 88 , render : money } ,
{ title : '支付' , dataIndex : 'payment_status' , width : 76 , render : paymentStatusTag } ,
{ title : '履约' , dataIndex : 'fulfillment_status' , width : 86 , render : fulfillmentStatusTag } ,
{ title : '上游单号' , dataIndex : 'provider_order_no' , width : 120 , ellipsis : true , render : ( v ) = > v || '-' } ,
{
title : '链接有效期' ,
dataIndex : 'delivery_link_expires_at' ,
width : 160 ,
width : 140 ,
render : ( _ , record ) = > record . delivery_link_revoked_at ? < Tag color = "red" > 已 作 废 < / Tag > : formatDateTime ( record . delivery_link_expires_at ) ,
} ,
{ title : '时间' , dataIndex : 'created_at' , width : 180 , render : formatDateTime } ,
{ title : '时间' , dataIndex : 'created_at' , width : 150 , render : formatDateTime } ,
{
title : '发货链接' ,
key : 'delivery_link' ,
width : 220 ,
width : 150 ,
render : ( _ , record ) = > record . delivery_link_revoked_at ? (
< Button type = "link" size = "small" onClick = { ( ) = > restoreDeliveryLink ( record . order_no ) } > 取 消 作 废 < / Button >
) : (
@@ -547,12 +575,141 @@ export default function MerchantCenter() {
{ title : '状态' , dataIndex : 'status' , width : 90 , render : ( v ) = > ( v === 1 ? < Tag color = "green" > 启 用 < / Tag > : < Tag > 禁 用 < / Tag > ) } ,
]
const productContent = (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 通 用 商 品 以 商 户 SKU 对 外 销 售 。 < / Typography.Text >
{ canManage && < Button type = "primary" icon = { < PlusOutlined / > } onClick = { openProductCreate } > 新 增 商 品 < / Button > }
< / Space >
< Table
rowKey = "id"
loading = { loading }
columns = { productColumns }
dataSource = { products . list }
tableLayout = "fixed"
pagination = { pageConfig ( products , loadProducts ) }
/ >
< / Space >
)
const orderContent = (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 发 货 链 接 由 后 端 生 成 并 带 签 名 , 用 户 只 填 写 游 戏 UID 。 < / Typography.Text >
{ canManage && (
< Space >
< Button type = "primary" icon = { < PlusOutlined / > } onClick = { openTestOrderCreate } >
创 建 测 试 订 单
< / Button >
< / Space >
) }
< / Space >
< Table
rowKey = "id"
loading = { loading }
columns = { orderColumns }
dataSource = { orders . list }
tableLayout = "fixed"
scroll = { { x : 1660 } }
pagination = { pageConfig ( orders , loadOrders ) }
/ >
< / Space >
)
const walletContent = (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Descriptions size = "small" bordered column = { 3 } style = { { flex : 1 } } >
< Descriptions.Item label = "可用余额" > { money ( wallet ? . available_balance ? ? 0 ) } < / Descriptions.Item >
< Descriptions.Item label = "冻结余额" > { money ( wallet ? . frozen_balance ? ? 0 ) } < / Descriptions.Item >
< Descriptions.Item label = "币种" > { wallet ? . currency || 'POINT' } < / Descriptions.Item >
< / Descriptions >
{ canFinance && (
< Button
icon = { < WalletOutlined / > }
onClick = { ( ) = > {
walletForm . resetFields ( )
walletForm . setFieldsValue ( { idempotency_key : ` manual- ${ Date . now ( ) } ` } )
setWalletOpen ( true )
} }
>
调 整
< / Button >
) }
< / Space >
{ canFinance ? (
< Table
rowKey = "id"
loading = { loading }
columns = { ledgerColumns }
dataSource = { ledger . list }
tableLayout = "fixed"
pagination = { pageConfig ( ledger , loadWallet ) }
/ >
) : (
< Typography.Text type = "secondary" > 当 前 角 色 可 查 看 余 额 , 钱 包 流 水 仅 财 务 或 负 责 人 可 见 。 < / Typography.Text >
) }
< / Space >
)
const apiKeyContent = (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 用 于 开 放 API 调 用 鉴 权 。 新 Secret 只 在 创 建 后 显 示 一 次 , 请 及 时 保 存 。 < / Typography.Text >
{ canManage && < Button type = "primary" icon = { < ApiOutlined / > } onClick = { ( ) = > {
apiClientForm . resetFields ( )
apiClientForm . setFieldsValue ( { signature_version : 'v1' , scopes : [ 'products:read' , 'orders:read' , 'orders:write' ] } )
setApiClientOpen ( true )
} } > 新 增 密 钥 < / Button > }
< / Space >
< Table rowKey = "id" loading = { loading } columns = { apiClientColumns } dataSource = { apiClients } tableLayout = "fixed" / >
< / Space >
)
const callbackContent = (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 回 调 事 件 通 过 outbox 持 久 化 发 送 。 < / Typography.Text >
{ canManage && < Button type = "primary" icon = { < PlusOutlined / > } onClick = { ( ) = > {
callbackForm . resetFields ( )
callbackForm . setFieldsValue ( { events : [ 'order.fulfillment.updated' ] } )
setCallbackOpen ( true )
} } > 新 增 回 调 < / Button > }
< / Space >
< Table rowKey = "id" loading = { loading } columns = { callbackColumns } dataSource = { callbacks } tableLayout = "fixed" / >
< / Space >
)
const memberContent = (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 成 员 角 色 独 立 于 平 台 账 号 角 色 。 < / Typography.Text >
{ merchantRole === 'owner' && < Button type = "primary" icon = { < PlusOutlined / > } onClick = { ( ) = > {
memberForm . resetFields ( )
memberForm . setFieldsValue ( { role : 'operator' } )
setMemberOpen ( true )
} } > 添 加 成 员 < / Button > }
< / Space >
< Table rowKey = "id" loading = { loading } columns = { memberColumns } dataSource = { members } tableLayout = "fixed" / >
< / Space >
)
const tabItems = [
{ key : 'products' , label : '商品' , disabled : ! hasFeature ( 'products' ) , children : productContent } ,
{ key : 'orders' , label : '履约订单' , disabled : ! hasFeature ( 'orders' ) , children : orderContent } ,
{ key : 'wallet' , label : '钱包' , disabled : ! hasFeature ( 'wallet' ) , children : walletContent } ,
{ key : 'api' , label : 'API 密钥' , disabled : ! hasFeature ( 'api' ) , children : apiKeyContent } ,
{ key : 'callbacks' , label : '回调' , disabled : ! hasFeature ( 'callbacks' ) , children : callbackContent } ,
{ key : 'members' , label : '成员' , children : memberContent } ,
]
const fixedTabContent = tabItems . find ( ( item ) = > item . key === fixedTab ) ? . children
return (
< div >
< Space style = { { marginBottom : 16 , width : '100%' , justifyContent : 'space-between' } } >
< div >
< Typography.Title level = { 4 } style = { { margin : 0 } } >
商 户 中 心
{ title }
< / Typography.Title >
< Typography.Text type = "secondary" >
{ merchant ? ` ${ merchant . name } / ${ merchant . code } ` : '加载中' } · { merchantRole ? roleText ( merchantRole ) : '-' }
@@ -563,154 +720,13 @@ export default function MerchantCenter() {
< / Button >
< / Space >
< Tabs
activeKey = { activeTab }
onChange = { setActiveTab }
items = { [
{
key : 'products' ,
label : '商品' ,
disabled : ! hasFeature ( 'products' ) ,
children : (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 通 用 商 品 以 商 户 SKU 对 外 销 售 。 < / Typography.Text >
{ canManage && < Button type = "primary" icon = { < PlusOutlined / > } onClick = { openProductCreate } > 新 增 商 品 < / Button > }
< / Space >
< Table
rowKey = "id"
loading = { loading }
columns = { productColumns }
dataSource = { products . list }
tableLayout = "fixed"
pagination = { pageConfig ( products , loadProducts ) }
/ >
< / Space >
) ,
} ,
{
key : 'orders' ,
label : '履约订单' ,
disabled : ! hasFeature ( 'orders' ) ,
children : (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 发 货 链 接 由 后 端 生 成 并 带 签 名 , 用 户 只 填 写 游 戏 UID 。 < / Typography.Text >
{ canManage && (
< Space >
< Button type = "primary" icon = { < PlusOutlined / > } onClick = { openTestOrderCreate } >
创 建 测 试 订 单
< / Button >
< / Space >
) }
< / Space >
< Table
rowKey = "id"
loading = { loading }
columns = { orderColumns }
dataSource = { orders . list }
tableLayout = "fixed"
scroll = { { x : 1500 } }
pagination = { pageConfig ( orders , loadOrders ) }
/ >
< / Space >
) ,
} ,
{
key : 'wallet' ,
label : '钱包' ,
disabled : ! hasFeature ( 'wallet' ) ,
children : (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Descriptions size = "small" bordered column = { 3 } style = { { flex : 1 } } >
< Descriptions.Item label = "可用余额" > { money ( wallet ? . available_balance ? ? 0 ) } < / Descriptions.Item >
< Descriptions.Item label = "冻结余额" > { money ( wallet ? . frozen_balance ? ? 0 ) } < / Descriptions.Item >
< Descriptions.Item label = "币种" > { wallet ? . currency || 'POINT' } < / Descriptions.Item >
< / Descriptions >
{ canFinance && (
< Button
icon = { < WalletOutlined / > }
onClick = { ( ) = > {
walletForm . resetFields ( )
walletForm . setFieldsValue ( { idempotency_key : ` manual- ${ Date . now ( ) } ` } )
setWalletOpen ( true )
} }
>
调 整
< / Button >
) }
< / Space >
{ 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' } } >
< Typography.Text type = "secondary" > 新 密 钥 只 在 创 建 后 显 示 一 次 。 < / Typography.Text >
{ canManage && < Button type = "primary" icon = { < ApiOutlined / > } onClick = { ( ) = > {
apiClientForm . resetFields ( )
apiClientForm . setFieldsValue ( { signature_version : 'v1' , scopes : [ 'products:read' , 'orders:read' , 'orders:write' ] } )
setApiClientOpen ( true )
} } > 新 增 客 户 端 < / Button > }
< / Space >
< Table rowKey = "id" loading = { loading } columns = { apiClientColumns } dataSource = { apiClients } tableLayout = "fixed" / >
< / Space >
) ,
} ,
{
key : 'callbacks' ,
label : '回调' ,
disabled : ! hasFeature ( 'callbacks' ) ,
children : (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 回 调 事 件 通 过 outbox 持 久 化 发 送 。 < / Typography.Text >
{ canManage && < Button type = "primary" icon = { < PlusOutlined / > } onClick = { ( ) = > {
callbackForm . resetFields ( )
callbackForm . setFieldsValue ( { events : [ 'order.fulfillment.updated' ] } )
setCallbackOpen ( true )
} } > 新 增 回 调 < / Button > }
< / Space >
< Table rowKey = "id" loading = { loading } columns = { callbackColumns } dataSource = { callbacks } tableLayout = "fixed" / >
< / Space >
) ,
} ,
{
key : 'members' ,
label : '成员' ,
children : (
< Space direction = "vertical" style = { { width : '100%' } } size = "middle" >
< Space style = { { width : '100%' , justifyContent : 'space-between' } } >
< Typography.Text type = "secondary" > 成 员 角 色 独 立 于 平 台 账 号 角 色 。 < / Typography.Text >
{ merchantRole === 'owner' && < Button type = "primary" icon = { < PlusOutlined / > } onClick = { ( ) = > {
memberForm . resetFields ( )
memberForm . setFieldsValue ( { role : 'operator' } )
setMemberOpen ( true )
} } > 添 加 成 员 < / Button > }
< / Space >
< Table rowKey = "id" loading = { loading } columns = { memberColumns } dataSource = { members } tableLayout = "fixed" / >
< / Space >
) ,
} ,
] }
/ >
{ fixedTab ? fixedTabContent : (
< Tabs
activeKey = { activeTab }
onChange = { ( key ) = > setActiveTab ( key as MerchantCenterTab ) }
items = { tabItems }
/ >
) }
< Modal title = { editingProduct ? '编辑商品' : '新增商品' } open = { productOpen } onOk = { submitProduct } onCancel = { ( ) = > setProductOpen ( false ) } destroyOnClose width = { 620 } >
< Form form = { productForm } layout = "vertical" style = { { marginTop : 16 } } >
@@ -847,7 +863,7 @@ export default function MerchantCenter() {
< / Form >
< / Modal >
< Modal title = "新增 API 客户端 " open = { apiClientOpen } onOk = { submitAPIClient } onCancel = { ( ) = > setApiClientOpen ( false ) } destroyOnClose >
< Modal title = "新增 API 密钥 " open = { apiClientOpen } onOk = { submitAPIClient } onCancel = { ( ) = > setApiClientOpen ( false ) } destroyOnClose >
< Form form = { apiClientForm } layout = "vertical" style = { { marginTop : 16 } } >
< Form.Item name = "name" label = "名称" rules = { [ { required : true } ] } >
< Input / >
@@ -942,9 +958,15 @@ function featuresToList(features?: string) {
return list . length > 0 ? list : [ 'products' , 'orders' , 'wallet' , 'api' , 'callbacks' ]
}
function resolveEnabledTab ( current : string , features? : string ) {
function tabFromSearch ( search : string ) : MerchantCenterTab | null {
const tab = new URLSearchParams ( search ) . get ( 'tab' )
const allowedTabs : MerchantCenterTab [ ] = [ 'products' , 'orders' , 'wallet' , 'api' , 'callbacks' , 'members' ]
return allowedTabs . includes ( tab as MerchantCenterTab ) ? tab as MerchantCenterTab : null
}
function resolveEnabledTab ( current : MerchantCenterTab , features? : string ) : MerchantCenterTab {
const enabled = new Set ( featuresToList ( features ) )
const tabFeatures : Record < string , string | null > = {
const tabFeatures : Record < MerchantCenterTab , string | null > = {
products : 'products' ,
orders : 'orders' ,
wallet : 'wallet' ,
@@ -956,7 +978,7 @@ function resolveEnabledTab(current: string, features?: string) {
if ( feature === null || enabled . has ( feature ) ) {
return current
}
return [ 'products' , 'orders' , 'wallet' , 'api' , 'callbacks' ] . find ( ( key ) = > enabled . has ( key ) ) || 'members'
return ( [ 'products' , 'orders' , 'wallet' , 'api' , 'callbacks' ] as MerchantCenterTab [ ] ) . find ( ( key ) = > enabled . has ( key ) ) || 'members'
}
function money ( value? : number | null ) {