Files
affiliate_dash/frontend/src/pages/merchantCenterTabs.tsx
T
2026-08-14 12:58:20 +08:00

959 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { Alert, Button, Card, Col, Form, Input, Modal, Popconfirm, Row, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd'
import type { FormInstance } from 'antd'
import {
ClockCircleOutlined,
CodeOutlined,
CopyOutlined,
DollarOutlined,
FileTextOutlined,
KeyOutlined,
LinkOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SendOutlined,
WalletOutlined,
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/time'
import type {
ApiClient,
CallbackSubscription,
FulfillmentOrder,
MerchantMember,
MerchantRole,
MerchantProduct,
PageResult,
WalletAccount,
WalletLedgerEntry,
} from '../types'
import {
defaultCallbackFormValues,
eventOptions,
money,
orderStatusOptions,
orderStatusMap,
pageConfig,
rolePermissionText,
roleText,
scopeLabel,
} from './merchantCenterUtils'
export function SecretBlock({ appKey, secret }: { appKey?: string; secret: string }) {
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{appKey && (
<div>
<Typography.Text type="secondary">App Key</Typography.Text>
<Typography.Paragraph copyable={{ text: appKey }} code style={{ marginTop: 8 }}>{appKey}</Typography.Paragraph>
</div>
)}
<div>
<Typography.Text type="secondary">Secret</Typography.Text>
<Typography.Paragraph copyable={{ text: secret }} code style={{ marginTop: 8 }}>{secret}</Typography.Paragraph>
</div>
<Button icon={<CopyOutlined />} onClick={() => navigator.clipboard.writeText(secret).then(() => message.success('已复制'))}>
复制 Secret
</Button>
</Space>
)
}
export function ProductsTab({
loading,
products,
canManage,
onToggleProduct,
onLoadProducts,
}: {
loading: boolean
products: PageResult<MerchantProduct>
canManage: boolean
onToggleProduct: (record: MerchantProduct) => void
onLoadProducts: (page?: number, size?: number) => void
}) {
const productColumns: ColumnsType<MerchantProduct> = [
{
title: '商户 SKU',
dataIndex: 'sku',
width: 180,
ellipsis: true,
render: (v) => <Typography.Text code copyable={{ tooltips: false }} style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
},
{
title: '商品名称',
dataIndex: 'display_name',
width: 220,
ellipsis: true,
render: (_, r) => (
<div>
<div style={{ fontWeight: 600, color: '#0f172a' }}>{r.display_name || r.product?.name || '-'}</div>
{r.product?.category && (
<span style={{ fontSize: 11.5, color: '#64748b' }}>{r.product.category}</span>
)}
</div>
),
},
{
title: '目录编码',
dataIndex: ['product', 'code'],
width: 150,
ellipsis: true,
render: (_, r) => r.product?.code ? <Typography.Text code>{r.product.code}</Typography.Text> : '-',
},
{
title: '对外售价',
dataIndex: 'price_amount',
width: 120,
render: (v) => <span style={{ fontWeight: 700, color: '#2563eb' }}>{money(v)}</span>,
},
{
title: '成本价',
dataIndex: 'cost_amount',
width: 120,
render: (v) => <span style={{ color: '#64748b' }}>{money(v)}</span>,
},
{
title: '当前库存',
dataIndex: 'stock',
width: 110,
render: (v) => (v < 0 ? <Tag color="blue">无限库存</Tag> : v === 0 ? <Tag color="red">已缺货</Tag> : `${v} 件`),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: productStatusTag,
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
render: (_, record) => canManage ? (
<Popconfirm
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
okText="确认"
onConfirm={() => onToggleProduct(record)}
>
<Button type="link" size="small">
{record.status === 'active' ? '下架' : '上架'}
</Button>
</Popconfirm>
) : '-',
},
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
商户可自主上架/下架商品;下架后不可再创建该商品订单。
</Typography.Text>
</div>
</div>
</Card>
<Table
rowKey="id"
loading={loading}
columns={productColumns}
dataSource={products.list}
scroll={{ x: 1100 }}
pagination={pageConfig(products, onLoadProducts)}
/>
</Space>
)
}
export function OrdersTab({
loading,
orders,
orderSource,
filters,
canManage,
onCreateManualOrder,
onOrderSourceChange,
onFilterChange,
onLoadOrders,
onCopyLink,
onOpenLink,
onRevokeLink,
onRestoreLink,
onCancelOrder,
}: {
loading: boolean
orders: PageResult<FulfillmentOrder>
orderSource: 'api' | 'manual'
filters: { keyword: string; order_status: string }
canManage: boolean
onCreateManualOrder: () => void
onOrderSourceChange: (source: 'api' | 'manual') => void
onFilterChange: (filters: { keyword: string; order_status: string }) => void
onLoadOrders: (page?: number, size?: number) => void
onCopyLink: (orderNo: string) => void
onOpenLink: (orderNo: string) => void
onRevokeLink: (orderNo: string) => void
onRestoreLink: (orderNo: string) => void
onCancelOrder: (orderNo: string, reason: string) => Promise<void> | void
}) {
const [keyword, setKeyword] = useState(filters.keyword)
const [cancelling, setCancelling] = useState<FulfillmentOrder | null>(null)
const [cancelReason, setCancelReason] = useState('')
const [cancelSubmitting, setCancelSubmitting] = useState(false)
useEffect(() => {
setKeyword(filters.keyword)
}, [filters.keyword])
const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() })
const submitCancel = async () => {
if (!cancelling) return
if (!cancelReason.trim()) {
message.warning('请填写取消原因')
return
}
setCancelSubmitting(true)
try {
await onCancelOrder(cancelling.order_no, cancelReason.trim())
setCancelling(null)
setCancelReason('')
} finally {
setCancelSubmitting(false)
}
}
const orderColumns: ColumnsType<FulfillmentOrder> = [
{
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: 'order_status', width: 92, render: orderStatusTag },
{
title: '链接有效期',
dataIndex: 'delivery_link_expires_at',
width: 140,
render: (_, record) => record.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(record.delivery_link_expires_at),
},
{ title: '时间', dataIndex: 'created_at', width: 150, render: formatDateTime },
{
title: '发货链接',
key: 'delivery_link',
width: 160,
render: (_, record) => {
if (record.order_status === 'cancelled') {
return <Typography.Text type="secondary">订单已取消</Typography.Text>
}
if (record.delivery_link_revoked_at) {
return <Button type="link" size="small" onClick={() => onRestoreLink(record.order_no)}>恢复链接</Button>
}
return (
<Space size={4} wrap>
<Button type="link" size="small" onClick={() => onCopyLink(record.order_no)}>复制</Button>
<Button type="link" size="small" onClick={() => onOpenLink(record.order_no)}>打开</Button>
<Button
type="link"
danger
size="small"
title="仅作废发货链接,不影响订单状态与积分"
onClick={() => onRevokeLink(record.order_no)}
>
链接作废
</Button>
</Space>
)
},
},
{
title: '操作',
key: 'action',
width: 110,
fixed: 'right',
render: (_, record) => {
if (!canManage) return '-'
if (record.order_status === 'paid' || record.order_status === 'ship_failed') {
return (
<Button
type="link"
danger
size="small"
onClick={() => {
setCancelling(record)
setCancelReason('')
}}
>
取消订单
</Button>
)
}
return '-'
},
},
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Tabs
activeKey={orderSource}
onChange={(key) => onOrderSourceChange(key as 'api' | 'manual')}
items={[{ key: 'api', label: 'API 订单' }, { key: 'manual', label: '手动订单' }]}
style={{ minWidth: 220 }}
/>
{canManage && (
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateManualOrder}>
创建手动订单
</Button>
</Space>
)}
</Space>
<Card size="small" style={{ borderRadius: 8 }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Input.Search
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onSearch={submitKeyword}
placeholder="搜索平台订单号或商户单号"
allowClear
onClear={() => onFilterChange({ ...filters, keyword: '' })}
style={{ width: 300, maxWidth: '100%' }}
/>
</Space>
<Space wrap size={[8, 8]}>
{orderStatusOptions.map((option) => (
<Button
key={option.value || 'all'}
type={filters.order_status === option.value ? 'primary' : 'text'}
onClick={() => onFilterChange({ ...filters, order_status: option.value })}
style={{ minWidth: 76 }}
>
{option.label}
</Button>
))}
</Space>
</Space>
</Card>
<Table
rowKey="id"
loading={loading}
columns={orderColumns}
dataSource={orders.list}
tableLayout="fixed"
scroll={{ x: 1780 }}
pagination={pageConfig(orders, onLoadOrders)}
/>
<Modal
title={`取消订单:${cancelling?.order_no || ''}`}
open={!!cancelling}
confirmLoading={cancelSubmitting}
onOk={submitCancel}
onCancel={() => {
setCancelling(null)
setCancelReason('')
}}
okText="确认取消并退款"
okButtonProps={{ danger: true }}
destroyOnClose
>
<Alert
type="warning"
showIcon
message="取消后订单将不可再发货"
description="已扣积分将全额退还商户钱包,商品库存自动回补;已提交上游发货的失败订单不可取消。"
style={{ marginBottom: 16 }}
/>
<Input.TextArea
rows={3}
maxLength={512}
showCount
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="请填写取消原因(必填)"
/>
</Modal>
</Space>
)
}
export function WalletTab({
loading,
wallet,
canFinance,
ledger,
form,
onFilter,
onResetFilter,
onLoadLedger,
}: {
loading: boolean
wallet: WalletAccount | null
canFinance: boolean
ledger: PageResult<WalletLedgerEntry>
form: FormInstance
onFilter: () => void
onResetFilter: () => void
onLoadLedger: (page: number, size: number) => void
}) {
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
{ title: '流水号', dataIndex: 'entry_no', width: 260, render: (v) => <Typography.Text code copyable={{ tooltips: false }}>{v}</Typography.Text> },
{ title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag },
{ title: '变动积分', dataIndex: 'amount', width: 130, render: moneyWithSign },
{ title: '结余积分', dataIndex: 'balance_after', width: 130, render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{money(v)}</span> },
{ title: '关联单号', dataIndex: 'reference_no', width: 200, ellipsis: true, render: (v) => v ? <Typography.Text code ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text> : '-' },
{ title: '备注说明', dataIndex: 'note', width: 220, ellipsis: { showTitle: false }, render: (v) => <Typography.Text ellipsis title={v}>{v || '-'}</Typography.Text> },
{ title: '变动时间', dataIndex: 'created_at', width: 170, render: formatDateTime },
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">可用余额</span>
<div className="metric-card-icon metric-card-icon--emerald"><WalletOutlined /></div>
</div>
<div className="metric-card-value" style={{ color: '#16a34a' }}>
{(wallet?.available_balance ?? 0).toLocaleString('zh-CN')}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>积分</span>
</div>
<div className="metric-card-footer">
<span>充值/调账</span>
<span style={{ fontWeight: 600, color: '#64748b' }}>仅平台管理员可操作</span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">冻结余额</span>
<div className="metric-card-icon"><ClockCircleOutlined /></div>
</div>
<div className="metric-card-value">
{(wallet?.frozen_balance ?? 0).toLocaleString('zh-CN')}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>积分</span>
</div>
<div className="metric-card-footer">
<span>提现/发货预扣</span>
<span style={{ fontWeight: 600, color: '#0f172a' }}>0 笔预扣</span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">结算币种与状态</span>
<div className="metric-card-icon metric-card-icon--purple"><DollarOutlined /></div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, margin: 0, fontWeight: 600 }}>
{wallet?.currency || 'POINT'}
</Tag>
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
账户正常
</span>
</div>
<div className="metric-card-footer">
<span>计费方式</span>
<span style={{ fontWeight: 600, color: '#0f172a' }}>自动按单扣减</span>
</div>
</div>
</Col>
</Row>
{canFinance && (
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
<Form form={form} layout="inline" onFinish={onFilter}>
<Form.Item name="reference_no" label="关联单号">
<Input placeholder="店铺单号 / 充值单号" allowClear style={{ width: 220, borderRadius: 6 }} />
</Form.Item>
<Form.Item name="type" label="收支类型">
<Select placeholder="全部类型" allowClear style={{ width: 140 }}>
<Select.Option value="credit">入账 (Credit)</Select.Option>
<Select.Option value="debit">扣款 (Debit)</Select.Option>
<Select.Option value="refund">退款 (Refund)</Select.Option>
<Select.Option value="adjust">调账 (Adjust)</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button onClick={onResetFilter}>重置</Button>
<Button type="primary" htmlType="submit">查询</Button>
</Space>
</Form.Item>
</Form>
</Card>
)}
{canFinance ? (
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
scroll={{ x: 1300 }}
pagination={pageConfig(ledger, onLoadLedger)}
/>
) : (
<Typography.Text type="secondary">当前角色可查看余额,钱包流水仅财务或负责人可见。</Typography.Text>
)}
</Space>
)
}
export function ApiKeysTab({
loading,
clients,
clientMax,
isAdmin,
canManage,
form,
onOpenCreate,
onNavigateDocs,
onNavigateDebug,
onToggleClient,
onDeleteClient,
}: {
loading: boolean
clients: ApiClient[]
clientMax: number
isAdmin: boolean
canManage: boolean
form: FormInstance
onOpenCreate: () => void
onNavigateDocs: () => void
onNavigateDebug: () => void
onToggleClient: (record: ApiClient) => void
onDeleteClient: (record: ApiClient) => void
}) {
const apiClientColumns: ColumnsType<ApiClient> = [
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
{ title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
{ title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => <Tag>{v}</Tag> },
{ title: '权限', dataIndex: 'scopes', width: 250, render: scopesTag },
{ title: '状态', dataIndex: 'status', width: 80, render: activeStatusTag },
{ title: '最后使用', dataIndex: 'last_used_at', width: 170, render: formatDateTime },
{
title: '操作',
key: 'action',
width: 150,
render: (_, record) => canManage ? (
<Space size={0}>
<Button type="link" size="small" onClick={() => onToggleClient(record)}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Popconfirm
title="删除该密钥?"
description={record.status === 'active' ? '请先停用该密钥再删除' : '删除后不可恢复,使用该密钥的对接方将无法调用开放接口。'}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => onDeleteClient(record)}
>
<Button type="link" size="small" danger disabled={record.status === 'active'}>
删除
</Button>
</Popconfirm>
</Space>
) : '-',
},
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<Space size={8}>
<KeyOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>API 密钥与开放接口鉴权</span>
<Tag color="blue" style={{ borderRadius: 10, padding: '0 8px', fontWeight: 600 }}>
已配额 {clients.length} / {clientMax}
</Tag>
</Space>
<Space size={8}>
<Button icon={<FileTextOutlined />} onClick={onNavigateDocs}>
对接文档
</Button>
{isAdmin && (
<Button icon={<CodeOutlined />} onClick={onNavigateDebug}>
接口调试
</Button>
)}
{canManage && (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={clients.length >= clientMax}
onClick={() => {
form.resetFields()
form.setFieldsValue({
signature_version: 'v1',
scopes: ['products:read', 'orders:read', 'orders:write'],
})
onOpenCreate()
}}
>
新增 API 密钥
</Button>
)}
</Space>
</div>
}
>
<div style={{ background: '#f8fafc', padding: '10px 14px', borderRadius: 8, marginBottom: 16, border: '1px solid #e2e8f0' }}>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
用于第三方分销系统或自建后台调用开放 API (商品查询、订单发货) 的 HMAC-SHA256 签名鉴权凭证。新生成的 App Secret 仅在创建成功时展示一次,请妥善保存。
</Typography.Text>
</div>
<Table
rowKey="id"
loading={loading}
columns={apiClientColumns}
dataSource={clients}
scroll={{ x: 1150 }}
pagination={false}
/>
</Card>
</Space>
)
}
export function CallbacksTab({
canManage,
callback,
form,
onSubmit,
onRotateSecret,
}: {
canManage: boolean
callback: CallbackSubscription | null
form: FormInstance
onSubmit: () => void
onRotateSecret: () => void
}) {
return (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<Space size={8}>
<SendOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>Webhook 异步回调订阅设置</span>
{callback?.status === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
推送已启用
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
推送已禁用
</span>
)}
</Space>
<Tag color="cyan" style={{ borderRadius: 6, fontWeight: 500 }}>
<SafetyCertificateOutlined style={{ marginRight: 4 }} /> HMAC-SHA256 签名保护
</Tag>
</div>
}
>
<div style={{ background: '#f8fafc', padding: '12px 16px', borderRadius: 8, marginBottom: 20, border: '1px solid #e2e8f0' }}>
<Row gutter={[16, 8]}>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}>重试机制</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
Outbox 队列 + 指数退避 (最多 16 次)
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}>推送信道签名 Header</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
X-Signature (HMAC-SHA256)
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}>推送超时标准</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
5000 ms 强制响应超时
</div>
</Col>
</Row>
</div>
<Form
form={form}
layout="vertical"
disabled={!canManage}
onFinish={onSubmit}
initialValues={defaultCallbackFormValues()}
>
<Form.Item
name="url"
label="回调接收端 URL"
rules={[
{ required: true, message: '请填写回调 URL' },
{ type: 'url', message: '请填写正确的 HTTP(S) 地址' },
]}
>
<Input
prefix={<LinkOutlined style={{ color: '#94a3b8' }} />}
placeholder="https://your-domain.com/api/v1/webhook"
allowClear
/>
</Form.Item>
<Form.Item name="events" label="订阅事件类型" rules={[{ required: true, message: '请选择至少一个回调事件' }]}>
<Select
mode="multiple"
placeholder="请选择订阅的事件列表"
options={eventOptions}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="status" label="回调状态" rules={[{ required: true }]}>
<Select
options={[
{ value: 'active', label: '启用回调推送' },
{ value: 'disabled', label: '禁用回调推送' },
]}
style={{ width: 200 }}
/>
</Form.Item>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 16,
borderTop: '1px solid #f1f5f9',
marginTop: 20,
flexWrap: 'wrap',
gap: 12,
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
{callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'}
</Typography.Text>
<Space size={12}>
{canManage && (
<Popconfirm
title="确认重置回调 Secret?"
description="重置后旧 Secret 立即失效,平台将使用新 Secret 进行 HMAC 签名推送,新 Secret 仅展示一次。"
okText="确认重置"
okButtonProps={{ danger: true }}
onConfirm={onRotateSecret}
>
<Button danger>重置 Secret</Button>
</Popconfirm>
)}
{canManage && (
<Button type="primary" htmlType="submit">
保存回调配置
</Button>
)}
</Space>
</div>
</Form>
</Card>
</Space>
)
}
export function MembersTab({
loading,
members,
roles,
canManage,
form,
onOpenAdd,
onOpenCreateRole,
onEditRole,
onEditMember,
onToggleMemberStatus,
onRemoveMember,
currentUserID,
}: {
loading: boolean
members: MerchantMember[]
roles: MerchantRole[]
canManage: boolean
form: FormInstance
onOpenAdd: () => void
onOpenCreateRole: () => void
onEditRole: (role: MerchantRole) => void
onEditMember: (member: MerchantMember) => void
onToggleMemberStatus: (member: MerchantMember) => void
onRemoveMember: (member: MerchantMember) => void
currentUserID?: number
}) {
const roleNames = new Map(roles.map((item) => [item.code, item.name]))
const memberColumns: ColumnsType<MerchantMember> = [
{ title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` },
{ title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' },
{ title: '角色', dataIndex: 'role', width: 140, render: (value) => <Tag color="blue">{roleNames.get(value) || roleText(value)}</Tag> },
{ title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? <Tag color="blue">默认</Tag> : '-') },
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>) },
{
title: '操作',
width: 210,
render: (_, member) => {
const protectedMember = member.role === 'owner' || member.user_id === currentUserID
if (!canManage) return '-'
if (protectedMember) return <Typography.Text type="secondary">受保护</Typography.Text>
const action = member.status === 1 ? '停用' : '启用'
return <Space size={0}>
<Button type="link" size="small" onClick={() => onEditMember(member)}>编辑</Button>
<Popconfirm title={`确认${action}该员工?`} onConfirm={() => onToggleMemberStatus(member)}>
<Button type="link" size="small">{action}</Button>
</Popconfirm>
<Popconfirm title="确认移除该员工?" description="仅移除当前商户成员关系,账号不会被删除。" onConfirm={() => onRemoveMember(member)} okButtonProps={{ danger: true }}>
<Button type="link" danger size="small">移除</Button>
</Popconfirm>
</Space>
},
},
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary">成员仅属于当前商户;角色和权限也在当前商户内独立配置。</Typography.Text>
{canManage && <Space>
<Button onClick={() => {
form.resetFields()
onOpenCreateRole()
}}>新建角色</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
form.resetFields()
onOpenAdd()
}}>添加员工</Button>
</Space>}
</Space>
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
<Card size="small" title="团队角色">
<Table
rowKey={(role) => role.id || role.code}
size="small"
pagination={false}
dataSource={roles}
columns={[
{ title: '角色', dataIndex: 'name', width: 160, render: (name, role) => <Space><Tag color={role.code === 'owner' ? 'gold' : 'blue'}>{name}</Tag>{role.code === 'owner' && <Typography.Text type="secondary">固定</Typography.Text>}</Space> },
{ title: '权限', dataIndex: 'permissions', render: (permissions) => permissions ? permissions.split(',').map((item: string) => <Tag key={item}>{rolePermissionText(item)}</Tag>) : '-' },
{ title: '操作', width: 90, render: (_, role) => role.code === 'owner' ? '-' : <Button type="link" size="small" disabled={!canManage} onClick={() => onEditRole(role)}>编辑</Button> },
]}
/>
</Card>
</Space>
)
}
function moneyWithSign(value?: number | null) {
const amount = Number(value || 0)
if (amount > 0) {
return <span style={{ fontWeight: 700, color: '#16a34a' }}>+{money(amount)}</span>
}
if (amount < 0) {
return <span style={{ fontWeight: 700, color: '#dc2626' }}>{money(amount)}</span>
}
return <span style={{ fontWeight: 600, color: '#64748b' }}>{money(amount)}</span>
}
function productStatusTag(value: string) {
return value === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
已上架
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
已下架
</span>
)
}
function activeStatusTag(value: string) {
return value === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
已启用
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
已禁用
</span>
)
}
function orderStatusTag(value: string) {
const item = orderStatusMap[value] || { color: 'default', text: value }
const tagClassMap: Record<string, string> = {
paid: 'status-tag--blue',
delivering: 'status-tag--cyan',
delivered: 'status-tag--green',
ship_failed: 'status-tag--red',
cancelled: 'status-tag--gray',
}
const tagClass = tagClassMap[value] || 'status-tag--gray'
return (
<span className={`status-tag ${tagClass}`}>
<span className="status-dot"></span>
{item.text}
</span>
)
}
function ledgerTypeTag(value: string) {
const map: Record<string, { color: string; text: string }> = {
credit: { color: 'green', text: '入账' },
debit: { color: 'red', text: '扣款' },
refund: { color: 'purple', text: '退款' },
adjust: { color: 'blue', text: '调整' },
}
const item = map[value] || { color: 'default', text: value }
return <Tag color={item.color}>{item.text}</Tag>
}
function scopesTag(value: string) {
const scopes = (value || '').split(',').filter(Boolean)
if (scopes.length === 0) {
return '-'
}
return (
<Space size={4} wrap>
{scopes.map((s) => <Tag key={s}>{scopeLabel(s)}</Tag>)}
</Space>
)
}